Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 0022d427d3 feat(integrations): add hindsight-opencode-coding plugin
Reflect-only long-term memory for coding agents in OpenCode, with a git+chat
backfill and (opt-in) live session write-back.

- reflect + INJECT: on a task, reflect() the symptom and push the root-cause
  answer into the system prompt (no tools/recall).
- backfill: every commit (full message + full diff, commit timestamp + git
  metadata) under a 'git' retain strategy; each chat as a JSON user/assistant
  transcript with custom extraction (<=2 coherent facts) under a 'chat' strategy;
  observations on; optional codebase knowledge pages.
- live write-back (opt-in HINDSIGHT_RETAIN_SESSIONS): every N turns upsert the
  tool-filtered transcript under a stable conversation:<sessionID> document_id.
2026-07-02 13:55:40 +02:00
Nicolò Boschi 1f9bad0858 feat(knowledge-base): default pages to living-document trigger + 4096 tokens
Client-created pages had no server curation applying a trigger, so they fell back
to the plain mental-model default (no refresh, full mode, all fact types). Make a
knowledge page a living document by default: when the client omits `trigger`, use
observation-only + delta + exclude_mental_models + refresh_after_consolidation;
when it omits `max_tokens`, default to 4096 (vs the mental-model 2048). Clients
can still override either.
2026-07-02 10:30:40 +02:00
Nicolò Boschi 138bf02f29 refactor(knowledge-base): drop server-side curation + folder missions
The knowledge base is now purely client-managed (CRUD over folders/pages); the
server no longer auto-curates. Removes the folder curator entirely and the
folder `mission` concept, and leads the sidebar with Knowledge Base.

- Remove engine/knowledge_curator.py, the curate_folder task (handler + dispatch
  + submit_async_curate_folder / _bank_folders), the post-consolidation curation
  hook, and the folder-create / mission-update curation triggers.
- Remove folder `mission` and `last_curated_at` (columns + engine + API + UI);
  keep `managed` as a client-set flag. Migration a5b6 now adds `managed` only;
  the last_curated_at migration is dropped and the unique-index migration
  repointed. Single alembic head preserved.
- API: KnowledgeNode/CreateFolderRequest/UpdateNodeRequest lose `mission`;
  PATCH node handles name/parent_id only.
- Control plane: sidebar leads with Knowledge Base (before Memories); remove the
  mission field, edit-mission dialog, and mission display from the KB view.
- Delete the curator tests; regenerate OpenAPI + SDK clients.
2026-07-02 10:30:39 +02:00
Nicolò Boschi df178aae8a refactor(hindsight-fs): mirror the knowledge-base tree, not mental models
Re-point hindsight-fs at the knowledge base so it projects a bank's folder/page
hierarchy as nested directories + .md files, instead of a flat list of mental
models.

- client: fetch GET /knowledge-base/tree + /export (two calls, any bank size)
  and join by page id; replaces the paginated mental-models list.
- format: planMirror() walks the tree into folder dirs + page files at nested
  paths (slug per segment, collision-safe); pages render the page's OKF doc.
- sync: create folder dirs, write pages at nested paths, prune removed pages and
  emptied folders; state keyed by relative path + tracked dirs.
- config/cli: drop the mental-model `detail` flag; `list` prints folders+pages;
  help/README updated. Tests rewritten for the tree/export model.

Verified live against a bank's knowledge base: the `people` folder mirrors to
people/anna.md + people/marco.md with OKF frontmatter.
2026-07-02 10:30:39 +02:00
Nicolò Boschi a43026b8f4 feat(hindsight-fs): mirror a bank's mental models as a live local folder
Add @vectorize-io/hindsight-fs, a CLI under hindsight-tools/ that mirrors a
Hindsight bank's mental models as real markdown files (YAML frontmatter + body)
in a local directory, refreshed from the API on an interval. Once mounted,
ordinary shell tools (ls, cat, grep, find, ...) work against current memory.

- Pull-based sync engine: full list each tick, write changed/new/tampered
  files, skip unchanged (content-hashed), prune deleted models. Atomic writes;
  a transient API error never wipes the mirror.
- One-way mirror enforced two ways: files are read-only (0444) so agent edits
  fail with EACCES, plus a tamper-revert backstop that compares on-disk bytes
  and overwrites drift on the next pass. --writable opts out.
- Commands: mount/start/stop/restart/sync/status/list/logs/unmount. Background
  daemon via detached process + pidfile; per-mount config is remembered.
- status doubles as a healthcheck: --json report and a non-zero exit when the
  mount is dead/failed/stale (--stale-after overrides the threshold).
- Tests: unit (sync engine, frontmatter, health) + e2e that spawns the real
  CLI against a mock API and exercises real bash commands. 26 tests.
2026-07-02 10:30:39 +02:00
Nicolò Boschi 5c425e276e feat(knowledge-base): self-curating knowledge base (OKF pages + folder missions)
Server-side knowledge base: a hierarchy of folders and pages over mental
models, projected to the Open Knowledge Format, with a mission-driven curator
that maintains pages automatically after each consolidation.

- knowledge_pages table (PG + Oracle): parent_id tree, kind folder/page,
  mission, managed, last_curated_at; partial unique index on (folder, name)
  for concurrency-safe dedup; added to BACKUP_TABLES.
- api/okf.py: OKF serializer (frontmatter + body, index/log, constellation graph).
- engine/knowledge_curator.py: folder curator (LLM op plan + safe apply); reads
  new memories since last curation (delta, not recall); ops create/merge/delete
  page + spawn sub-folder (bounded depth<=3, <=8). Runs as an async curate_folder
  task on folder/mission create and after consolidation. Curator pages use an
  observation-only delta trigger with exclude_mental_models.
- MemoryEngine: folder/page CRUD, tree, curate, async submit + worker handler.
- /v1/default/banks/{bank}/knowledge-base/* endpoints.
- Control plane: knowledge-base tree view + constellation toggle, missions,
  OKF page panel + bundle export; proxies, client, sidebar, i18n.
- Tests: okf unit, knowledge-base HTTP, curator apply + dedup guard, hs_llm_core e2e.
- Regenerated OpenAPI + SDK clients + docs-skill.
2026-07-02 10:30:39 +02:00
91 changed files with 18347 additions and 14 deletions
@@ -56,6 +56,7 @@ BACKUP_TABLES = [
"observation_history",
"mental_models",
"mental_model_history",
"knowledge_pages",
"directives",
"async_operations",
"webhooks",
@@ -0,0 +1,52 @@
"""Add managed flag to knowledge_pages.
The knowledge base is managed by clients (CRUD over folders/pages). ``managed``
lets a client tag a node as system-owned vs. hand-authored; it carries no
server-side behaviour.
Revision ID: a5b6c7d8e9f0
Revises: a9b8c7d6e5f4
Create Date: 2026-06-26
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a5b6c7d8e9f0"
down_revision: str | Sequence[str] | None = "a9b8c7d6e5f4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}knowledge_pages ADD COLUMN IF NOT EXISTS managed BOOLEAN NOT NULL DEFAULT false")
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}knowledge_pages DROP COLUMN IF EXISTS managed")
def _oracle_upgrade() -> None:
op.execute("ALTER TABLE knowledge_pages ADD (managed NUMBER(1) DEFAULT 0 NOT NULL)")
def _oracle_downgrade() -> None:
op.execute("ALTER TABLE knowledge_pages DROP COLUMN managed")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -0,0 +1,110 @@
"""Add knowledge_pages table (knowledge-base hierarchy).
The knowledge base organizes synthesized mental models into a navigable tree of
**folders** and **pages**. A page references the mental model that holds its
content (``mental_model_id``); a folder is a pure container (``mental_model_id``
NULL). Hierarchy is a single self-referential ``parent_id`` so folders can nest
arbitrarily. Content stays in ``mental_models`` — this table is metadata + tree
structure only.
Revision ID: a9b8c7d6e5f4
Revises: b57a7c9e0d13
Create Date: 2026-06-25
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a9b8c7d6e5f4"
down_revision: str | Sequence[str] | None = "b57a7c9e0d13"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# parent_id self-FK cascades so deleting a folder row removes its whole
# subtree of rows in one shot. The mental_model FK is composite (matches the
# mental_models (id, bank_id) PK) and cascades too, so deleting a page's
# mental model removes the page row — folders skip the FK because a NULL
# column in a composite FK is not enforced (MATCH SIMPLE).
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}knowledge_pages (
id VARCHAR(64) NOT NULL,
bank_id TEXT NOT NULL,
parent_id VARCHAR(64),
kind VARCHAR(16) NOT NULL,
name TEXT NOT NULL,
mental_model_id VARCHAR(64),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT pk_knowledge_pages PRIMARY KEY (id),
CONSTRAINT ck_knowledge_pages_kind CHECK (kind IN ('folder', 'page')),
CONSTRAINT fk_kp_bank FOREIGN KEY (bank_id)
REFERENCES {schema}banks(bank_id) ON DELETE CASCADE,
CONSTRAINT fk_kp_parent FOREIGN KEY (parent_id)
REFERENCES {schema}knowledge_pages(id) ON DELETE CASCADE,
CONSTRAINT fk_kp_mm FOREIGN KEY (mental_model_id, bank_id)
REFERENCES {schema}mental_models(id, bank_id) ON DELETE CASCADE
)
"""
)
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_kp_bank_parent ON {schema}knowledge_pages (bank_id, parent_id, sort_order)"
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_kp_bank_parent")
op.execute(f"DROP TABLE IF EXISTS {schema}knowledge_pages")
def _oracle_upgrade() -> None:
op.execute(
"""
CREATE TABLE IF NOT EXISTS knowledge_pages (
id VARCHAR2(64) NOT NULL,
bank_id VARCHAR2(256) NOT NULL,
parent_id VARCHAR2(64),
kind VARCHAR2(16) NOT NULL,
name CLOB NOT NULL,
mental_model_id VARCHAR2(64),
sort_order NUMBER DEFAULT 0 NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_knowledge_pages PRIMARY KEY (id),
CONSTRAINT ck_knowledge_pages_kind CHECK (kind IN ('folder', 'page')),
CONSTRAINT fk_kp_bank FOREIGN KEY (bank_id)
REFERENCES banks(bank_id) ON DELETE CASCADE,
CONSTRAINT fk_kp_parent FOREIGN KEY (parent_id)
REFERENCES knowledge_pages(id) ON DELETE CASCADE,
CONSTRAINT fk_kp_mm FOREIGN KEY (mental_model_id, bank_id)
REFERENCES mental_models(id, bank_id) ON DELETE CASCADE
)
"""
)
op.execute("CREATE INDEX idx_kp_bank_parent ON knowledge_pages (bank_id, parent_id, sort_order)")
def _oracle_downgrade() -> None:
op.execute("DROP TABLE knowledge_pages CASCADE CONSTRAINTS")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -0,0 +1,71 @@
"""Unique page name per folder in knowledge_pages.
The folder curator can fire concurrently (folder-create trigger + the
post-consolidation sweep), and an in-process lock can't serialize runs that
execute in different threads/loops. A partial unique index on
(bank_id, parent, lower(name)) for pages makes duplicate-named pages in the same
folder impossible at the DB level — the second concurrent insert fails and the
curator treats it as "already exists".
PostgreSQL only: the Oracle ``name`` column is a CLOB and cannot back a
functional unique index; Oracle relies on the in-process serialization instead.
Revision ID: c3d4e5f6a7b8
Revises: a5b6c7d8e9f0
Create Date: 2026-06-26
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c3d4e5f6a7b8"
down_revision: str | Sequence[str] | None = "a5b6c7d8e9f0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# First drop any pre-existing duplicate pages (created by the racy curator
# before this guard existed), keeping the earliest row of each duplicate set,
# so the unique index can be built. Their backing mental models are left in
# place (harmless orphans).
op.execute(
f"""
DELETE FROM {schema}knowledge_pages a
USING {schema}knowledge_pages b
WHERE a.kind = 'page' AND b.kind = 'page'
AND a.bank_id = b.bank_id
AND COALESCE(a.parent_id, '') = COALESCE(b.parent_id, '')
AND lower(a.name) = lower(b.name)
AND a.ctid > b.ctid
"""
)
# COALESCE(parent_id, '') so root-level pages (NULL parent) are also unique by
# name — NULLs would otherwise compare distinct and allow duplicates.
op.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_kp_folder_pagename "
f"ON {schema}knowledge_pages (bank_id, COALESCE(parent_id, ''), lower(name)) "
"WHERE kind = 'page'"
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}uq_kp_folder_pagename")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade) # oracle slot intentionally absent (CLOB name)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -18,6 +18,7 @@ from typing import Any, Literal, TypeVar
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, Request, UploadFile
from fastapi.middleware.gzip import GZipMiddleware
from hindsight_api.api import okf
from hindsight_api.api.disconnect import ClientDisconnectCancellationMiddleware, get_scope_cancellation_token
from hindsight_api.cancellation import OperationCancelledError
from hindsight_api.engine.audit import (
@@ -2110,6 +2111,150 @@ class MentalModelListResponse(BaseModel):
items: list[MentalModelResponse]
# =========================================================================
# KNOWLEDGE BASE (folders + pages over mental models, projected to OKF)
# =========================================================================
class KnowledgeNode(BaseModel):
"""A node in the knowledge-base tree — a folder or a page.
Pages carry ``description``/``tags`` from their backing mental model. The
knowledge base is client-managed (CRUD); ``managed`` lets a client tag a node
as system-owned vs. hand-authored.
"""
id: str
kind: Literal["folder", "page"]
name: str
parent_id: str | None = None
mental_model_id: str | None = Field(default=None, description="Backing mental model id (pages only).")
managed: bool = Field(default=False, description="Client-set flag: true = system-owned, false = hand-authored.")
description: str | None = Field(default=None, description="Page source query (OKF `description`).")
tags: list[str] = FieldWithDefault(list)
timestamp: str | None = Field(default=None, description="Last refresh (page) or last update (folder).")
children: list["KnowledgeNode"] = FieldWithDefault(list)
class KnowledgeTreeResponse(BaseModel):
"""The knowledge base as a nested folder/page tree."""
roots: list[KnowledgeNode]
class CreateFolderRequest(BaseModel):
"""Create a folder under an optional parent folder."""
name: str
parent_id: str | None = None
class CreatePageRequest(BaseModel):
"""Create a page (a mental model + tree node) under an optional parent folder."""
name: str
source_query: str
parent_id: str | None = None
tags: list[str] | None = None
max_tokens: int | None = None
trigger: MentalModelTrigger | None = None
class UpdateNodeRequest(BaseModel):
"""Rename and/or move a node. Each field applies only when present."""
name: str | None = None
parent_id: str | None = None
class CreateKnowledgePageResponse(BaseModel):
"""Result of creating a page: the node id, its mental model, and the refresh op."""
page_id: str
mental_model_id: str
operation_id: str | None = None
class KnowledgePageResponse(BaseModel):
"""A knowledge page rendered as an OKF document."""
id: str
name: str
type: str = Field(description="OKF document type — from a `type:<x>` tag, else 'knowledge-page'.")
description: str | None = Field(default=None, description="The source query that rebuilds the page.")
tags: list[str] = FieldWithDefault(list)
timestamp: str | None = Field(default=None, description="Last refresh time (falls back to creation).")
body: str | None = Field(default=None, description="The page's synthesized markdown body.")
markdown: str = Field(description="The full OKF document: YAML frontmatter + markdown body.")
class KnowledgePageGraphResponse(BaseModel):
"""Constellation graph of knowledge pages linked by shared tags."""
nodes: list[dict[str, Any]]
edges: list[dict[str, Any]]
total_pages: int
total_edges: int
class KnowledgePageBundleFile(BaseModel):
"""One file in a portable OKF bundle."""
path: str
content: str
class KnowledgePageBundleResponse(BaseModel):
"""A portable OKF bundle — a flat set of markdown files (index + pages + logs)."""
files: list[KnowledgePageBundleFile]
def _knowledge_node_model(node: dict[str, Any]) -> KnowledgeNode:
"""Project an engine node dict into a (childless) KnowledgeNode."""
is_page = node.get("kind") == "page"
return KnowledgeNode(
id=node["id"],
kind=node["kind"],
name=node["name"],
parent_id=node.get("parent_id"),
mental_model_id=node.get("mental_model_id"),
managed=bool(node.get("managed")),
description=node.get("source_query") if is_page else None,
tags=list(node.get("tags") or []) if is_page else [],
timestamp=(node.get("last_refreshed_at") if is_page else node.get("updated_at")),
)
def _build_knowledge_tree(nodes: list[dict[str, Any]]) -> list[KnowledgeNode]:
"""Assemble the flat node list into a nested tree of roots."""
models = {n["id"]: _knowledge_node_model(n) for n in nodes}
roots: list[KnowledgeNode] = []
for node in nodes:
model = models[node["id"]]
parent_id = node.get("parent_id")
if parent_id and parent_id in models:
models[parent_id].children.append(model)
else:
roots.append(model)
return roots
def _knowledge_page_response(node: dict[str, Any]) -> KnowledgePageResponse:
"""Project a page node (with merged mental-model content) into an OKF document."""
page = okf.page_type(node.get("tags"))
return KnowledgePageResponse(
id=node["id"],
name=node["name"],
type=page.type,
description=node.get("source_query"),
tags=page.display_tags,
timestamp=node.get("last_refreshed_at") or node.get("created_at"),
body=node.get("content"),
markdown=okf.render_document(node),
)
class CreateMentalModelRequest(BaseModel):
"""Request model for creating a mental model."""
@@ -4781,6 +4926,333 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/mental-models/{mental_model_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
# =========================================================================
# KNOWLEDGE BASE ENDPOINTS (folders + pages, Open Knowledge Format)
# =========================================================================
# A hierarchy of folders and pages over mental models. Pages project to OKF
# documents (markdown body + YAML frontmatter); see api/okf.py. The static
# sub-paths (/tree, /folders, /pages, /graph, /export) are declared before
# the /pages/{id} and /nodes/{id} path-parameter routes so they win.
@app.get(
"/v1/default/banks/{bank_id}/knowledge-base/tree",
response_model=KnowledgeTreeResponse,
summary="Get the knowledge-base tree",
description="Return the knowledge base as a nested tree of folders and pages.",
operation_id="get_knowledge_base_tree",
tags=["Knowledge Base"],
)
async def api_knowledge_base_tree(
bank_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Return the folder/page tree for a bank."""
try:
nodes = await app.state.memory.list_knowledge_nodes(bank_id=bank_id, request_context=request_context)
return KnowledgeTreeResponse(roots=_build_knowledge_tree(nodes))
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in GET /v1/default/banks/{bank_id}/knowledge-base/tree: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/knowledge-base/folders",
response_model=KnowledgeNode,
status_code=201,
summary="Create a knowledge-base folder",
description="Create a folder, optionally nested under a parent folder.",
operation_id="create_knowledge_folder",
tags=["Knowledge Base"],
)
async def api_create_knowledge_folder(
bank_id: str,
body: CreateFolderRequest,
request_context: RequestContext = Depends(get_request_context),
):
"""Create a folder node."""
try:
node = await app.state.memory.create_knowledge_folder(
bank_id=bank_id,
name=body.name,
parent_id=body.parent_id,
request_context=request_context,
)
return _knowledge_node_model(node)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in POST /v1/default/banks/{bank_id}/knowledge-base/folders: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/knowledge-base/pages",
response_model=CreateKnowledgePageResponse,
status_code=201,
summary="Create a knowledge-base page",
description="Create a page (a mental model + tree node). Content is generated asynchronously; "
"use the returned operation_id to track completion.",
operation_id="create_knowledge_page",
tags=["Knowledge Base"],
)
async def api_create_knowledge_page(
bank_id: str,
body: CreatePageRequest,
request_context: RequestContext = Depends(get_request_context),
):
"""Create a page node (async content generation)."""
try:
node = await app.state.memory.create_knowledge_page(
bank_id=bank_id,
name=body.name,
source_query=body.source_query,
content="Generating content...",
parent_id=body.parent_id,
tags=body.tags if body.tags else None,
max_tokens=body.max_tokens,
trigger=body.trigger.model_dump() if body.trigger else None,
request_context=request_context,
)
if node is None:
raise HTTPException(status_code=409, detail=f"A page named '{body.name}' already exists in this folder")
result = await app.state.memory.submit_async_refresh_mental_model(
bank_id=bank_id,
mental_model_id=node["mental_model_id"],
request_context=request_context,
)
return CreateKnowledgePageResponse(
page_id=node["id"],
mental_model_id=node["mental_model_id"],
operation_id=result["operation_id"],
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in POST /v1/default/banks/{bank_id}/knowledge-base/pages: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/knowledge-base/graph",
response_model=KnowledgePageGraphResponse,
summary="Knowledge-base constellation graph",
description="Return pages as nodes linked by shared tags, for the constellation view.",
operation_id="get_knowledge_base_graph",
tags=["Knowledge Base"],
)
async def api_knowledge_base_graph(
bank_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Return the shared-tag constellation graph for a bank's pages."""
try:
nodes = await app.state.memory.list_knowledge_nodes(bank_id=bank_id, request_context=request_context)
pages = [n for n in nodes if n.get("kind") == "page"]
# Cluster the constellation by parent folder (the knowledge base's own
# structure) rather than by the retired type: tag.
folder_names = {n["id"]: n["name"] for n in nodes if n.get("kind") == "folder"}
graph = okf.knowledge_graph(pages, cluster_for=lambda p: folder_names.get(p.get("parent_id"), "Ungrouped"))
return KnowledgePageGraphResponse(
nodes=graph.nodes,
edges=graph.edges,
total_pages=len(graph.nodes),
total_edges=len(graph.edges),
)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in GET /v1/default/banks/{bank_id}/knowledge-base/graph: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/knowledge-base/export",
response_model=KnowledgePageBundleResponse,
summary="Export the knowledge base as an OKF bundle",
description="Return a portable OKF bundle: a nested index.md, one <id>.md per page, and history logs.",
operation_id="export_knowledge_base",
tags=["Knowledge Base"],
)
async def api_export_knowledge_base(
bank_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Export a bank's knowledge base as a flat OKF markdown bundle."""
try:
nodes = await app.state.memory.list_knowledge_nodes(bank_id=bank_id, request_context=request_context)
files = [KnowledgePageBundleFile(path=okf.INDEX_FILENAME, content=okf.render_index(nodes))]
for node in nodes:
if node.get("kind") != "page":
continue
page = await app.state.memory.get_knowledge_page(
bank_id=bank_id, page_id=node["id"], request_context=request_context
)
if page is None:
continue
files.append(
KnowledgePageBundleFile(path=okf.page_filename(node["id"]), content=okf.render_document(page))
)
if node.get("mental_model_id"):
history = (
await app.state.memory.get_mental_model_history(
bank_id=bank_id,
mental_model_id=node["mental_model_id"],
request_context=request_context,
)
or []
)
if history:
files.append(
KnowledgePageBundleFile(
path=okf.log_filename(node["id"]), content=okf.render_log(page, history)
)
)
return KnowledgePageBundleResponse(files=files)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in GET /v1/default/banks/{bank_id}/knowledge-base/export: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}",
response_model=KnowledgePageResponse,
summary="Get a knowledge-base page",
description="Return a single page as an OKF document (frontmatter + markdown body).",
operation_id="get_knowledge_page",
tags=["Knowledge Base"],
)
async def api_get_knowledge_page(
bank_id: str,
page_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Get a single page as an OKF document."""
try:
node = await app.state.memory.get_knowledge_page(
bank_id=bank_id, page_id=page_id, request_context=request_context
)
if node is None:
raise HTTPException(status_code=404, detail=f"Knowledge page '{page_id}' not found")
return _knowledge_page_response(node)
except (AuthenticationError, HTTPException):
raise
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in GET /v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.patch(
"/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}",
response_model=KnowledgeNode,
summary="Rename or move a knowledge-base node",
description="Rename a node (set `name`) and/or move it under another folder (set `parent_id`, "
"null for the root).",
operation_id="update_knowledge_node",
tags=["Knowledge Base"],
)
async def api_update_knowledge_node(
bank_id: str,
node_id: str,
body: UpdateNodeRequest,
request_context: RequestContext = Depends(get_request_context),
):
"""Rename and/or move a node."""
try:
updated: dict[str, Any] | None = None
did_change = False
if body.name is not None:
did_change = True
updated = await app.state.memory.rename_knowledge_node(
bank_id=bank_id, node_id=node_id, name=body.name, request_context=request_context
)
# parent_id is applied only when present in the body, so passing null
# moves the node to the root (distinct from "not provided").
if "parent_id" in body.model_fields_set:
did_change = True
updated = await app.state.memory.move_knowledge_node(
bank_id=bank_id, node_id=node_id, new_parent_id=body.parent_id, request_context=request_context
)
if not did_change:
raise HTTPException(status_code=400, detail="Provide name and/or parent_id to update")
if updated is None:
raise HTTPException(status_code=404, detail=f"Knowledge node '{node_id}' not found")
return _knowledge_node_model(updated)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except (AuthenticationError, HTTPException):
raise
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.delete(
"/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}",
summary="Delete a knowledge-base node",
description="Delete a folder or page and its whole subtree (pages' mental models are removed too).",
operation_id="delete_knowledge_node",
tags=["Knowledge Base"],
)
async def api_delete_knowledge_node(
bank_id: str,
node_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Delete a node and its subtree."""
try:
deleted = await app.state.memory.delete_knowledge_node(
bank_id=bank_id, node_id=node_id, request_context=request_context
)
if not deleted:
raise HTTPException(status_code=404, detail=f"Knowledge node '{node_id}' not found")
return {"status": "deleted"}
except (AuthenticationError, HTTPException):
raise
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
# =========================================================================
# DIRECTIVES ENDPOINTS
# =========================================================================
+263
View File
@@ -0,0 +1,263 @@
"""Open Knowledge Format (OKF) projection for knowledge pages.
Knowledge pages are a *read-only* OKF view over the existing mental models: each
mental model is projected into an OKF document — a markdown body with YAML
frontmatter (``type`` required; ``title``/``description``/``tags``/``timestamp``
optional) — and pages are linked into a constellation graph via shared tags.
See the Open Knowledge Format spec:
https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf
This module is intentionally pure: every function transforms the mental-model
dicts returned by ``MemoryEngine.list_mental_models`` / ``get_mental_model`` and
never touches the database. That keeps the OKF contract unit-testable without a
DB or LLM and lets the HTTP layer stay a thin wrapper.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
# OKF requires exactly one frontmatter field — ``type``. We default to this when
# a page does not declare one via a ``type:<x>`` tag.
DEFAULT_PAGE_TYPE = "knowledge-page"
# A page declares its OKF ``type`` through a tag of the form ``type:runbook``.
# This keeps the projection schema-free (no new mental_models column): the type
# is lifted from the existing tags array.
TYPE_TAG_PREFIX = "type:"
INDEX_FILENAME = "index.md"
# Deterministic, colour-blind-friendly palette. Type → colour is stable across
# requests so the constellation keeps the same colours between reloads.
_PALETTE = (
"#0074d9", # blue
"#2ecc40", # green
"#b10dc9", # purple
"#ff851b", # orange
"#39cccc", # teal
"#f012be", # magenta
"#3d9970", # olive
"#ff4136", # red
)
_EDGE_COLOR = "#9aa5b1"
@dataclass(frozen=True)
class PageType:
"""A page's OKF ``type`` and the tags that remain after the type tag is split off."""
type: str
display_tags: list[str]
@dataclass(frozen=True)
class KnowledgeGraph:
"""Cytoscape-style node/edge graph of knowledge pages linked by shared tags."""
nodes: list[dict[str, Any]] = field(default_factory=list)
edges: list[dict[str, Any]] = field(default_factory=list)
def _color_for(key: str) -> str:
"""Stable colour for a string key (FNV-ish hash into the fixed palette)."""
h = 0
for ch in key:
h = (h * 31 + ord(ch)) & 0xFFFFFFFF
return _PALETTE[h % len(_PALETTE)]
def _scalar(value: Any) -> str:
"""Emit a YAML-safe double-quoted scalar.
We always double-quote so arbitrary page names / source queries can't be
misread as YAML special forms (``true``, ``2026-01-01``, ``- x``, etc.).
"""
text = str(value)
escaped = text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "")
return f'"{escaped}"'
def page_type(tags: list[str] | None) -> PageType:
"""Split an OKF ``type`` out of the tag list.
The first ``type:<x>`` tag wins; all ``type:`` tags are removed from the
returned ``display_tags`` so they don't pollute the constellation's
shared-tag edges. Falls back to :data:`DEFAULT_PAGE_TYPE`.
"""
resolved = DEFAULT_PAGE_TYPE
display: list[str] = []
for tag in tags or []:
if tag.startswith(TYPE_TAG_PREFIX):
suffix = tag[len(TYPE_TAG_PREFIX) :].strip()
if suffix and resolved == DEFAULT_PAGE_TYPE:
resolved = suffix
continue
display.append(tag)
return PageType(type=resolved, display_tags=display)
def _timestamp(mm: dict[str, Any]) -> str | None:
return mm.get("last_refreshed_at") or mm.get("created_at")
def frontmatter(mm: dict[str, Any]) -> dict[str, Any]:
"""Build the ordered OKF frontmatter mapping for a mental model.
``None``/empty values are dropped by :func:`render_frontmatter`.
"""
pt = page_type(mm.get("tags"))
return {
"id": mm.get("id"),
"type": pt.type,
"title": mm.get("name"),
"description": mm.get("source_query"),
"tags": pt.display_tags,
"timestamp": _timestamp(mm),
}
def render_frontmatter(fm: dict[str, Any]) -> str:
"""Render a frontmatter mapping into a ``---`` fenced YAML block."""
lines = ["---"]
for key, value in fm.items():
if value is None:
continue
if isinstance(value, list):
if not value:
continue
lines.append(f"{key}:")
lines.extend(f" - {_scalar(item)}" for item in value)
else:
lines.append(f"{key}: {_scalar(value)}")
lines.append("---")
return "\n".join(lines)
def render_document(mm: dict[str, Any]) -> str:
"""Render a full OKF document: frontmatter block + markdown body."""
body = (mm.get("content") or "").strip()
return f"{render_frontmatter(frontmatter(mm))}\n\n{body}\n" if body else f"{render_frontmatter(frontmatter(mm))}\n"
def page_filename(page_id: str) -> str:
"""OKF bundle filename for a page id."""
return f"{page_id}.md"
def log_filename(page_id: str) -> str:
"""OKF reserved per-page history filename."""
return f"{page_id}.log.md"
def render_index(nodes: list[dict[str, Any]]) -> str:
"""Render the reserved ``index.md`` — nested OKF navigation over the tree.
``nodes`` is the flat folder/page list (each with ``id``, ``kind``, ``name``,
``parent_id``); folders nest their children, pages link to their ``.md``.
"""
fm = render_frontmatter({"type": "index", "title": "Knowledge base"})
lines = [fm, "", "# Knowledge base", ""]
children: dict[Any, list[dict[str, Any]]] = {}
for node in nodes:
children.setdefault(node.get("parent_id"), []).append(node)
def walk(parent: Any, depth: int) -> None:
ordered = sorted(children.get(parent, []), key=lambda n: (n.get("sort_order", 0), n.get("name") or ""))
for node in ordered:
indent = " " * depth
if node.get("kind") == "folder":
lines.append(f"{indent}- **{node['name']}/**")
walk(node["id"], depth + 1)
else:
description = node.get("source_query") or node.get("description")
link = f"{indent}- [{node['name']}](./{page_filename(node['id'])})"
lines.append(f"{link}{description}" if description else link)
walk(None, 0)
if len(lines) == 4:
lines.append("_No knowledge pages yet._")
return "\n".join(lines) + "\n"
def render_log(mm: dict[str, Any], history: list[dict[str, Any]]) -> str:
"""Render the reserved per-page ``log.md`` from refresh history.
Each history entry is ``{previous_content, previous_reflect_response,
changed_at}`` (newest first), capturing the content *before* a refresh.
"""
name = mm.get("name") or mm.get("id")
fm = render_frontmatter({"type": "log", "title": f"{name} — history"})
lines = [fm, "", f"# {name} — history", ""]
if not history:
lines.append("_No refresh history._")
return "\n".join(lines) + "\n"
for entry in history:
changed_at = entry.get("changed_at") or "unknown"
previous = (entry.get("previous_content") or "").strip()
lines.append(f"## {changed_at}")
lines.append("")
lines.append(previous if previous else "_(empty)_")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def knowledge_graph(
pages: list[dict[str, Any]],
cluster_for: "Callable[[dict[str, Any]], str] | None" = None,
) -> KnowledgeGraph:
"""Derive the constellation graph: pages as nodes, shared tags as edges.
Two pages are linked when they share at least one (non-``type:``) tag; the
edge weight is the number of shared tags. Each node's cluster (``type`` field
+ colour) comes from ``cluster_for(page)`` — the knowledge base groups by
parent folder; the default groups by OKF ``type``.
"""
nodes: list[dict[str, Any]] = []
tag_sets: list[tuple[str, frozenset[str]]] = []
for mm in pages:
page_id = mm["id"]
pt = page_type(mm.get("tags"))
cluster = cluster_for(mm) if cluster_for else pt.type
tag_sets.append((page_id, frozenset(pt.display_tags)))
nodes.append(
{
"data": {
"id": page_id,
"label": mm.get("name") or page_id,
"type": cluster,
"tagCount": len(pt.display_tags),
"color": _color_for(cluster),
}
}
)
edges: list[dict[str, Any]] = []
for i in range(len(tag_sets)):
source_id, source_tags = tag_sets[i]
if not source_tags:
continue
for j in range(i + 1, len(tag_sets)):
target_id, target_tags = tag_sets[j]
shared = source_tags & target_tags
if not shared:
continue
edges.append(
{
"data": {
"id": f"{source_id}--{target_id}",
"source": source_id,
"target": target_id,
"sharedTags": sorted(shared),
"weight": len(shared),
"color": _EDGE_COLOR,
}
}
)
return KnowledgeGraph(nodes=nodes, edges=edges)
@@ -11119,6 +11119,335 @@ class MemoryEngine(MemoryEngineInterface):
return result == "DELETE 1"
# =====================================================================
# KNOWLEDGE BASE (folders + pages over mental models)
# =====================================================================
# The knowledge base is a tree of folders and pages stored in
# ``knowledge_pages``. A page references the mental model holding its content
# (``mental_model_id``); a folder is a container (``mental_model_id`` NULL).
# Content lives in ``mental_models`` — this layer owns only tree structure.
# Default trigger for a knowledge page: a living document synthesized from the
# bank's consolidated **observations** (not raw facts), refreshed incrementally
# (delta) after each consolidation, and excluding other mental models so a page
# never reflects on sibling pages. Applied when the client doesn't pass its own
# ``trigger`` on create; a client can override any of these.
KNOWLEDGE_PAGE_DEFAULT_TRIGGER = {
"mode": "delta",
"fact_types": ["observation"],
"exclude_mental_models": True,
"refresh_after_consolidation": True,
}
# Knowledge pages default to a larger budget than a plain mental model (2048)
# since they're meant to read as full documents. Applied when the client
# doesn't pass ``max_tokens`` on create.
KNOWLEDGE_PAGE_DEFAULT_MAX_TOKENS = 4096
@staticmethod
def _row_to_knowledge_node(row) -> dict[str, Any]:
"""Project a knowledge_pages row (optionally joined to its mental model)."""
node: dict[str, Any] = {
"id": row["id"],
"bank_id": row["bank_id"],
"parent_id": row["parent_id"],
"kind": row["kind"],
"name": row["name"],
"mental_model_id": row["mental_model_id"],
"sort_order": row["sort_order"],
"managed": (row["managed"] if "managed" in row else False),
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
}
# Page rows are returned LEFT JOINed to mental_models so the OKF
# projection (type/tags/description) needs no second round-trip.
if "mm_tags" in row:
node["tags"] = list(row["mm_tags"] or [])
node["source_query"] = row["mm_source_query"]
node["last_refreshed_at"] = row["mm_last_refreshed_at"].isoformat() if row["mm_last_refreshed_at"] else None
return node
# Column list for plain (non-joined) knowledge_pages reads/RETURNING.
_KP_COLUMNS = "id, bank_id, parent_id, kind, name, mental_model_id, sort_order, managed, created_at, updated_at"
_KP_PAGE_SELECT = (
"kp.id, kp.bank_id, kp.parent_id, kp.kind, kp.name, kp.mental_model_id, "
"kp.sort_order, kp.managed, kp.created_at, kp.updated_at, "
"mm.tags AS mm_tags, mm.source_query AS mm_source_query, "
"mm.last_refreshed_at AS mm_last_refreshed_at"
)
def _kp_join(self) -> str:
kp = fq_table("knowledge_pages")
mm = fq_table("mental_models")
return f"{kp} kp LEFT JOIN {mm} mm ON mm.id = kp.mental_model_id AND mm.bank_id = kp.bank_id"
async def _kp_assert_folder_parent(self, conn, bank_id: str, parent_id: str | None) -> None:
"""A non-null parent must be an existing folder in this bank."""
if parent_id is None:
return
row = await conn.fetchrow(
f"SELECT kind FROM {fq_table('knowledge_pages')} WHERE bank_id = $1 AND id = $2",
bank_id,
parent_id,
)
if row is None:
raise ValueError(f"Parent folder '{parent_id}' not found")
if row["kind"] != "folder":
raise ValueError(f"Parent '{parent_id}' is not a folder")
async def create_knowledge_folder(
self,
bank_id: str,
name: str,
*,
parent_id: str | None = None,
managed: bool = False,
request_context: "RequestContext",
) -> dict[str, Any]:
"""Create a folder (a container node) in the knowledge base.
The knowledge base is managed by clients (CRUD over folders/pages);
``managed`` lets a client tag a node as system-owned vs. hand-authored.
"""
await self._authenticate_tenant(request_context)
backend = await self._get_backend()
folder_id = f"kf-{uuid.uuid4().hex}"
async with acquire_with_retry(backend) as conn:
async with conn.transaction():
await self._ensure_bank_exists(bank_id, request_context, conn=conn)
await self._kp_assert_folder_parent(conn, bank_id, parent_id)
row = await conn.fetchrow(
f"""
INSERT INTO {fq_table("knowledge_pages")} (id, bank_id, parent_id, kind, name, managed)
VALUES ($1, $2, $3, 'folder', $4, $5)
RETURNING {self._KP_COLUMNS}
""",
folder_id,
bank_id,
parent_id,
name,
managed,
)
return self._row_to_knowledge_node(row)
async def create_knowledge_page(
self,
bank_id: str,
name: str,
source_query: str,
content: str,
*,
parent_id: str | None = None,
tags: list[str] | None = None,
max_tokens: int | None = None,
trigger: dict[str, Any] | None = None,
mental_model_id: str | None = None,
managed: bool = False,
request_context: "RequestContext",
) -> dict[str, Any] | None:
"""Create a page: a backing mental model plus the tree node that refs it.
``managed`` lets a client tag the page as system-owned vs. hand-authored.
When ``trigger`` is omitted the page uses ``KNOWLEDGE_PAGE_DEFAULT_TRIGGER``
(observation-only, delta, auto-refresh) so a knowledge page is a living
document by default.
Returns ``None`` when a page with the same name already exists in the same
folder (a uniqueness violation) the caller should treat that as
"already exists" (surfaced by the API as a 409).
"""
await self._authenticate_tenant(request_context)
# The mental model carries the content (and is created+validated by the
# existing path, including lazy bank creation); the node only refs it.
mm = await self.create_mental_model(
bank_id=bank_id,
name=name,
source_query=source_query,
content=content,
mental_model_id=mental_model_id,
tags=tags,
max_tokens=max_tokens if max_tokens is not None else self.KNOWLEDGE_PAGE_DEFAULT_MAX_TOKENS,
trigger=trigger if trigger is not None else dict(self.KNOWLEDGE_PAGE_DEFAULT_TRIGGER),
request_context=request_context,
)
backend = await self._get_backend()
page_id = f"kp-{uuid.uuid4().hex}"
try:
async with acquire_with_retry(backend) as conn:
async with conn.transaction():
await self._kp_assert_folder_parent(conn, bank_id, parent_id)
row = await conn.fetchrow(
f"""
INSERT INTO {fq_table("knowledge_pages")}
(id, bank_id, parent_id, kind, name, mental_model_id, managed)
VALUES ($1, $2, $3, 'page', $4, $5, $6)
RETURNING {self._KP_COLUMNS}
""",
page_id,
bank_id,
parent_id,
name,
mm["id"],
managed,
)
except asyncpg.UniqueViolationError:
# Duplicate page name in this folder (uq_kp_folder_pagename). Roll back
# by deleting the orphan mental model we just created, then signal the
# caller that the page already exists.
await self.delete_mental_model(bank_id, mm["id"], request_context=request_context)
return None
node = self._row_to_knowledge_node(row)
# Surface the mental-model metadata so the caller can render OKF or
# schedule a content refresh without a second fetch.
node["tags"] = list(mm.get("tags") or [])
node["source_query"] = mm.get("source_query")
node["last_refreshed_at"] = mm.get("last_refreshed_at")
return node
async def list_knowledge_nodes(self, bank_id: str, *, request_context: "RequestContext") -> list[dict[str, Any]]:
"""Return every folder/page node in the bank (flat; caller builds the tree)."""
await self._authenticate_tenant(request_context)
backend = await self._get_backend()
async with acquire_with_retry(backend) as conn:
rows = await conn.fetch(
f"""
SELECT {self._KP_PAGE_SELECT}
FROM {self._kp_join()}
WHERE kp.bank_id = $1
ORDER BY kp.sort_order, kp.name
""",
bank_id,
)
return [self._row_to_knowledge_node(r) for r in rows]
async def get_knowledge_page(
self, bank_id: str, page_id: str, *, request_context: "RequestContext"
) -> dict[str, Any] | None:
"""Return a page node merged with its mental model's content (for OKF)."""
await self._authenticate_tenant(request_context)
backend = await self._get_backend()
async with acquire_with_retry(backend) as conn:
row = await conn.fetchrow(
f"""
SELECT {self._KP_PAGE_SELECT}, mm.content AS mm_content
FROM {self._kp_join()}
WHERE kp.bank_id = $1 AND kp.id = $2 AND kp.kind = 'page'
""",
bank_id,
page_id,
)
if row is None:
return None
node = self._row_to_knowledge_node(row)
node["content"] = row["mm_content"]
return node
async def rename_knowledge_node(
self, bank_id: str, node_id: str, name: str, *, request_context: "RequestContext"
) -> dict[str, Any] | None:
"""Rename a folder or page node."""
await self._authenticate_tenant(request_context)
backend = await self._get_backend()
async with acquire_with_retry(backend) as conn:
row = await conn.fetchrow(
f"""
UPDATE {fq_table("knowledge_pages")}
SET name = $3, updated_at = now()
WHERE bank_id = $1 AND id = $2
RETURNING {self._KP_COLUMNS}
""",
bank_id,
node_id,
name,
)
return self._row_to_knowledge_node(row) if row else None
async def move_knowledge_node(
self, bank_id: str, node_id: str, new_parent_id: str | None, *, request_context: "RequestContext"
) -> dict[str, Any] | None:
"""Re-parent a node, rejecting self-parenting and cycles."""
await self._authenticate_tenant(request_context)
if new_parent_id == node_id:
raise ValueError("A node cannot be its own parent")
backend = await self._get_backend()
async with acquire_with_retry(backend) as conn:
async with conn.transaction():
await self._kp_assert_folder_parent(conn, bank_id, new_parent_id)
# Cycle guard: walk up from the new parent; if we reach node_id,
# the move would create a loop. Done in Python so the check stays
# dialect-agnostic (no recursive CTE).
if new_parent_id is not None:
parents = {
r["id"]: r["parent_id"]
for r in await conn.fetch(
f"SELECT id, parent_id FROM {fq_table('knowledge_pages')} WHERE bank_id = $1",
bank_id,
)
}
cursor: str | None = new_parent_id
while cursor is not None:
if cursor == node_id:
raise ValueError("Cannot move a node into its own subtree")
cursor = parents.get(cursor)
row = await conn.fetchrow(
f"""
UPDATE {fq_table("knowledge_pages")}
SET parent_id = $3, updated_at = now()
WHERE bank_id = $1 AND id = $2
RETURNING {self._KP_COLUMNS}
""",
bank_id,
node_id,
new_parent_id,
)
return self._row_to_knowledge_node(row) if row else None
async def delete_knowledge_node(self, bank_id: str, node_id: str, *, request_context: "RequestContext") -> bool:
"""Delete a node and its whole subtree, including each page's mental model.
Deleting the mental models cascades their page rows away (FK ON DELETE
CASCADE); deleting the node then cascades any remaining descendant folder
rows. The subtree is gathered in Python so the logic is dialect-agnostic.
"""
await self._authenticate_tenant(request_context)
backend = await self._get_backend()
async with acquire_with_retry(backend) as conn:
async with conn.transaction():
all_rows = await conn.fetch(
f"SELECT id, parent_id, mental_model_id FROM {fq_table('knowledge_pages')} WHERE bank_id = $1",
bank_id,
)
by_parent: dict[str | None, list] = {}
for r in all_rows:
by_parent.setdefault(r["parent_id"], []).append(r)
if not any(r["id"] == node_id for r in all_rows):
return False
# BFS the subtree rooted at node_id, collecting page mental models.
stack = [node_id]
mm_ids: list[str] = []
while stack:
current = stack.pop()
for child in by_parent.get(current, []):
stack.append(child["id"])
node_row = next((r for r in all_rows if r["id"] == current), None)
if node_row and node_row["mental_model_id"]:
mm_ids.append(node_row["mental_model_id"])
# Delete each backing mental model individually (the subtree is
# small) to keep the SQL dialect-neutral — no PG array casts.
for mm_id in mm_ids:
await conn.execute(
f"DELETE FROM {fq_table('mental_models')} WHERE bank_id = $1 AND id = $2",
bank_id,
mm_id,
)
await conn.execute(
f"DELETE FROM {fq_table('knowledge_pages')} WHERE bank_id = $1 AND id = $2",
bank_id,
node_id,
)
return True
async def compute_mental_model_is_stale(
self,
conn,
@@ -12847,3 +13176,4 @@ class MemoryEngine(MemoryEngineInterface):
result_metadata={"mental_model_id": mental_model_id, "name": mental_model["name"]},
dedupe_by_bank=False,
)
@@ -0,0 +1,241 @@
"""HTTP + engine integration tests for the knowledge base (folders + pages).
Pages are seeded directly via the engine (deterministic content, no LLM) so the
tree, OKF projection, move/rename, and cascade-delete behaviour can be asserted
without consolidation.
"""
import urllib.parse
import uuid
import pytest_asyncio
from hindsight_api.engine.memory_engine import MemoryEngine
def _enc(bank_id: str) -> str:
return urllib.parse.quote(bank_id, safe="")
class _Seed:
"""Holds the ids created by the seed fixture for assertions."""
def __init__(self, **ids):
self.__dict__.update(ids)
@pytest_asyncio.fixture
async def kb_bank(memory: MemoryEngine, request_context):
"""A bank with folders, nested folders, and pages."""
bank_id = f"test-kb-{uuid.uuid4().hex[:8]}"
runbooks = await memory.create_knowledge_folder(bank_id, "Runbooks", request_context=request_context)
policies = await memory.create_knowledge_folder(bank_id, "Policies", request_context=request_context)
sub = await memory.create_knowledge_folder(
bank_id, "Sub", parent_id=runbooks["id"], request_context=request_context
)
orders = await memory.create_knowledge_page(
bank_id,
"Orders",
"What are the order facts?",
"# Orders\n\nOne row per order.",
parent_id=runbooks["id"],
tags=["type:runbook", "sales", "revenue"],
request_context=request_context,
)
billing = await memory.create_knowledge_page(
bank_id,
"Billing",
"What is the billing policy?",
"# Billing\n\nNet-30.",
parent_id=policies["id"],
tags=["type:policy", "revenue"],
request_context=request_context,
)
loose = await memory.create_knowledge_page(
bank_id,
"Loose",
"A root page.",
"# Loose\n\nNo folder, no tags.",
tags=[],
request_context=request_context,
)
yield (
bank_id,
_Seed(
runbooks=runbooks["id"],
policies=policies["id"],
sub=sub["id"],
orders=orders["id"],
billing=billing["id"],
loose=loose["id"],
orders_mm=orders["mental_model_id"],
),
)
await memory.delete_bank(bank_id, request_context=request_context)
class TestTree:
async def test_nested_tree(self, api_client, kb_bank):
bank_id, ids = kb_bank
resp = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/tree")
assert resp.status_code == 200, resp.text
roots = {r["name"]: r for r in resp.json()["roots"]}
assert set(roots) == {"Runbooks", "Policies", "Loose"}
runbooks = roots["Runbooks"]
assert runbooks["kind"] == "folder"
child_names = {c["name"] for c in runbooks["children"]}
assert child_names == {"Sub", "Orders"}
orders = next(c for c in runbooks["children"] if c["name"] == "Orders")
assert orders["kind"] == "page"
# Human-created pages are pinned (not curator-managed).
assert orders["managed"] is False
assert "sales" in orders["tags"]
assert roots["Loose"]["kind"] == "page"
class TestPageDefaults:
"""A knowledge page is a living document by default: observation-only, delta,
auto-refreshing, with a larger token budget than a plain mental model."""
async def test_default_trigger_and_max_tokens(self, memory: MemoryEngine, request_context):
bank_id = f"test-kb-def-{uuid.uuid4().hex[:8]}"
page = await memory.create_knowledge_page(
bank_id, "P", "What is P?", "seed", request_context=request_context
)
mm = await memory.get_mental_model(bank_id, page["mental_model_id"], request_context=request_context)
assert mm["trigger"] == {
"mode": "delta",
"fact_types": ["observation"],
"exclude_mental_models": True,
"refresh_after_consolidation": True,
}
assert mm["max_tokens"] == 4096
await memory.delete_bank(bank_id, request_context=request_context)
async def test_client_trigger_and_max_tokens_override_defaults(self, memory: MemoryEngine, request_context):
bank_id = f"test-kb-ovr-{uuid.uuid4().hex[:8]}"
page = await memory.create_knowledge_page(
bank_id,
"P",
"What is P?",
"seed",
trigger={"mode": "full", "refresh_after_consolidation": False},
max_tokens=1024,
request_context=request_context,
)
mm = await memory.get_mental_model(bank_id, page["mental_model_id"], request_context=request_context)
assert mm["trigger"]["mode"] == "full"
assert mm["trigger"].get("refresh_after_consolidation") is False
assert mm["max_tokens"] == 1024
await memory.delete_bank(bank_id, request_context=request_context)
class TestGetPage:
async def test_okf_document(self, api_client, kb_bank):
bank_id, ids = kb_bank
resp = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/pages/{ids.orders}")
assert resp.status_code == 200, resp.text
page = resp.json()
assert page["type"] == "runbook"
assert page["body"].startswith("# Orders")
assert page["markdown"].startswith("---\n")
assert 'type: "runbook"' in page["markdown"]
async def test_missing_page_404(self, api_client, kb_bank):
bank_id, ids = kb_bank
resp = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/pages/nope")
assert resp.status_code == 404
class TestCreate:
async def test_create_folder(self, api_client, kb_bank):
bank_id, ids = kb_bank
resp = await api_client.post(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/folders",
json={"name": "Guides", "parent_id": None},
)
assert resp.status_code == 201, resp.text
assert resp.json()["kind"] == "folder"
assert resp.json()["name"] == "Guides"
async def test_create_folder_bad_parent(self, api_client, kb_bank):
bank_id, ids = kb_bank
# parent that is a page, not a folder → 400
resp = await api_client.post(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/folders",
json={"name": "Nope", "parent_id": ids.orders},
)
assert resp.status_code == 400
class TestGraphAndExport:
async def test_graph_shared_tag_edge(self, api_client, kb_bank):
bank_id, ids = kb_bank
resp = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/graph")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["total_pages"] == 3
# orders & billing share "revenue"; loose has no tags
assert data["total_edges"] == 1
edge = data["edges"][0]["data"]
assert {edge["source"], edge["target"]} == {ids.orders, ids.billing}
assert edge["sharedTags"] == ["revenue"]
async def test_export_bundle_nested_index(self, api_client, kb_bank):
bank_id, ids = kb_bank
resp = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/export")
assert resp.status_code == 200, resp.text
files = {f["path"]: f["content"] for f in resp.json()["files"]}
assert "index.md" in files
assert f"{ids.orders}.md" in files
# index reflects the folder hierarchy
assert "**Runbooks/**" in files["index.md"]
assert "One row per order." in files[f"{ids.orders}.md"]
class TestMoveRenameDelete:
async def test_rename(self, api_client, kb_bank):
bank_id, ids = kb_bank
resp = await api_client.patch(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.policies}",
json={"name": "Compliance"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["name"] == "Compliance"
async def test_move_into_folder(self, api_client, kb_bank):
bank_id, ids = kb_bank
# move the Loose root page under Policies
resp = await api_client.patch(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.loose}",
json={"parent_id": ids.policies},
)
assert resp.status_code == 200, resp.text
assert resp.json()["parent_id"] == ids.policies
async def test_move_cycle_rejected(self, api_client, kb_bank):
bank_id, ids = kb_bank
# moving Runbooks under its own descendant Sub must fail
resp = await api_client.patch(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.runbooks}",
json={"parent_id": ids.sub},
)
assert resp.status_code == 400
async def test_delete_folder_cascades(self, api_client, kb_bank, memory, request_context):
bank_id, ids = kb_bank
# deleting Runbooks removes Sub + Orders (and Orders' mental model)
resp = await api_client.delete(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.runbooks}")
assert resp.status_code == 200, resp.text
tree = (await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/tree")).json()
root_names = {r["name"] for r in tree["roots"]}
assert "Runbooks" not in root_names
# the backing mental model is gone too
mm = await memory.get_mental_model(bank_id, ids.orders_mm, request_context=request_context)
assert mm is None
+166
View File
@@ -0,0 +1,166 @@
"""Pure unit tests for the OKF (Open Knowledge Format) serializer.
These exercise hindsight_api/api/okf.py with plain dicts — no DB, no LLM — so
they pin the OKF contract (frontmatter projection, type-from-tag, shared-tag
graph) deterministically and fast.
"""
from hindsight_api.api import okf
def _mm(**overrides):
base = {
"id": "orders",
"name": "Orders",
"source_query": "What are the order facts?",
"content": "# Orders\n\nOne row per order.",
"tags": ["type:runbook", "sales", "revenue"],
"last_refreshed_at": "2026-01-02T00:00:00Z",
"created_at": "2026-01-01T00:00:00Z",
}
base.update(overrides)
return base
class TestPageType:
def test_lifts_type_from_tag_and_drops_it(self):
pt = okf.page_type(["type:runbook", "sales", "revenue"])
assert pt.type == "runbook"
assert pt.display_tags == ["sales", "revenue"]
def test_defaults_when_no_type_tag(self):
pt = okf.page_type(["sales"])
assert pt.type == okf.DEFAULT_PAGE_TYPE
assert pt.display_tags == ["sales"]
def test_handles_none_and_empty(self):
assert okf.page_type(None).type == okf.DEFAULT_PAGE_TYPE
assert okf.page_type(None).display_tags == []
def test_blank_type_suffix_falls_back(self):
pt = okf.page_type(["type:", "sales"])
assert pt.type == okf.DEFAULT_PAGE_TYPE
# the (blank) type tag is still stripped from display tags
assert pt.display_tags == ["sales"]
def test_first_type_tag_wins(self):
pt = okf.page_type(["type:runbook", "type:guide"])
assert pt.type == "runbook"
assert pt.display_tags == []
class TestFrontmatter:
def test_projects_expected_fields(self):
fm = okf.frontmatter(_mm())
assert fm["id"] == "orders"
assert fm["type"] == "runbook"
assert fm["title"] == "Orders"
assert fm["description"] == "What are the order facts?"
assert fm["tags"] == ["sales", "revenue"]
assert fm["timestamp"] == "2026-01-02T00:00:00Z"
def test_timestamp_falls_back_to_created_at(self):
fm = okf.frontmatter(_mm(last_refreshed_at=None))
assert fm["timestamp"] == "2026-01-01T00:00:00Z"
def test_render_omits_none_and_empty(self):
rendered = okf.render_frontmatter({"type": "x", "title": None, "tags": []})
assert "title" not in rendered
assert "tags" not in rendered
assert 'type: "x"' in rendered
def test_render_quotes_and_escapes(self):
# A name that looks like a YAML bool / contains a quote must stay a string.
rendered = okf.render_frontmatter({"title": 'true "x"'})
assert 'title: "true \\"x\\""' in rendered
class TestRenderDocument:
def test_includes_frontmatter_and_body(self):
doc = okf.render_document(_mm())
assert doc.startswith("---\n")
assert 'type: "runbook"' in doc
assert "One row per order." in doc
def test_empty_body(self):
doc = okf.render_document(_mm(content=""))
assert doc.count("---") == 2
assert doc.rstrip().endswith("---")
class TestKnowledgeGraph:
def test_edge_from_shared_tag(self):
pages = [
_mm(id="orders", tags=["type:runbook", "sales", "revenue"]),
_mm(id="customers", tags=["sales", "crm"]),
_mm(id="lonely", tags=[]),
]
graph = okf.knowledge_graph(pages)
assert len(graph.nodes) == 3
assert len(graph.edges) == 1
edge = graph.edges[0]["data"]
assert {edge["source"], edge["target"]} == {"orders", "customers"}
assert edge["sharedTags"] == ["sales"]
assert edge["weight"] == 1
def test_type_tag_does_not_create_edges(self):
# Two pages sharing only a type: tag must NOT be linked.
pages = [
_mm(id="a", tags=["type:runbook"]),
_mm(id="b", tags=["type:runbook"]),
]
graph = okf.knowledge_graph(pages)
assert graph.edges == []
def test_node_carries_type_and_color(self):
graph = okf.knowledge_graph([_mm(id="orders", tags=["type:runbook", "sales"])])
node = graph.nodes[0]["data"]
assert node["type"] == "runbook"
assert node["label"] == "Orders"
assert node["tagCount"] == 1
assert node["color"].startswith("#")
def test_weight_counts_shared_tags(self):
pages = [
_mm(id="a", tags=["sales", "revenue", "x"]),
_mm(id="b", tags=["sales", "revenue", "y"]),
]
graph = okf.knowledge_graph(pages)
assert graph.edges[0]["data"]["weight"] == 2
assert graph.edges[0]["data"]["sharedTags"] == ["revenue", "sales"]
class TestReservedFiles:
def test_index_links_each_page(self):
index = okf.render_index([_mm(id="orders", name="Orders", source_query="q?")])
assert "[Orders](./orders.md)" in index
assert "q?" in index
assert 'type: "index"' in index
def test_index_empty(self):
assert "No knowledge pages yet" in okf.render_index([])
def test_index_nests_folders(self):
nodes = [
{"id": "f1", "kind": "folder", "name": "Runbooks", "parent_id": None},
{"id": "p1", "kind": "page", "name": "Orders", "parent_id": "f1", "source_query": "q?"},
{"id": "p2", "kind": "page", "name": "Loose", "parent_id": None},
]
idx = okf.render_index(nodes)
assert "**Runbooks/**" in idx
# the page nested in the folder is indented and links to its file
assert " - [Orders](./p1.md) — q?" in idx
assert "- [Loose](./p2.md)" in idx
def test_log_renders_history_newest_first(self):
history = [
{"previous_content": "v2", "changed_at": "2026-01-02T00:00:00Z"},
{"previous_content": "v1", "changed_at": "2026-01-01T00:00:00Z"},
]
log = okf.render_log(_mm(), history)
assert 'type: "log"' in log
assert log.index("2026-01-02") < log.index("2026-01-01")
assert "v2" in log and "v1" in log
def test_log_empty(self):
assert "No refresh history" in okf.render_log(_mm(), [])
+692
View File
@@ -1382,6 +1382,348 @@ paths:
summary: Clear mental model content
tags:
- Mental Models
/v1/default/banks/{bank_id}/knowledge-base/tree:
get:
description: Return the knowledge base as a nested tree of folders and pages.
operationId: get_knowledge_base_tree
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgeTreeResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Get the knowledge-base tree
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/knowledge-base/folders:
post:
description: "Create a folder, optionally nested under a parent folder."
operationId: create_knowledge_folder
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CreateFolderRequest'
required: true
responses:
"201":
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgeNode'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Create a knowledge-base folder
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/knowledge-base/pages:
post:
description: Create a page (a mental model + tree node). Content is generated
asynchronously; use the returned operation_id to track completion.
operationId: create_knowledge_page
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CreatePageRequest'
required: true
responses:
"201":
content:
application/json:
schema:
$ref: '#/components/schemas/CreateKnowledgePageResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Create a knowledge-base page
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/knowledge-base/graph:
get:
description: "Return pages as nodes linked by shared tags, for the constellation\
\ view."
operationId: get_knowledge_base_graph
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgePageGraphResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Knowledge-base constellation graph
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/knowledge-base/export:
get:
description: "Return a portable OKF bundle: a nested index.md, one <id>.md per\
\ page, and history logs."
operationId: export_knowledge_base
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgePageBundleResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Export the knowledge base as an OKF bundle
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}:
get:
description: Return a single page as an OKF document (frontmatter + markdown
body).
operationId: get_knowledge_page
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: page_id
required: true
schema:
title: Page Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgePageResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Get a knowledge-base page
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}:
delete:
description: Delete a folder or page and its whole subtree (pages' mental models
are removed too).
operationId: delete_knowledge_node
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: node_id
required: true
schema:
title: Node Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema: {}
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Delete a knowledge-base node
tags:
- Knowledge Base
patch:
description: "Rename a node (set `name`) and/or move it under another folder\
\ (set `parent_id`, null for the root)."
operationId: update_knowledge_node
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: node_id
required: true
schema:
title: Node Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateNodeRequest'
required: true
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgeNode'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Rename or move a knowledge-base node
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/directives:
get:
description: List hard rules that are injected into prompts.
@@ -5095,6 +5437,42 @@ components:
- content
- name
title: CreateDirectiveRequest
CreateFolderRequest:
description: Create a folder under an optional parent folder.
example:
parent_id: parent_id
name: name
properties:
name:
title: Name
type: string
parent_id:
nullable: true
type: string
required:
- name
title: CreateFolderRequest
CreateKnowledgePageResponse:
description: "Result of creating a page: the node id, its mental model, and\
\ the refresh op."
example:
page_id: page_id
operation_id: operation_id
mental_model_id: mental_model_id
properties:
page_id:
title: Page Id
type: string
mental_model_id:
title: Mental Model Id
type: string
operation_id:
nullable: true
type: string
required:
- mental_model_id
- page_id
title: CreateKnowledgePageResponse
CreateMentalModelRequest:
description: Request model for creating a mental model.
example:
@@ -5153,6 +5531,64 @@ components:
required:
- operation_id
title: CreateMentalModelResponse
CreatePageRequest:
description: Create a page (a mental model + tree node) under an optional parent
folder.
example:
source_query: source_query
max_tokens: 0
parent_id: parent_id
name: name
trigger:
mode: full
refresh_after_consolidation: false
recall_chunks_max_tokens: 1
tag_groups:
- match: any_strict
tags:
- tags
- tags
- match: any_strict
tags:
- tags
- tags
fact_types:
- world
- world
exclude_mental_model_ids:
- exclude_mental_model_ids
- exclude_mental_model_ids
include_chunks: true
tags_match: any
exclude_mental_models: false
recall_max_tokens: 6
tags:
- tags
- tags
properties:
name:
title: Name
type: string
source_query:
title: Source Query
type: string
parent_id:
nullable: true
type: string
tags:
items:
type: string
nullable: true
type: array
max_tokens:
nullable: true
type: integer
trigger:
$ref: '#/components/schemas/MentalModelTrigger-Input'
required:
- name
- source_query
title: CreatePageRequest
CreateWebhookRequest:
description: Request model for registering a webhook.
example:
@@ -5956,6 +6392,226 @@ components:
source_facts:
$ref: '#/components/schemas/SourceFactsIncludeOptions'
title: IncludeOptions
KnowledgeNode:
description: |-
A node in the knowledge-base tree — a folder or a page.
Pages carry ``description``/``tags`` from their backing mental model. The
knowledge base is client-managed (CRUD); ``managed`` lets a client tag a node
as system-owned vs. hand-authored.
example:
children:
- null
- null
kind: folder
parent_id: parent_id
managed: false
name: name
description: description
id: id
mental_model_id: mental_model_id
tags:
- tags
- tags
timestamp: timestamp
properties:
id:
title: Id
type: string
kind:
enum:
- folder
- page
title: Kind
type: string
name:
title: Name
type: string
parent_id:
nullable: true
type: string
mental_model_id:
nullable: true
type: string
managed:
default: false
description: "Client-set flag: true = system-owned, false = hand-authored."
title: Managed
type: boolean
description:
nullable: true
type: string
tags:
default: []
items:
type: string
type: array
timestamp:
nullable: true
type: string
children:
default: []
items:
$ref: '#/components/schemas/KnowledgeNode'
type: array
required:
- id
- kind
- name
title: KnowledgeNode
KnowledgePageBundleFile:
description: One file in a portable OKF bundle.
example:
path: path
content: content
properties:
path:
title: Path
type: string
content:
title: Content
type: string
required:
- content
- path
title: KnowledgePageBundleFile
KnowledgePageBundleResponse:
description: A portable OKF bundle — a flat set of markdown files (index + pages
+ logs).
example:
files:
- path: path
content: content
- path: path
content: content
properties:
files:
items:
$ref: '#/components/schemas/KnowledgePageBundleFile'
type: array
required:
- files
title: KnowledgePageBundleResponse
KnowledgePageGraphResponse:
description: Constellation graph of knowledge pages linked by shared tags.
example:
total_edges: 6
nodes:
- key: ""
- key: ""
edges:
- key: ""
- key: ""
total_pages: 0
properties:
nodes:
items:
additionalProperties: {}
type: array
edges:
items:
additionalProperties: {}
type: array
total_pages:
title: Total Pages
type: integer
total_edges:
title: Total Edges
type: integer
required:
- edges
- nodes
- total_edges
- total_pages
title: KnowledgePageGraphResponse
KnowledgePageResponse:
description: A knowledge page rendered as an OKF document.
example:
name: name
markdown: markdown
description: description
id: id
type: type
body: body
tags:
- tags
- tags
timestamp: timestamp
properties:
id:
title: Id
type: string
name:
title: Name
type: string
type:
description: "OKF document type — from a `type:<x>` tag, else 'knowledge-page'."
title: Type
type: string
description:
nullable: true
type: string
tags:
default: []
items:
type: string
type: array
timestamp:
nullable: true
type: string
body:
nullable: true
type: string
markdown:
description: "The full OKF document: YAML frontmatter + markdown body."
title: Markdown
type: string
required:
- id
- markdown
- name
- type
title: KnowledgePageResponse
KnowledgeTreeResponse:
description: The knowledge base as a nested folder/page tree.
example:
roots:
- children:
- null
- null
kind: folder
parent_id: parent_id
managed: false
name: name
description: description
id: id
mental_model_id: mental_model_id
tags:
- tags
- tags
timestamp: timestamp
- children:
- null
- null
kind: folder
parent_id: parent_id
managed: false
name: name
description: description
id: id
mental_model_id: mental_model_id
tags:
- tags
- tags
timestamp: timestamp
properties:
roots:
items:
$ref: '#/components/schemas/KnowledgeNode'
type: array
required:
- roots
title: KnowledgeTreeResponse
LLMRequestEntry:
description: "A single LLM request trace row, as returned by the read API."
example:
@@ -6741,6 +7397,29 @@ components:
title: MentalModelResponse
MentalModelTrigger-Input:
description: Trigger settings for a mental model.
example:
mode: full
refresh_after_consolidation: false
recall_chunks_max_tokens: 1
tag_groups:
- match: any_strict
tags:
- tags
- tags
- match: any_strict
tags:
- tags
- tags
fact_types:
- world
- world
exclude_mental_model_ids:
- exclude_mental_model_ids
- exclude_mental_model_ids
include_chunks: true
tags_match: any
exclude_mental_models: false
recall_max_tokens: 6
properties:
mode:
default: full
@@ -8114,6 +8793,19 @@ components:
trigger:
$ref: '#/components/schemas/MentalModelTrigger-Input'
title: UpdateMentalModelRequest
UpdateNodeRequest:
description: Rename and/or move a node. Each field applies only when present.
example:
parent_id: parent_id
name: name
properties:
name:
nullable: true
type: string
parent_id:
nullable: true
type: string
title: UpdateNodeRequest
UpdateWebhookRequest:
description: Request model for updating a webhook. Only provided fields are
updated.
File diff suppressed because it is too large Load Diff
+3
View File
@@ -65,6 +65,8 @@ type APIClient struct {
FilesAPI *FilesAPIService
KnowledgeBaseAPI *KnowledgeBaseAPIService
LLMTracesAPI *LLMTracesAPIService
MemoryAPI *MemoryAPIService
@@ -102,6 +104,7 @@ func NewAPIClient(cfg *Configuration) *APIClient {
c.DocumentsAPI = (*DocumentsAPIService)(&c.common)
c.EntitiesAPI = (*EntitiesAPIService)(&c.common)
c.FilesAPI = (*FilesAPIService)(&c.common)
c.KnowledgeBaseAPI = (*KnowledgeBaseAPIService)(&c.common)
c.LLMTracesAPI = (*LLMTracesAPIService)(&c.common)
c.MemoryAPI = (*MemoryAPIService)(&c.common)
c.MentalModelsAPI = (*MentalModelsAPIService)(&c.common)
@@ -0,0 +1,204 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the CreateFolderRequest type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &CreateFolderRequest{}
// CreateFolderRequest Create a folder under an optional parent folder.
type CreateFolderRequest struct {
Name string `json:"name"`
ParentId NullableString `json:"parent_id,omitempty"`
}
type _CreateFolderRequest CreateFolderRequest
// NewCreateFolderRequest instantiates a new CreateFolderRequest object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewCreateFolderRequest(name string) *CreateFolderRequest {
this := CreateFolderRequest{}
this.Name = name
return &this
}
// NewCreateFolderRequestWithDefaults instantiates a new CreateFolderRequest object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewCreateFolderRequestWithDefaults() *CreateFolderRequest {
this := CreateFolderRequest{}
return &this
}
// GetName returns the Name field value
func (o *CreateFolderRequest) GetName() string {
if o == nil {
var ret string
return ret
}
return o.Name
}
// GetNameOk returns a tuple with the Name field value
// and a boolean to check if the value has been set.
func (o *CreateFolderRequest) GetNameOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Name, true
}
// SetName sets field value
func (o *CreateFolderRequest) SetName(v string) {
o.Name = v
}
// GetParentId returns the ParentId field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *CreateFolderRequest) GetParentId() string {
if o == nil || IsNil(o.ParentId.Get()) {
var ret string
return ret
}
return *o.ParentId.Get()
}
// GetParentIdOk returns a tuple with the ParentId field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *CreateFolderRequest) GetParentIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.ParentId.Get(), o.ParentId.IsSet()
}
// HasParentId returns a boolean if a field has been set.
func (o *CreateFolderRequest) HasParentId() bool {
if o != nil && o.ParentId.IsSet() {
return true
}
return false
}
// SetParentId gets a reference to the given NullableString and assigns it to the ParentId field.
func (o *CreateFolderRequest) SetParentId(v string) {
o.ParentId.Set(&v)
}
// SetParentIdNil sets the value for ParentId to be an explicit nil
func (o *CreateFolderRequest) SetParentIdNil() {
o.ParentId.Set(nil)
}
// UnsetParentId ensures that no value is present for ParentId, not even an explicit nil
func (o *CreateFolderRequest) UnsetParentId() {
o.ParentId.Unset()
}
func (o CreateFolderRequest) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o CreateFolderRequest) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["name"] = o.Name
if o.ParentId.IsSet() {
toSerialize["parent_id"] = o.ParentId.Get()
}
return toSerialize, nil
}
func (o *CreateFolderRequest) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"name",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varCreateFolderRequest := _CreateFolderRequest{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varCreateFolderRequest)
if err != nil {
return err
}
*o = CreateFolderRequest(varCreateFolderRequest)
return err
}
type NullableCreateFolderRequest struct {
value *CreateFolderRequest
isSet bool
}
func (v NullableCreateFolderRequest) Get() *CreateFolderRequest {
return v.value
}
func (v *NullableCreateFolderRequest) Set(val *CreateFolderRequest) {
v.value = val
v.isSet = true
}
func (v NullableCreateFolderRequest) IsSet() bool {
return v.isSet
}
func (v *NullableCreateFolderRequest) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableCreateFolderRequest(val *CreateFolderRequest) *NullableCreateFolderRequest {
return &NullableCreateFolderRequest{value: val, isSet: true}
}
func (v NullableCreateFolderRequest) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableCreateFolderRequest) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,232 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the CreateKnowledgePageResponse type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &CreateKnowledgePageResponse{}
// CreateKnowledgePageResponse Result of creating a page: the node id, its mental model, and the refresh op.
type CreateKnowledgePageResponse struct {
PageId string `json:"page_id"`
MentalModelId string `json:"mental_model_id"`
OperationId NullableString `json:"operation_id,omitempty"`
}
type _CreateKnowledgePageResponse CreateKnowledgePageResponse
// NewCreateKnowledgePageResponse instantiates a new CreateKnowledgePageResponse object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewCreateKnowledgePageResponse(pageId string, mentalModelId string) *CreateKnowledgePageResponse {
this := CreateKnowledgePageResponse{}
this.PageId = pageId
this.MentalModelId = mentalModelId
return &this
}
// NewCreateKnowledgePageResponseWithDefaults instantiates a new CreateKnowledgePageResponse object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewCreateKnowledgePageResponseWithDefaults() *CreateKnowledgePageResponse {
this := CreateKnowledgePageResponse{}
return &this
}
// GetPageId returns the PageId field value
func (o *CreateKnowledgePageResponse) GetPageId() string {
if o == nil {
var ret string
return ret
}
return o.PageId
}
// GetPageIdOk returns a tuple with the PageId field value
// and a boolean to check if the value has been set.
func (o *CreateKnowledgePageResponse) GetPageIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.PageId, true
}
// SetPageId sets field value
func (o *CreateKnowledgePageResponse) SetPageId(v string) {
o.PageId = v
}
// GetMentalModelId returns the MentalModelId field value
func (o *CreateKnowledgePageResponse) GetMentalModelId() string {
if o == nil {
var ret string
return ret
}
return o.MentalModelId
}
// GetMentalModelIdOk returns a tuple with the MentalModelId field value
// and a boolean to check if the value has been set.
func (o *CreateKnowledgePageResponse) GetMentalModelIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.MentalModelId, true
}
// SetMentalModelId sets field value
func (o *CreateKnowledgePageResponse) SetMentalModelId(v string) {
o.MentalModelId = v
}
// GetOperationId returns the OperationId field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *CreateKnowledgePageResponse) GetOperationId() string {
if o == nil || IsNil(o.OperationId.Get()) {
var ret string
return ret
}
return *o.OperationId.Get()
}
// GetOperationIdOk returns a tuple with the OperationId field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *CreateKnowledgePageResponse) GetOperationIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.OperationId.Get(), o.OperationId.IsSet()
}
// HasOperationId returns a boolean if a field has been set.
func (o *CreateKnowledgePageResponse) HasOperationId() bool {
if o != nil && o.OperationId.IsSet() {
return true
}
return false
}
// SetOperationId gets a reference to the given NullableString and assigns it to the OperationId field.
func (o *CreateKnowledgePageResponse) SetOperationId(v string) {
o.OperationId.Set(&v)
}
// SetOperationIdNil sets the value for OperationId to be an explicit nil
func (o *CreateKnowledgePageResponse) SetOperationIdNil() {
o.OperationId.Set(nil)
}
// UnsetOperationId ensures that no value is present for OperationId, not even an explicit nil
func (o *CreateKnowledgePageResponse) UnsetOperationId() {
o.OperationId.Unset()
}
func (o CreateKnowledgePageResponse) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o CreateKnowledgePageResponse) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["page_id"] = o.PageId
toSerialize["mental_model_id"] = o.MentalModelId
if o.OperationId.IsSet() {
toSerialize["operation_id"] = o.OperationId.Get()
}
return toSerialize, nil
}
func (o *CreateKnowledgePageResponse) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"page_id",
"mental_model_id",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varCreateKnowledgePageResponse := _CreateKnowledgePageResponse{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varCreateKnowledgePageResponse)
if err != nil {
return err
}
*o = CreateKnowledgePageResponse(varCreateKnowledgePageResponse)
return err
}
type NullableCreateKnowledgePageResponse struct {
value *CreateKnowledgePageResponse
isSet bool
}
func (v NullableCreateKnowledgePageResponse) Get() *CreateKnowledgePageResponse {
return v.value
}
func (v *NullableCreateKnowledgePageResponse) Set(val *CreateKnowledgePageResponse) {
v.value = val
v.isSet = true
}
func (v NullableCreateKnowledgePageResponse) IsSet() bool {
return v.isSet
}
func (v *NullableCreateKnowledgePageResponse) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableCreateKnowledgePageResponse(val *CreateKnowledgePageResponse) *NullableCreateKnowledgePageResponse {
return &NullableCreateKnowledgePageResponse{value: val, isSet: true}
}
func (v NullableCreateKnowledgePageResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableCreateKnowledgePageResponse) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,361 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the CreatePageRequest type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &CreatePageRequest{}
// CreatePageRequest Create a page (a mental model + tree node) under an optional parent folder.
type CreatePageRequest struct {
Name string `json:"name"`
SourceQuery string `json:"source_query"`
ParentId NullableString `json:"parent_id,omitempty"`
Tags []string `json:"tags,omitempty"`
MaxTokens NullableInt32 `json:"max_tokens,omitempty"`
Trigger NullableMentalModelTriggerInput `json:"trigger,omitempty"`
}
type _CreatePageRequest CreatePageRequest
// NewCreatePageRequest instantiates a new CreatePageRequest object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewCreatePageRequest(name string, sourceQuery string) *CreatePageRequest {
this := CreatePageRequest{}
this.Name = name
this.SourceQuery = sourceQuery
return &this
}
// NewCreatePageRequestWithDefaults instantiates a new CreatePageRequest object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewCreatePageRequestWithDefaults() *CreatePageRequest {
this := CreatePageRequest{}
return &this
}
// GetName returns the Name field value
func (o *CreatePageRequest) GetName() string {
if o == nil {
var ret string
return ret
}
return o.Name
}
// GetNameOk returns a tuple with the Name field value
// and a boolean to check if the value has been set.
func (o *CreatePageRequest) GetNameOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Name, true
}
// SetName sets field value
func (o *CreatePageRequest) SetName(v string) {
o.Name = v
}
// GetSourceQuery returns the SourceQuery field value
func (o *CreatePageRequest) GetSourceQuery() string {
if o == nil {
var ret string
return ret
}
return o.SourceQuery
}
// GetSourceQueryOk returns a tuple with the SourceQuery field value
// and a boolean to check if the value has been set.
func (o *CreatePageRequest) GetSourceQueryOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.SourceQuery, true
}
// SetSourceQuery sets field value
func (o *CreatePageRequest) SetSourceQuery(v string) {
o.SourceQuery = v
}
// GetParentId returns the ParentId field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *CreatePageRequest) GetParentId() string {
if o == nil || IsNil(o.ParentId.Get()) {
var ret string
return ret
}
return *o.ParentId.Get()
}
// GetParentIdOk returns a tuple with the ParentId field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *CreatePageRequest) GetParentIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.ParentId.Get(), o.ParentId.IsSet()
}
// HasParentId returns a boolean if a field has been set.
func (o *CreatePageRequest) HasParentId() bool {
if o != nil && o.ParentId.IsSet() {
return true
}
return false
}
// SetParentId gets a reference to the given NullableString and assigns it to the ParentId field.
func (o *CreatePageRequest) SetParentId(v string) {
o.ParentId.Set(&v)
}
// SetParentIdNil sets the value for ParentId to be an explicit nil
func (o *CreatePageRequest) SetParentIdNil() {
o.ParentId.Set(nil)
}
// UnsetParentId ensures that no value is present for ParentId, not even an explicit nil
func (o *CreatePageRequest) UnsetParentId() {
o.ParentId.Unset()
}
// GetTags returns the Tags field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *CreatePageRequest) GetTags() []string {
if o == nil {
var ret []string
return ret
}
return o.Tags
}
// GetTagsOk returns a tuple with the Tags field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *CreatePageRequest) GetTagsOk() ([]string, bool) {
if o == nil || IsNil(o.Tags) {
return nil, false
}
return o.Tags, true
}
// HasTags returns a boolean if a field has been set.
func (o *CreatePageRequest) HasTags() bool {
if o != nil && !IsNil(o.Tags) {
return true
}
return false
}
// SetTags gets a reference to the given []string and assigns it to the Tags field.
func (o *CreatePageRequest) SetTags(v []string) {
o.Tags = v
}
// GetMaxTokens returns the MaxTokens field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *CreatePageRequest) GetMaxTokens() int32 {
if o == nil || IsNil(o.MaxTokens.Get()) {
var ret int32
return ret
}
return *o.MaxTokens.Get()
}
// GetMaxTokensOk returns a tuple with the MaxTokens field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *CreatePageRequest) GetMaxTokensOk() (*int32, bool) {
if o == nil {
return nil, false
}
return o.MaxTokens.Get(), o.MaxTokens.IsSet()
}
// HasMaxTokens returns a boolean if a field has been set.
func (o *CreatePageRequest) HasMaxTokens() bool {
if o != nil && o.MaxTokens.IsSet() {
return true
}
return false
}
// SetMaxTokens gets a reference to the given NullableInt32 and assigns it to the MaxTokens field.
func (o *CreatePageRequest) SetMaxTokens(v int32) {
o.MaxTokens.Set(&v)
}
// SetMaxTokensNil sets the value for MaxTokens to be an explicit nil
func (o *CreatePageRequest) SetMaxTokensNil() {
o.MaxTokens.Set(nil)
}
// UnsetMaxTokens ensures that no value is present for MaxTokens, not even an explicit nil
func (o *CreatePageRequest) UnsetMaxTokens() {
o.MaxTokens.Unset()
}
// GetTrigger returns the Trigger field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *CreatePageRequest) GetTrigger() MentalModelTriggerInput {
if o == nil || IsNil(o.Trigger.Get()) {
var ret MentalModelTriggerInput
return ret
}
return *o.Trigger.Get()
}
// GetTriggerOk returns a tuple with the Trigger field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *CreatePageRequest) GetTriggerOk() (*MentalModelTriggerInput, bool) {
if o == nil {
return nil, false
}
return o.Trigger.Get(), o.Trigger.IsSet()
}
// HasTrigger returns a boolean if a field has been set.
func (o *CreatePageRequest) HasTrigger() bool {
if o != nil && o.Trigger.IsSet() {
return true
}
return false
}
// SetTrigger gets a reference to the given NullableMentalModelTriggerInput and assigns it to the Trigger field.
func (o *CreatePageRequest) SetTrigger(v MentalModelTriggerInput) {
o.Trigger.Set(&v)
}
// SetTriggerNil sets the value for Trigger to be an explicit nil
func (o *CreatePageRequest) SetTriggerNil() {
o.Trigger.Set(nil)
}
// UnsetTrigger ensures that no value is present for Trigger, not even an explicit nil
func (o *CreatePageRequest) UnsetTrigger() {
o.Trigger.Unset()
}
func (o CreatePageRequest) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o CreatePageRequest) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["name"] = o.Name
toSerialize["source_query"] = o.SourceQuery
if o.ParentId.IsSet() {
toSerialize["parent_id"] = o.ParentId.Get()
}
if o.Tags != nil {
toSerialize["tags"] = o.Tags
}
if o.MaxTokens.IsSet() {
toSerialize["max_tokens"] = o.MaxTokens.Get()
}
if o.Trigger.IsSet() {
toSerialize["trigger"] = o.Trigger.Get()
}
return toSerialize, nil
}
func (o *CreatePageRequest) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"name",
"source_query",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varCreatePageRequest := _CreatePageRequest{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varCreatePageRequest)
if err != nil {
return err
}
*o = CreatePageRequest(varCreatePageRequest)
return err
}
type NullableCreatePageRequest struct {
value *CreatePageRequest
isSet bool
}
func (v NullableCreatePageRequest) Get() *CreatePageRequest {
return v.value
}
func (v *NullableCreatePageRequest) Set(val *CreatePageRequest) {
v.value = val
v.isSet = true
}
func (v NullableCreatePageRequest) IsSet() bool {
return v.isSet
}
func (v *NullableCreatePageRequest) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableCreatePageRequest(val *CreatePageRequest) *NullableCreatePageRequest {
return &NullableCreatePageRequest{value: val, isSet: true}
}
func (v NullableCreatePageRequest) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableCreatePageRequest) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,511 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the KnowledgeNode type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &KnowledgeNode{}
// KnowledgeNode A node in the knowledge-base tree — a folder or a page. Pages carry ``description``/``tags`` from their backing mental model. The knowledge base is client-managed (CRUD); ``managed`` lets a client tag a node as system-owned vs. hand-authored.
type KnowledgeNode struct {
Id string `json:"id"`
Kind string `json:"kind"`
Name string `json:"name"`
ParentId NullableString `json:"parent_id,omitempty"`
MentalModelId NullableString `json:"mental_model_id,omitempty"`
// Client-set flag: true = system-owned, false = hand-authored.
Managed *bool `json:"managed,omitempty"`
Description NullableString `json:"description,omitempty"`
Tags []string `json:"tags,omitempty"`
Timestamp NullableString `json:"timestamp,omitempty"`
Children []KnowledgeNode `json:"children,omitempty"`
}
type _KnowledgeNode KnowledgeNode
// NewKnowledgeNode instantiates a new KnowledgeNode object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewKnowledgeNode(id string, kind string, name string) *KnowledgeNode {
this := KnowledgeNode{}
this.Id = id
this.Kind = kind
this.Name = name
var managed bool = false
this.Managed = &managed
return &this
}
// NewKnowledgeNodeWithDefaults instantiates a new KnowledgeNode object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewKnowledgeNodeWithDefaults() *KnowledgeNode {
this := KnowledgeNode{}
var managed bool = false
this.Managed = &managed
return &this
}
// GetId returns the Id field value
func (o *KnowledgeNode) GetId() string {
if o == nil {
var ret string
return ret
}
return o.Id
}
// GetIdOk returns a tuple with the Id field value
// and a boolean to check if the value has been set.
func (o *KnowledgeNode) GetIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Id, true
}
// SetId sets field value
func (o *KnowledgeNode) SetId(v string) {
o.Id = v
}
// GetKind returns the Kind field value
func (o *KnowledgeNode) GetKind() string {
if o == nil {
var ret string
return ret
}
return o.Kind
}
// GetKindOk returns a tuple with the Kind field value
// and a boolean to check if the value has been set.
func (o *KnowledgeNode) GetKindOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Kind, true
}
// SetKind sets field value
func (o *KnowledgeNode) SetKind(v string) {
o.Kind = v
}
// GetName returns the Name field value
func (o *KnowledgeNode) GetName() string {
if o == nil {
var ret string
return ret
}
return o.Name
}
// GetNameOk returns a tuple with the Name field value
// and a boolean to check if the value has been set.
func (o *KnowledgeNode) GetNameOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Name, true
}
// SetName sets field value
func (o *KnowledgeNode) SetName(v string) {
o.Name = v
}
// GetParentId returns the ParentId field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *KnowledgeNode) GetParentId() string {
if o == nil || IsNil(o.ParentId.Get()) {
var ret string
return ret
}
return *o.ParentId.Get()
}
// GetParentIdOk returns a tuple with the ParentId field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *KnowledgeNode) GetParentIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.ParentId.Get(), o.ParentId.IsSet()
}
// HasParentId returns a boolean if a field has been set.
func (o *KnowledgeNode) HasParentId() bool {
if o != nil && o.ParentId.IsSet() {
return true
}
return false
}
// SetParentId gets a reference to the given NullableString and assigns it to the ParentId field.
func (o *KnowledgeNode) SetParentId(v string) {
o.ParentId.Set(&v)
}
// SetParentIdNil sets the value for ParentId to be an explicit nil
func (o *KnowledgeNode) SetParentIdNil() {
o.ParentId.Set(nil)
}
// UnsetParentId ensures that no value is present for ParentId, not even an explicit nil
func (o *KnowledgeNode) UnsetParentId() {
o.ParentId.Unset()
}
// GetMentalModelId returns the MentalModelId field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *KnowledgeNode) GetMentalModelId() string {
if o == nil || IsNil(o.MentalModelId.Get()) {
var ret string
return ret
}
return *o.MentalModelId.Get()
}
// GetMentalModelIdOk returns a tuple with the MentalModelId field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *KnowledgeNode) GetMentalModelIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.MentalModelId.Get(), o.MentalModelId.IsSet()
}
// HasMentalModelId returns a boolean if a field has been set.
func (o *KnowledgeNode) HasMentalModelId() bool {
if o != nil && o.MentalModelId.IsSet() {
return true
}
return false
}
// SetMentalModelId gets a reference to the given NullableString and assigns it to the MentalModelId field.
func (o *KnowledgeNode) SetMentalModelId(v string) {
o.MentalModelId.Set(&v)
}
// SetMentalModelIdNil sets the value for MentalModelId to be an explicit nil
func (o *KnowledgeNode) SetMentalModelIdNil() {
o.MentalModelId.Set(nil)
}
// UnsetMentalModelId ensures that no value is present for MentalModelId, not even an explicit nil
func (o *KnowledgeNode) UnsetMentalModelId() {
o.MentalModelId.Unset()
}
// GetManaged returns the Managed field value if set, zero value otherwise.
func (o *KnowledgeNode) GetManaged() bool {
if o == nil || IsNil(o.Managed) {
var ret bool
return ret
}
return *o.Managed
}
// GetManagedOk returns a tuple with the Managed field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *KnowledgeNode) GetManagedOk() (*bool, bool) {
if o == nil || IsNil(o.Managed) {
return nil, false
}
return o.Managed, true
}
// HasManaged returns a boolean if a field has been set.
func (o *KnowledgeNode) HasManaged() bool {
if o != nil && !IsNil(o.Managed) {
return true
}
return false
}
// SetManaged gets a reference to the given bool and assigns it to the Managed field.
func (o *KnowledgeNode) SetManaged(v bool) {
o.Managed = &v
}
// GetDescription returns the Description field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *KnowledgeNode) GetDescription() string {
if o == nil || IsNil(o.Description.Get()) {
var ret string
return ret
}
return *o.Description.Get()
}
// GetDescriptionOk returns a tuple with the Description field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *KnowledgeNode) GetDescriptionOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.Description.Get(), o.Description.IsSet()
}
// HasDescription returns a boolean if a field has been set.
func (o *KnowledgeNode) HasDescription() bool {
if o != nil && o.Description.IsSet() {
return true
}
return false
}
// SetDescription gets a reference to the given NullableString and assigns it to the Description field.
func (o *KnowledgeNode) SetDescription(v string) {
o.Description.Set(&v)
}
// SetDescriptionNil sets the value for Description to be an explicit nil
func (o *KnowledgeNode) SetDescriptionNil() {
o.Description.Set(nil)
}
// UnsetDescription ensures that no value is present for Description, not even an explicit nil
func (o *KnowledgeNode) UnsetDescription() {
o.Description.Unset()
}
// GetTags returns the Tags field value if set, zero value otherwise.
func (o *KnowledgeNode) GetTags() []string {
if o == nil || IsNil(o.Tags) {
var ret []string
return ret
}
return o.Tags
}
// GetTagsOk returns a tuple with the Tags field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *KnowledgeNode) GetTagsOk() ([]string, bool) {
if o == nil || IsNil(o.Tags) {
return nil, false
}
return o.Tags, true
}
// HasTags returns a boolean if a field has been set.
func (o *KnowledgeNode) HasTags() bool {
if o != nil && !IsNil(o.Tags) {
return true
}
return false
}
// SetTags gets a reference to the given []string and assigns it to the Tags field.
func (o *KnowledgeNode) SetTags(v []string) {
o.Tags = v
}
// GetTimestamp returns the Timestamp field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *KnowledgeNode) GetTimestamp() string {
if o == nil || IsNil(o.Timestamp.Get()) {
var ret string
return ret
}
return *o.Timestamp.Get()
}
// GetTimestampOk returns a tuple with the Timestamp field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *KnowledgeNode) GetTimestampOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.Timestamp.Get(), o.Timestamp.IsSet()
}
// HasTimestamp returns a boolean if a field has been set.
func (o *KnowledgeNode) HasTimestamp() bool {
if o != nil && o.Timestamp.IsSet() {
return true
}
return false
}
// SetTimestamp gets a reference to the given NullableString and assigns it to the Timestamp field.
func (o *KnowledgeNode) SetTimestamp(v string) {
o.Timestamp.Set(&v)
}
// SetTimestampNil sets the value for Timestamp to be an explicit nil
func (o *KnowledgeNode) SetTimestampNil() {
o.Timestamp.Set(nil)
}
// UnsetTimestamp ensures that no value is present for Timestamp, not even an explicit nil
func (o *KnowledgeNode) UnsetTimestamp() {
o.Timestamp.Unset()
}
// GetChildren returns the Children field value if set, zero value otherwise.
func (o *KnowledgeNode) GetChildren() []KnowledgeNode {
if o == nil || IsNil(o.Children) {
var ret []KnowledgeNode
return ret
}
return o.Children
}
// GetChildrenOk returns a tuple with the Children field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *KnowledgeNode) GetChildrenOk() ([]KnowledgeNode, bool) {
if o == nil || IsNil(o.Children) {
return nil, false
}
return o.Children, true
}
// HasChildren returns a boolean if a field has been set.
func (o *KnowledgeNode) HasChildren() bool {
if o != nil && !IsNil(o.Children) {
return true
}
return false
}
// SetChildren gets a reference to the given []KnowledgeNode and assigns it to the Children field.
func (o *KnowledgeNode) SetChildren(v []KnowledgeNode) {
o.Children = v
}
func (o KnowledgeNode) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o KnowledgeNode) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["id"] = o.Id
toSerialize["kind"] = o.Kind
toSerialize["name"] = o.Name
if o.ParentId.IsSet() {
toSerialize["parent_id"] = o.ParentId.Get()
}
if o.MentalModelId.IsSet() {
toSerialize["mental_model_id"] = o.MentalModelId.Get()
}
if !IsNil(o.Managed) {
toSerialize["managed"] = o.Managed
}
if o.Description.IsSet() {
toSerialize["description"] = o.Description.Get()
}
if !IsNil(o.Tags) {
toSerialize["tags"] = o.Tags
}
if o.Timestamp.IsSet() {
toSerialize["timestamp"] = o.Timestamp.Get()
}
if !IsNil(o.Children) {
toSerialize["children"] = o.Children
}
return toSerialize, nil
}
func (o *KnowledgeNode) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"id",
"kind",
"name",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varKnowledgeNode := _KnowledgeNode{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varKnowledgeNode)
if err != nil {
return err
}
*o = KnowledgeNode(varKnowledgeNode)
return err
}
type NullableKnowledgeNode struct {
value *KnowledgeNode
isSet bool
}
func (v NullableKnowledgeNode) Get() *KnowledgeNode {
return v.value
}
func (v *NullableKnowledgeNode) Set(val *KnowledgeNode) {
v.value = val
v.isSet = true
}
func (v NullableKnowledgeNode) IsSet() bool {
return v.isSet
}
func (v *NullableKnowledgeNode) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableKnowledgeNode(val *KnowledgeNode) *NullableKnowledgeNode {
return &NullableKnowledgeNode{value: val, isSet: true}
}
func (v NullableKnowledgeNode) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableKnowledgeNode) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,186 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the KnowledgePageBundleFile type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &KnowledgePageBundleFile{}
// KnowledgePageBundleFile One file in a portable OKF bundle.
type KnowledgePageBundleFile struct {
Path string `json:"path"`
Content string `json:"content"`
}
type _KnowledgePageBundleFile KnowledgePageBundleFile
// NewKnowledgePageBundleFile instantiates a new KnowledgePageBundleFile object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewKnowledgePageBundleFile(path string, content string) *KnowledgePageBundleFile {
this := KnowledgePageBundleFile{}
this.Path = path
this.Content = content
return &this
}
// NewKnowledgePageBundleFileWithDefaults instantiates a new KnowledgePageBundleFile object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewKnowledgePageBundleFileWithDefaults() *KnowledgePageBundleFile {
this := KnowledgePageBundleFile{}
return &this
}
// GetPath returns the Path field value
func (o *KnowledgePageBundleFile) GetPath() string {
if o == nil {
var ret string
return ret
}
return o.Path
}
// GetPathOk returns a tuple with the Path field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageBundleFile) GetPathOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Path, true
}
// SetPath sets field value
func (o *KnowledgePageBundleFile) SetPath(v string) {
o.Path = v
}
// GetContent returns the Content field value
func (o *KnowledgePageBundleFile) GetContent() string {
if o == nil {
var ret string
return ret
}
return o.Content
}
// GetContentOk returns a tuple with the Content field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageBundleFile) GetContentOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Content, true
}
// SetContent sets field value
func (o *KnowledgePageBundleFile) SetContent(v string) {
o.Content = v
}
func (o KnowledgePageBundleFile) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o KnowledgePageBundleFile) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["path"] = o.Path
toSerialize["content"] = o.Content
return toSerialize, nil
}
func (o *KnowledgePageBundleFile) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"path",
"content",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varKnowledgePageBundleFile := _KnowledgePageBundleFile{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varKnowledgePageBundleFile)
if err != nil {
return err
}
*o = KnowledgePageBundleFile(varKnowledgePageBundleFile)
return err
}
type NullableKnowledgePageBundleFile struct {
value *KnowledgePageBundleFile
isSet bool
}
func (v NullableKnowledgePageBundleFile) Get() *KnowledgePageBundleFile {
return v.value
}
func (v *NullableKnowledgePageBundleFile) Set(val *KnowledgePageBundleFile) {
v.value = val
v.isSet = true
}
func (v NullableKnowledgePageBundleFile) IsSet() bool {
return v.isSet
}
func (v *NullableKnowledgePageBundleFile) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableKnowledgePageBundleFile(val *KnowledgePageBundleFile) *NullableKnowledgePageBundleFile {
return &NullableKnowledgePageBundleFile{value: val, isSet: true}
}
func (v NullableKnowledgePageBundleFile) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableKnowledgePageBundleFile) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,158 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the KnowledgePageBundleResponse type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &KnowledgePageBundleResponse{}
// KnowledgePageBundleResponse A portable OKF bundle — a flat set of markdown files (index + pages + logs).
type KnowledgePageBundleResponse struct {
Files []KnowledgePageBundleFile `json:"files"`
}
type _KnowledgePageBundleResponse KnowledgePageBundleResponse
// NewKnowledgePageBundleResponse instantiates a new KnowledgePageBundleResponse object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewKnowledgePageBundleResponse(files []KnowledgePageBundleFile) *KnowledgePageBundleResponse {
this := KnowledgePageBundleResponse{}
this.Files = files
return &this
}
// NewKnowledgePageBundleResponseWithDefaults instantiates a new KnowledgePageBundleResponse object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewKnowledgePageBundleResponseWithDefaults() *KnowledgePageBundleResponse {
this := KnowledgePageBundleResponse{}
return &this
}
// GetFiles returns the Files field value
func (o *KnowledgePageBundleResponse) GetFiles() []KnowledgePageBundleFile {
if o == nil {
var ret []KnowledgePageBundleFile
return ret
}
return o.Files
}
// GetFilesOk returns a tuple with the Files field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageBundleResponse) GetFilesOk() ([]KnowledgePageBundleFile, bool) {
if o == nil {
return nil, false
}
return o.Files, true
}
// SetFiles sets field value
func (o *KnowledgePageBundleResponse) SetFiles(v []KnowledgePageBundleFile) {
o.Files = v
}
func (o KnowledgePageBundleResponse) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o KnowledgePageBundleResponse) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["files"] = o.Files
return toSerialize, nil
}
func (o *KnowledgePageBundleResponse) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"files",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varKnowledgePageBundleResponse := _KnowledgePageBundleResponse{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varKnowledgePageBundleResponse)
if err != nil {
return err
}
*o = KnowledgePageBundleResponse(varKnowledgePageBundleResponse)
return err
}
type NullableKnowledgePageBundleResponse struct {
value *KnowledgePageBundleResponse
isSet bool
}
func (v NullableKnowledgePageBundleResponse) Get() *KnowledgePageBundleResponse {
return v.value
}
func (v *NullableKnowledgePageBundleResponse) Set(val *KnowledgePageBundleResponse) {
v.value = val
v.isSet = true
}
func (v NullableKnowledgePageBundleResponse) IsSet() bool {
return v.isSet
}
func (v *NullableKnowledgePageBundleResponse) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableKnowledgePageBundleResponse(val *KnowledgePageBundleResponse) *NullableKnowledgePageBundleResponse {
return &NullableKnowledgePageBundleResponse{value: val, isSet: true}
}
func (v NullableKnowledgePageBundleResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableKnowledgePageBundleResponse) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,242 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the KnowledgePageGraphResponse type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &KnowledgePageGraphResponse{}
// KnowledgePageGraphResponse Constellation graph of knowledge pages linked by shared tags.
type KnowledgePageGraphResponse struct {
Nodes []map[string]interface{} `json:"nodes"`
Edges []map[string]interface{} `json:"edges"`
TotalPages int32 `json:"total_pages"`
TotalEdges int32 `json:"total_edges"`
}
type _KnowledgePageGraphResponse KnowledgePageGraphResponse
// NewKnowledgePageGraphResponse instantiates a new KnowledgePageGraphResponse object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewKnowledgePageGraphResponse(nodes []map[string]interface{}, edges []map[string]interface{}, totalPages int32, totalEdges int32) *KnowledgePageGraphResponse {
this := KnowledgePageGraphResponse{}
this.Nodes = nodes
this.Edges = edges
this.TotalPages = totalPages
this.TotalEdges = totalEdges
return &this
}
// NewKnowledgePageGraphResponseWithDefaults instantiates a new KnowledgePageGraphResponse object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewKnowledgePageGraphResponseWithDefaults() *KnowledgePageGraphResponse {
this := KnowledgePageGraphResponse{}
return &this
}
// GetNodes returns the Nodes field value
func (o *KnowledgePageGraphResponse) GetNodes() []map[string]interface{} {
if o == nil {
var ret []map[string]interface{}
return ret
}
return o.Nodes
}
// GetNodesOk returns a tuple with the Nodes field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageGraphResponse) GetNodesOk() ([]map[string]interface{}, bool) {
if o == nil {
return nil, false
}
return o.Nodes, true
}
// SetNodes sets field value
func (o *KnowledgePageGraphResponse) SetNodes(v []map[string]interface{}) {
o.Nodes = v
}
// GetEdges returns the Edges field value
func (o *KnowledgePageGraphResponse) GetEdges() []map[string]interface{} {
if o == nil {
var ret []map[string]interface{}
return ret
}
return o.Edges
}
// GetEdgesOk returns a tuple with the Edges field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageGraphResponse) GetEdgesOk() ([]map[string]interface{}, bool) {
if o == nil {
return nil, false
}
return o.Edges, true
}
// SetEdges sets field value
func (o *KnowledgePageGraphResponse) SetEdges(v []map[string]interface{}) {
o.Edges = v
}
// GetTotalPages returns the TotalPages field value
func (o *KnowledgePageGraphResponse) GetTotalPages() int32 {
if o == nil {
var ret int32
return ret
}
return o.TotalPages
}
// GetTotalPagesOk returns a tuple with the TotalPages field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageGraphResponse) GetTotalPagesOk() (*int32, bool) {
if o == nil {
return nil, false
}
return &o.TotalPages, true
}
// SetTotalPages sets field value
func (o *KnowledgePageGraphResponse) SetTotalPages(v int32) {
o.TotalPages = v
}
// GetTotalEdges returns the TotalEdges field value
func (o *KnowledgePageGraphResponse) GetTotalEdges() int32 {
if o == nil {
var ret int32
return ret
}
return o.TotalEdges
}
// GetTotalEdgesOk returns a tuple with the TotalEdges field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageGraphResponse) GetTotalEdgesOk() (*int32, bool) {
if o == nil {
return nil, false
}
return &o.TotalEdges, true
}
// SetTotalEdges sets field value
func (o *KnowledgePageGraphResponse) SetTotalEdges(v int32) {
o.TotalEdges = v
}
func (o KnowledgePageGraphResponse) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o KnowledgePageGraphResponse) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["nodes"] = o.Nodes
toSerialize["edges"] = o.Edges
toSerialize["total_pages"] = o.TotalPages
toSerialize["total_edges"] = o.TotalEdges
return toSerialize, nil
}
func (o *KnowledgePageGraphResponse) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"nodes",
"edges",
"total_pages",
"total_edges",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varKnowledgePageGraphResponse := _KnowledgePageGraphResponse{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varKnowledgePageGraphResponse)
if err != nil {
return err
}
*o = KnowledgePageGraphResponse(varKnowledgePageGraphResponse)
return err
}
type NullableKnowledgePageGraphResponse struct {
value *KnowledgePageGraphResponse
isSet bool
}
func (v NullableKnowledgePageGraphResponse) Get() *KnowledgePageGraphResponse {
return v.value
}
func (v *NullableKnowledgePageGraphResponse) Set(val *KnowledgePageGraphResponse) {
v.value = val
v.isSet = true
}
func (v NullableKnowledgePageGraphResponse) IsSet() bool {
return v.isSet
}
func (v *NullableKnowledgePageGraphResponse) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableKnowledgePageGraphResponse(val *KnowledgePageGraphResponse) *NullableKnowledgePageGraphResponse {
return &NullableKnowledgePageGraphResponse{value: val, isSet: true}
}
func (v NullableKnowledgePageGraphResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableKnowledgePageGraphResponse) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,418 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the KnowledgePageResponse type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &KnowledgePageResponse{}
// KnowledgePageResponse A knowledge page rendered as an OKF document.
type KnowledgePageResponse struct {
Id string `json:"id"`
Name string `json:"name"`
// OKF document type — from a `type:<x>` tag, else 'knowledge-page'.
Type string `json:"type"`
Description NullableString `json:"description,omitempty"`
Tags []string `json:"tags,omitempty"`
Timestamp NullableString `json:"timestamp,omitempty"`
Body NullableString `json:"body,omitempty"`
// The full OKF document: YAML frontmatter + markdown body.
Markdown string `json:"markdown"`
}
type _KnowledgePageResponse KnowledgePageResponse
// NewKnowledgePageResponse instantiates a new KnowledgePageResponse object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewKnowledgePageResponse(id string, name string, type_ string, markdown string) *KnowledgePageResponse {
this := KnowledgePageResponse{}
this.Id = id
this.Name = name
this.Type = type_
this.Markdown = markdown
return &this
}
// NewKnowledgePageResponseWithDefaults instantiates a new KnowledgePageResponse object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewKnowledgePageResponseWithDefaults() *KnowledgePageResponse {
this := KnowledgePageResponse{}
return &this
}
// GetId returns the Id field value
func (o *KnowledgePageResponse) GetId() string {
if o == nil {
var ret string
return ret
}
return o.Id
}
// GetIdOk returns a tuple with the Id field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageResponse) GetIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Id, true
}
// SetId sets field value
func (o *KnowledgePageResponse) SetId(v string) {
o.Id = v
}
// GetName returns the Name field value
func (o *KnowledgePageResponse) GetName() string {
if o == nil {
var ret string
return ret
}
return o.Name
}
// GetNameOk returns a tuple with the Name field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageResponse) GetNameOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Name, true
}
// SetName sets field value
func (o *KnowledgePageResponse) SetName(v string) {
o.Name = v
}
// GetType returns the Type field value
func (o *KnowledgePageResponse) GetType() string {
if o == nil {
var ret string
return ret
}
return o.Type
}
// GetTypeOk returns a tuple with the Type field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageResponse) GetTypeOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Type, true
}
// SetType sets field value
func (o *KnowledgePageResponse) SetType(v string) {
o.Type = v
}
// GetDescription returns the Description field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *KnowledgePageResponse) GetDescription() string {
if o == nil || IsNil(o.Description.Get()) {
var ret string
return ret
}
return *o.Description.Get()
}
// GetDescriptionOk returns a tuple with the Description field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *KnowledgePageResponse) GetDescriptionOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.Description.Get(), o.Description.IsSet()
}
// HasDescription returns a boolean if a field has been set.
func (o *KnowledgePageResponse) HasDescription() bool {
if o != nil && o.Description.IsSet() {
return true
}
return false
}
// SetDescription gets a reference to the given NullableString and assigns it to the Description field.
func (o *KnowledgePageResponse) SetDescription(v string) {
o.Description.Set(&v)
}
// SetDescriptionNil sets the value for Description to be an explicit nil
func (o *KnowledgePageResponse) SetDescriptionNil() {
o.Description.Set(nil)
}
// UnsetDescription ensures that no value is present for Description, not even an explicit nil
func (o *KnowledgePageResponse) UnsetDescription() {
o.Description.Unset()
}
// GetTags returns the Tags field value if set, zero value otherwise.
func (o *KnowledgePageResponse) GetTags() []string {
if o == nil || IsNil(o.Tags) {
var ret []string
return ret
}
return o.Tags
}
// GetTagsOk returns a tuple with the Tags field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *KnowledgePageResponse) GetTagsOk() ([]string, bool) {
if o == nil || IsNil(o.Tags) {
return nil, false
}
return o.Tags, true
}
// HasTags returns a boolean if a field has been set.
func (o *KnowledgePageResponse) HasTags() bool {
if o != nil && !IsNil(o.Tags) {
return true
}
return false
}
// SetTags gets a reference to the given []string and assigns it to the Tags field.
func (o *KnowledgePageResponse) SetTags(v []string) {
o.Tags = v
}
// GetTimestamp returns the Timestamp field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *KnowledgePageResponse) GetTimestamp() string {
if o == nil || IsNil(o.Timestamp.Get()) {
var ret string
return ret
}
return *o.Timestamp.Get()
}
// GetTimestampOk returns a tuple with the Timestamp field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *KnowledgePageResponse) GetTimestampOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.Timestamp.Get(), o.Timestamp.IsSet()
}
// HasTimestamp returns a boolean if a field has been set.
func (o *KnowledgePageResponse) HasTimestamp() bool {
if o != nil && o.Timestamp.IsSet() {
return true
}
return false
}
// SetTimestamp gets a reference to the given NullableString and assigns it to the Timestamp field.
func (o *KnowledgePageResponse) SetTimestamp(v string) {
o.Timestamp.Set(&v)
}
// SetTimestampNil sets the value for Timestamp to be an explicit nil
func (o *KnowledgePageResponse) SetTimestampNil() {
o.Timestamp.Set(nil)
}
// UnsetTimestamp ensures that no value is present for Timestamp, not even an explicit nil
func (o *KnowledgePageResponse) UnsetTimestamp() {
o.Timestamp.Unset()
}
// GetBody returns the Body field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *KnowledgePageResponse) GetBody() string {
if o == nil || IsNil(o.Body.Get()) {
var ret string
return ret
}
return *o.Body.Get()
}
// GetBodyOk returns a tuple with the Body field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *KnowledgePageResponse) GetBodyOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.Body.Get(), o.Body.IsSet()
}
// HasBody returns a boolean if a field has been set.
func (o *KnowledgePageResponse) HasBody() bool {
if o != nil && o.Body.IsSet() {
return true
}
return false
}
// SetBody gets a reference to the given NullableString and assigns it to the Body field.
func (o *KnowledgePageResponse) SetBody(v string) {
o.Body.Set(&v)
}
// SetBodyNil sets the value for Body to be an explicit nil
func (o *KnowledgePageResponse) SetBodyNil() {
o.Body.Set(nil)
}
// UnsetBody ensures that no value is present for Body, not even an explicit nil
func (o *KnowledgePageResponse) UnsetBody() {
o.Body.Unset()
}
// GetMarkdown returns the Markdown field value
func (o *KnowledgePageResponse) GetMarkdown() string {
if o == nil {
var ret string
return ret
}
return o.Markdown
}
// GetMarkdownOk returns a tuple with the Markdown field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageResponse) GetMarkdownOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Markdown, true
}
// SetMarkdown sets field value
func (o *KnowledgePageResponse) SetMarkdown(v string) {
o.Markdown = v
}
func (o KnowledgePageResponse) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o KnowledgePageResponse) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["id"] = o.Id
toSerialize["name"] = o.Name
toSerialize["type"] = o.Type
if o.Description.IsSet() {
toSerialize["description"] = o.Description.Get()
}
if !IsNil(o.Tags) {
toSerialize["tags"] = o.Tags
}
if o.Timestamp.IsSet() {
toSerialize["timestamp"] = o.Timestamp.Get()
}
if o.Body.IsSet() {
toSerialize["body"] = o.Body.Get()
}
toSerialize["markdown"] = o.Markdown
return toSerialize, nil
}
func (o *KnowledgePageResponse) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"id",
"name",
"type",
"markdown",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varKnowledgePageResponse := _KnowledgePageResponse{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varKnowledgePageResponse)
if err != nil {
return err
}
*o = KnowledgePageResponse(varKnowledgePageResponse)
return err
}
type NullableKnowledgePageResponse struct {
value *KnowledgePageResponse
isSet bool
}
func (v NullableKnowledgePageResponse) Get() *KnowledgePageResponse {
return v.value
}
func (v *NullableKnowledgePageResponse) Set(val *KnowledgePageResponse) {
v.value = val
v.isSet = true
}
func (v NullableKnowledgePageResponse) IsSet() bool {
return v.isSet
}
func (v *NullableKnowledgePageResponse) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableKnowledgePageResponse(val *KnowledgePageResponse) *NullableKnowledgePageResponse {
return &NullableKnowledgePageResponse{value: val, isSet: true}
}
func (v NullableKnowledgePageResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableKnowledgePageResponse) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,158 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the KnowledgeTreeResponse type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &KnowledgeTreeResponse{}
// KnowledgeTreeResponse The knowledge base as a nested folder/page tree.
type KnowledgeTreeResponse struct {
Roots []KnowledgeNode `json:"roots"`
}
type _KnowledgeTreeResponse KnowledgeTreeResponse
// NewKnowledgeTreeResponse instantiates a new KnowledgeTreeResponse object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewKnowledgeTreeResponse(roots []KnowledgeNode) *KnowledgeTreeResponse {
this := KnowledgeTreeResponse{}
this.Roots = roots
return &this
}
// NewKnowledgeTreeResponseWithDefaults instantiates a new KnowledgeTreeResponse object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewKnowledgeTreeResponseWithDefaults() *KnowledgeTreeResponse {
this := KnowledgeTreeResponse{}
return &this
}
// GetRoots returns the Roots field value
func (o *KnowledgeTreeResponse) GetRoots() []KnowledgeNode {
if o == nil {
var ret []KnowledgeNode
return ret
}
return o.Roots
}
// GetRootsOk returns a tuple with the Roots field value
// and a boolean to check if the value has been set.
func (o *KnowledgeTreeResponse) GetRootsOk() ([]KnowledgeNode, bool) {
if o == nil {
return nil, false
}
return o.Roots, true
}
// SetRoots sets field value
func (o *KnowledgeTreeResponse) SetRoots(v []KnowledgeNode) {
o.Roots = v
}
func (o KnowledgeTreeResponse) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o KnowledgeTreeResponse) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["roots"] = o.Roots
return toSerialize, nil
}
func (o *KnowledgeTreeResponse) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"roots",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varKnowledgeTreeResponse := _KnowledgeTreeResponse{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varKnowledgeTreeResponse)
if err != nil {
return err
}
*o = KnowledgeTreeResponse(varKnowledgeTreeResponse)
return err
}
type NullableKnowledgeTreeResponse struct {
value *KnowledgeTreeResponse
isSet bool
}
func (v NullableKnowledgeTreeResponse) Get() *KnowledgeTreeResponse {
return v.value
}
func (v *NullableKnowledgeTreeResponse) Set(val *KnowledgeTreeResponse) {
v.value = val
v.isSet = true
}
func (v NullableKnowledgeTreeResponse) IsSet() bool {
return v.isSet
}
func (v *NullableKnowledgeTreeResponse) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableKnowledgeTreeResponse(val *KnowledgeTreeResponse) *NullableKnowledgeTreeResponse {
return &NullableKnowledgeTreeResponse{value: val, isSet: true}
}
func (v NullableKnowledgeTreeResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableKnowledgeTreeResponse) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,182 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
)
// checks if the UpdateNodeRequest type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &UpdateNodeRequest{}
// UpdateNodeRequest Rename and/or move a node. Each field applies only when present.
type UpdateNodeRequest struct {
Name NullableString `json:"name,omitempty"`
ParentId NullableString `json:"parent_id,omitempty"`
}
// NewUpdateNodeRequest instantiates a new UpdateNodeRequest object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewUpdateNodeRequest() *UpdateNodeRequest {
this := UpdateNodeRequest{}
return &this
}
// NewUpdateNodeRequestWithDefaults instantiates a new UpdateNodeRequest object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewUpdateNodeRequestWithDefaults() *UpdateNodeRequest {
this := UpdateNodeRequest{}
return &this
}
// GetName returns the Name field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *UpdateNodeRequest) GetName() string {
if o == nil || IsNil(o.Name.Get()) {
var ret string
return ret
}
return *o.Name.Get()
}
// GetNameOk returns a tuple with the Name field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *UpdateNodeRequest) GetNameOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.Name.Get(), o.Name.IsSet()
}
// HasName returns a boolean if a field has been set.
func (o *UpdateNodeRequest) HasName() bool {
if o != nil && o.Name.IsSet() {
return true
}
return false
}
// SetName gets a reference to the given NullableString and assigns it to the Name field.
func (o *UpdateNodeRequest) SetName(v string) {
o.Name.Set(&v)
}
// SetNameNil sets the value for Name to be an explicit nil
func (o *UpdateNodeRequest) SetNameNil() {
o.Name.Set(nil)
}
// UnsetName ensures that no value is present for Name, not even an explicit nil
func (o *UpdateNodeRequest) UnsetName() {
o.Name.Unset()
}
// GetParentId returns the ParentId field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *UpdateNodeRequest) GetParentId() string {
if o == nil || IsNil(o.ParentId.Get()) {
var ret string
return ret
}
return *o.ParentId.Get()
}
// GetParentIdOk returns a tuple with the ParentId field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *UpdateNodeRequest) GetParentIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.ParentId.Get(), o.ParentId.IsSet()
}
// HasParentId returns a boolean if a field has been set.
func (o *UpdateNodeRequest) HasParentId() bool {
if o != nil && o.ParentId.IsSet() {
return true
}
return false
}
// SetParentId gets a reference to the given NullableString and assigns it to the ParentId field.
func (o *UpdateNodeRequest) SetParentId(v string) {
o.ParentId.Set(&v)
}
// SetParentIdNil sets the value for ParentId to be an explicit nil
func (o *UpdateNodeRequest) SetParentIdNil() {
o.ParentId.Set(nil)
}
// UnsetParentId ensures that no value is present for ParentId, not even an explicit nil
func (o *UpdateNodeRequest) UnsetParentId() {
o.ParentId.Unset()
}
func (o UpdateNodeRequest) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o UpdateNodeRequest) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
if o.Name.IsSet() {
toSerialize["name"] = o.Name.Get()
}
if o.ParentId.IsSet() {
toSerialize["parent_id"] = o.ParentId.Get()
}
return toSerialize, nil
}
type NullableUpdateNodeRequest struct {
value *UpdateNodeRequest
isSet bool
}
func (v NullableUpdateNodeRequest) Get() *UpdateNodeRequest {
return v.value
}
func (v *NullableUpdateNodeRequest) Set(val *UpdateNodeRequest) {
v.value = val
v.isSet = true
}
func (v NullableUpdateNodeRequest) IsSet() bool {
return v.isSet
}
func (v *NullableUpdateNodeRequest) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableUpdateNodeRequest(val *UpdateNodeRequest) *NullableUpdateNodeRequest {
return &NullableUpdateNodeRequest{value: val, isSet: true}
}
func (v NullableUpdateNodeRequest) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableUpdateNodeRequest) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -9,6 +9,7 @@ hindsight_client_api/api/document_transfer_api.py
hindsight_client_api/api/documents_api.py
hindsight_client_api/api/entities_api.py
hindsight_client_api/api/files_api.py
hindsight_client_api/api/knowledge_base_api.py
hindsight_client_api/api/llm_traces_api.py
hindsight_client_api/api/memory_api.py
hindsight_client_api/api/mental_models_api.py
@@ -50,8 +51,11 @@ hindsight_client_api/models/consolidation_request.py
hindsight_client_api/models/consolidation_response.py
hindsight_client_api/models/create_bank_request.py
hindsight_client_api/models/create_directive_request.py
hindsight_client_api/models/create_folder_request.py
hindsight_client_api/models/create_knowledge_page_response.py
hindsight_client_api/models/create_mental_model_request.py
hindsight_client_api/models/create_mental_model_response.py
hindsight_client_api/models/create_page_request.py
hindsight_client_api/models/create_webhook_request.py
hindsight_client_api/models/delete_document_response.py
hindsight_client_api/models/delete_response.py
@@ -76,6 +80,12 @@ hindsight_client_api/models/file_retain_response.py
hindsight_client_api/models/graph_data_response.py
hindsight_client_api/models/http_validation_error.py
hindsight_client_api/models/include_options.py
hindsight_client_api/models/knowledge_node.py
hindsight_client_api/models/knowledge_page_bundle_file.py
hindsight_client_api/models/knowledge_page_bundle_response.py
hindsight_client_api/models/knowledge_page_graph_response.py
hindsight_client_api/models/knowledge_page_response.py
hindsight_client_api/models/knowledge_tree_response.py
hindsight_client_api/models/list_chunks_response.py
hindsight_client_api/models/list_documents_response.py
hindsight_client_api/models/list_memory_units_response.py
@@ -142,6 +152,7 @@ hindsight_client_api/models/update_document_request.py
hindsight_client_api/models/update_document_response.py
hindsight_client_api/models/update_memory_request.py
hindsight_client_api/models/update_mental_model_request.py
hindsight_client_api/models/update_node_request.py
hindsight_client_api/models/update_webhook_request.py
hindsight_client_api/models/validation_error.py
hindsight_client_api/models/validation_error_loc_inner.py
@@ -25,6 +25,7 @@ from hindsight_client_api.api.document_transfer_api import DocumentTransferApi
from hindsight_client_api.api.documents_api import DocumentsApi
from hindsight_client_api.api.entities_api import EntitiesApi
from hindsight_client_api.api.files_api import FilesApi
from hindsight_client_api.api.knowledge_base_api import KnowledgeBaseApi
from hindsight_client_api.api.llm_traces_api import LLMTracesApi
from hindsight_client_api.api.memory_api import MemoryApi
from hindsight_client_api.api.mental_models_api import MentalModelsApi
@@ -74,8 +75,11 @@ from hindsight_client_api.models.consolidation_request import ConsolidationReque
from hindsight_client_api.models.consolidation_response import ConsolidationResponse
from hindsight_client_api.models.create_bank_request import CreateBankRequest
from hindsight_client_api.models.create_directive_request import CreateDirectiveRequest
from hindsight_client_api.models.create_folder_request import CreateFolderRequest
from hindsight_client_api.models.create_knowledge_page_response import CreateKnowledgePageResponse
from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest
from hindsight_client_api.models.create_mental_model_response import CreateMentalModelResponse
from hindsight_client_api.models.create_page_request import CreatePageRequest
from hindsight_client_api.models.create_webhook_request import CreateWebhookRequest
from hindsight_client_api.models.delete_document_response import DeleteDocumentResponse
from hindsight_client_api.models.delete_response import DeleteResponse
@@ -100,6 +104,12 @@ from hindsight_client_api.models.file_retain_response import FileRetainResponse
from hindsight_client_api.models.graph_data_response import GraphDataResponse
from hindsight_client_api.models.http_validation_error import HTTPValidationError
from hindsight_client_api.models.include_options import IncludeOptions
from hindsight_client_api.models.knowledge_node import KnowledgeNode
from hindsight_client_api.models.knowledge_page_bundle_file import KnowledgePageBundleFile
from hindsight_client_api.models.knowledge_page_bundle_response import KnowledgePageBundleResponse
from hindsight_client_api.models.knowledge_page_graph_response import KnowledgePageGraphResponse
from hindsight_client_api.models.knowledge_page_response import KnowledgePageResponse
from hindsight_client_api.models.knowledge_tree_response import KnowledgeTreeResponse
from hindsight_client_api.models.llm_request_entry import LLMRequestEntry
from hindsight_client_api.models.llm_request_list_response import LLMRequestListResponse
from hindsight_client_api.models.llm_request_stats_bucket import LLMRequestStatsBucket
@@ -166,6 +176,7 @@ from hindsight_client_api.models.update_document_request import UpdateDocumentRe
from hindsight_client_api.models.update_document_response import UpdateDocumentResponse
from hindsight_client_api.models.update_memory_request import UpdateMemoryRequest
from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest
from hindsight_client_api.models.update_node_request import UpdateNodeRequest
from hindsight_client_api.models.update_webhook_request import UpdateWebhookRequest
from hindsight_client_api.models.validation_error import ValidationError
from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner
@@ -9,6 +9,7 @@ from hindsight_client_api.api.document_transfer_api import DocumentTransferApi
from hindsight_client_api.api.documents_api import DocumentsApi
from hindsight_client_api.api.entities_api import EntitiesApi
from hindsight_client_api.api.files_api import FilesApi
from hindsight_client_api.api.knowledge_base_api import KnowledgeBaseApi
from hindsight_client_api.api.llm_traces_api import LLMTracesApi
from hindsight_client_api.api.memory_api import MemoryApi
from hindsight_client_api.api.mental_models_api import MentalModelsApi
File diff suppressed because it is too large Load Diff
@@ -44,8 +44,11 @@ from hindsight_client_api.models.consolidation_request import ConsolidationReque
from hindsight_client_api.models.consolidation_response import ConsolidationResponse
from hindsight_client_api.models.create_bank_request import CreateBankRequest
from hindsight_client_api.models.create_directive_request import CreateDirectiveRequest
from hindsight_client_api.models.create_folder_request import CreateFolderRequest
from hindsight_client_api.models.create_knowledge_page_response import CreateKnowledgePageResponse
from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest
from hindsight_client_api.models.create_mental_model_response import CreateMentalModelResponse
from hindsight_client_api.models.create_page_request import CreatePageRequest
from hindsight_client_api.models.create_webhook_request import CreateWebhookRequest
from hindsight_client_api.models.delete_document_response import DeleteDocumentResponse
from hindsight_client_api.models.delete_response import DeleteResponse
@@ -70,6 +73,12 @@ from hindsight_client_api.models.file_retain_response import FileRetainResponse
from hindsight_client_api.models.graph_data_response import GraphDataResponse
from hindsight_client_api.models.http_validation_error import HTTPValidationError
from hindsight_client_api.models.include_options import IncludeOptions
from hindsight_client_api.models.knowledge_node import KnowledgeNode
from hindsight_client_api.models.knowledge_page_bundle_file import KnowledgePageBundleFile
from hindsight_client_api.models.knowledge_page_bundle_response import KnowledgePageBundleResponse
from hindsight_client_api.models.knowledge_page_graph_response import KnowledgePageGraphResponse
from hindsight_client_api.models.knowledge_page_response import KnowledgePageResponse
from hindsight_client_api.models.knowledge_tree_response import KnowledgeTreeResponse
from hindsight_client_api.models.llm_request_entry import LLMRequestEntry
from hindsight_client_api.models.llm_request_list_response import LLMRequestListResponse
from hindsight_client_api.models.llm_request_stats_bucket import LLMRequestStatsBucket
@@ -136,6 +145,7 @@ from hindsight_client_api.models.update_document_request import UpdateDocumentRe
from hindsight_client_api.models.update_document_response import UpdateDocumentResponse
from hindsight_client_api.models.update_memory_request import UpdateMemoryRequest
from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest
from hindsight_client_api.models.update_node_request import UpdateNodeRequest
from hindsight_client_api.models.update_webhook_request import UpdateWebhookRequest
from hindsight_client_api.models.validation_error import ValidationError
from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner
@@ -0,0 +1,94 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class CreateFolderRequest(BaseModel):
"""
Create a folder under an optional parent folder.
""" # noqa: E501
name: StrictStr
parent_id: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["name", "parent_id"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of CreateFolderRequest from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# set to None if parent_id (nullable) is None
# and model_fields_set contains the field
if self.parent_id is None and "parent_id" in self.model_fields_set:
_dict['parent_id'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of CreateFolderRequest from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"name": obj.get("name"),
"parent_id": obj.get("parent_id")
})
return _obj
@@ -0,0 +1,96 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class CreateKnowledgePageResponse(BaseModel):
"""
Result of creating a page: the node id, its mental model, and the refresh op.
""" # noqa: E501
page_id: StrictStr
mental_model_id: StrictStr
operation_id: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["page_id", "mental_model_id", "operation_id"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of CreateKnowledgePageResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# set to None if operation_id (nullable) is None
# and model_fields_set contains the field
if self.operation_id is None and "operation_id" in self.model_fields_set:
_dict['operation_id'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of CreateKnowledgePageResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"page_id": obj.get("page_id"),
"mental_model_id": obj.get("mental_model_id"),
"operation_id": obj.get("operation_id")
})
return _obj
@@ -0,0 +1,121 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictInt, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.mental_model_trigger_input import MentalModelTriggerInput
from typing import Optional, Set
from typing_extensions import Self
class CreatePageRequest(BaseModel):
"""
Create a page (a mental model + tree node) under an optional parent folder.
""" # noqa: E501
name: StrictStr
source_query: StrictStr
parent_id: Optional[StrictStr] = None
tags: Optional[List[StrictStr]] = None
max_tokens: Optional[StrictInt] = None
trigger: Optional[MentalModelTriggerInput] = None
__properties: ClassVar[List[str]] = ["name", "source_query", "parent_id", "tags", "max_tokens", "trigger"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of CreatePageRequest from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of trigger
if self.trigger:
_dict['trigger'] = self.trigger.to_dict()
# set to None if parent_id (nullable) is None
# and model_fields_set contains the field
if self.parent_id is None and "parent_id" in self.model_fields_set:
_dict['parent_id'] = None
# set to None if tags (nullable) is None
# and model_fields_set contains the field
if self.tags is None and "tags" in self.model_fields_set:
_dict['tags'] = None
# set to None if max_tokens (nullable) is None
# and model_fields_set contains the field
if self.max_tokens is None and "max_tokens" in self.model_fields_set:
_dict['max_tokens'] = None
# set to None if trigger (nullable) is None
# and model_fields_set contains the field
if self.trigger is None and "trigger" in self.model_fields_set:
_dict['trigger'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of CreatePageRequest from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"name": obj.get("name"),
"source_query": obj.get("source_query"),
"parent_id": obj.get("parent_id"),
"tags": obj.get("tags"),
"max_tokens": obj.get("max_tokens"),
"trigger": MentalModelTriggerInput.from_dict(obj["trigger"]) if obj.get("trigger") is not None else None
})
return _obj
@@ -0,0 +1,141 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr, field_validator
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class KnowledgeNode(BaseModel):
"""
A node in the knowledge-base tree — a folder or a page. Pages carry ``description``/``tags`` from their backing mental model. The knowledge base is client-managed (CRUD); ``managed`` lets a client tag a node as system-owned vs. hand-authored.
""" # noqa: E501
id: StrictStr
kind: StrictStr
name: StrictStr
parent_id: Optional[StrictStr] = None
mental_model_id: Optional[StrictStr] = None
managed: Optional[StrictBool] = Field(default=False, description="Client-set flag: true = system-owned, false = hand-authored.")
description: Optional[StrictStr] = None
tags: Optional[List[StrictStr]] = None
timestamp: Optional[StrictStr] = None
children: Optional[List[KnowledgeNode]] = None
__properties: ClassVar[List[str]] = ["id", "kind", "name", "parent_id", "mental_model_id", "managed", "description", "tags", "timestamp", "children"]
@field_validator('kind')
def kind_validate_enum(cls, value):
"""Validates the enum"""
if value not in set(['folder', 'page']):
raise ValueError("must be one of enum values ('folder', 'page')")
return value
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of KnowledgeNode from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of each item in children (list)
_items = []
if self.children:
for _item_children in self.children:
if _item_children:
_items.append(_item_children.to_dict())
_dict['children'] = _items
# set to None if parent_id (nullable) is None
# and model_fields_set contains the field
if self.parent_id is None and "parent_id" in self.model_fields_set:
_dict['parent_id'] = None
# set to None if mental_model_id (nullable) is None
# and model_fields_set contains the field
if self.mental_model_id is None and "mental_model_id" in self.model_fields_set:
_dict['mental_model_id'] = None
# set to None if description (nullable) is None
# and model_fields_set contains the field
if self.description is None and "description" in self.model_fields_set:
_dict['description'] = None
# set to None if timestamp (nullable) is None
# and model_fields_set contains the field
if self.timestamp is None and "timestamp" in self.model_fields_set:
_dict['timestamp'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of KnowledgeNode from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"id": obj.get("id"),
"kind": obj.get("kind"),
"name": obj.get("name"),
"parent_id": obj.get("parent_id"),
"mental_model_id": obj.get("mental_model_id"),
"managed": obj.get("managed") if obj.get("managed") is not None else False,
"description": obj.get("description"),
"tags": obj.get("tags"),
"timestamp": obj.get("timestamp"),
"children": [KnowledgeNode.from_dict(_item) for _item in obj["children"]] if obj.get("children") is not None else None
})
return _obj
# TODO: Rewrite to not use raise_errors
KnowledgeNode.model_rebuild(raise_errors=False)
@@ -0,0 +1,89 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictStr
from typing import Any, ClassVar, Dict, List
from typing import Optional, Set
from typing_extensions import Self
class KnowledgePageBundleFile(BaseModel):
"""
One file in a portable OKF bundle.
""" # noqa: E501
path: StrictStr
content: StrictStr
__properties: ClassVar[List[str]] = ["path", "content"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of KnowledgePageBundleFile from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of KnowledgePageBundleFile from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"path": obj.get("path"),
"content": obj.get("content")
})
return _obj
@@ -0,0 +1,95 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict
from typing import Any, ClassVar, Dict, List
from hindsight_client_api.models.knowledge_page_bundle_file import KnowledgePageBundleFile
from typing import Optional, Set
from typing_extensions import Self
class KnowledgePageBundleResponse(BaseModel):
"""
A portable OKF bundle — a flat set of markdown files (index + pages + logs).
""" # noqa: E501
files: List[KnowledgePageBundleFile]
__properties: ClassVar[List[str]] = ["files"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of KnowledgePageBundleResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of each item in files (list)
_items = []
if self.files:
for _item_files in self.files:
if _item_files:
_items.append(_item_files.to_dict())
_dict['files'] = _items
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of KnowledgePageBundleResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"files": [KnowledgePageBundleFile.from_dict(_item) for _item in obj["files"]] if obj.get("files") is not None else None
})
return _obj
@@ -0,0 +1,93 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictInt
from typing import Any, ClassVar, Dict, List
from typing import Optional, Set
from typing_extensions import Self
class KnowledgePageGraphResponse(BaseModel):
"""
Constellation graph of knowledge pages linked by shared tags.
""" # noqa: E501
nodes: List[Dict[str, Any]]
edges: List[Dict[str, Any]]
total_pages: StrictInt
total_edges: StrictInt
__properties: ClassVar[List[str]] = ["nodes", "edges", "total_pages", "total_edges"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of KnowledgePageGraphResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of KnowledgePageGraphResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"nodes": obj.get("nodes"),
"edges": obj.get("edges"),
"total_pages": obj.get("total_pages"),
"total_edges": obj.get("total_edges")
})
return _obj
@@ -0,0 +1,116 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class KnowledgePageResponse(BaseModel):
"""
A knowledge page rendered as an OKF document.
""" # noqa: E501
id: StrictStr
name: StrictStr
type: StrictStr = Field(description="OKF document type — from a `type:<x>` tag, else 'knowledge-page'.")
description: Optional[StrictStr] = None
tags: Optional[List[StrictStr]] = None
timestamp: Optional[StrictStr] = None
body: Optional[StrictStr] = None
markdown: StrictStr = Field(description="The full OKF document: YAML frontmatter + markdown body.")
__properties: ClassVar[List[str]] = ["id", "name", "type", "description", "tags", "timestamp", "body", "markdown"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of KnowledgePageResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# set to None if description (nullable) is None
# and model_fields_set contains the field
if self.description is None and "description" in self.model_fields_set:
_dict['description'] = None
# set to None if timestamp (nullable) is None
# and model_fields_set contains the field
if self.timestamp is None and "timestamp" in self.model_fields_set:
_dict['timestamp'] = None
# set to None if body (nullable) is None
# and model_fields_set contains the field
if self.body is None and "body" in self.model_fields_set:
_dict['body'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of KnowledgePageResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"id": obj.get("id"),
"name": obj.get("name"),
"type": obj.get("type"),
"description": obj.get("description"),
"tags": obj.get("tags"),
"timestamp": obj.get("timestamp"),
"body": obj.get("body"),
"markdown": obj.get("markdown")
})
return _obj
@@ -0,0 +1,95 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict
from typing import Any, ClassVar, Dict, List
from hindsight_client_api.models.knowledge_node import KnowledgeNode
from typing import Optional, Set
from typing_extensions import Self
class KnowledgeTreeResponse(BaseModel):
"""
The knowledge base as a nested folder/page tree.
""" # noqa: E501
roots: List[KnowledgeNode]
__properties: ClassVar[List[str]] = ["roots"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of KnowledgeTreeResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of each item in roots (list)
_items = []
if self.roots:
for _item_roots in self.roots:
if _item_roots:
_items.append(_item_roots.to_dict())
_dict['roots'] = _items
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of KnowledgeTreeResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"roots": [KnowledgeNode.from_dict(_item) for _item in obj["roots"]] if obj.get("roots") is not None else None
})
return _obj
@@ -0,0 +1,99 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class UpdateNodeRequest(BaseModel):
"""
Rename and/or move a node. Each field applies only when present.
""" # noqa: E501
name: Optional[StrictStr] = None
parent_id: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["name", "parent_id"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of UpdateNodeRequest from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# set to None if name (nullable) is None
# and model_fields_set contains the field
if self.name is None and "name" in self.model_fields_set:
_dict['name'] = None
# set to None if parent_id (nullable) is None
# and model_fields_set contains the field
if self.parent_id is None and "parent_id" in self.model_fields_set:
_dict['parent_id'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of UpdateNodeRequest from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"name": obj.get("name"),
"parent_id": obj.get("parent_id")
})
return _obj
@@ -32,6 +32,12 @@ import type {
CreateDirectiveData,
CreateDirectiveErrors,
CreateDirectiveResponses,
CreateKnowledgeFolderData,
CreateKnowledgeFolderErrors,
CreateKnowledgeFolderResponses,
CreateKnowledgePageData,
CreateKnowledgePageErrors,
CreateKnowledgePageResponses,
CreateMentalModelData,
CreateMentalModelErrors,
CreateMentalModelResponses,
@@ -50,6 +56,9 @@ import type {
DeleteDocumentData,
DeleteDocumentErrors,
DeleteDocumentResponses,
DeleteKnowledgeNodeData,
DeleteKnowledgeNodeErrors,
DeleteKnowledgeNodeResponses,
DeleteMentalModelData,
DeleteMentalModelErrors,
DeleteMentalModelResponses,
@@ -65,6 +74,9 @@ import type {
ExportDocumentsData,
ExportDocumentsErrors,
ExportDocumentsResponses,
ExportKnowledgeBaseData,
ExportKnowledgeBaseErrors,
ExportKnowledgeBaseResponses,
FileRetainData,
FileRetainErrors,
FileRetainResponses,
@@ -97,6 +109,15 @@ import type {
GetGraphData,
GetGraphErrors,
GetGraphResponses,
GetKnowledgeBaseGraphData,
GetKnowledgeBaseGraphErrors,
GetKnowledgeBaseGraphResponses,
GetKnowledgeBaseTreeData,
GetKnowledgeBaseTreeErrors,
GetKnowledgeBaseTreeResponses,
GetKnowledgePageData,
GetKnowledgePageErrors,
GetKnowledgePageResponses,
GetMemoriesTimeseriesData,
GetMemoriesTimeseriesErrors,
GetMemoriesTimeseriesResponses,
@@ -220,6 +241,9 @@ import type {
UpdateDocumentData,
UpdateDocumentErrors,
UpdateDocumentResponses,
UpdateKnowledgeNodeData,
UpdateKnowledgeNodeErrors,
UpdateKnowledgeNodeResponses,
UpdateMemoryData,
UpdateMemoryErrors,
UpdateMemoryResponses,
@@ -654,6 +678,138 @@ export const clearMentalModel = <ThrowOnError extends boolean = false>(
...options,
});
/**
* Get the knowledge-base tree
*
* Return the knowledge base as a nested tree of folders and pages.
*/
export const getKnowledgeBaseTree = <ThrowOnError extends boolean = false>(
options: Options<GetKnowledgeBaseTreeData, ThrowOnError>
) =>
(options.client ?? client).get<
GetKnowledgeBaseTreeResponses,
GetKnowledgeBaseTreeErrors,
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/knowledge-base/tree", ...options });
/**
* Create a knowledge-base folder
*
* Create a folder, optionally nested under a parent folder.
*/
export const createKnowledgeFolder = <ThrowOnError extends boolean = false>(
options: Options<CreateKnowledgeFolderData, ThrowOnError>
) =>
(options.client ?? client).post<
CreateKnowledgeFolderResponses,
CreateKnowledgeFolderErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/knowledge-base/folders",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
* Create a knowledge-base page
*
* Create a page (a mental model + tree node). Content is generated asynchronously; use the returned operation_id to track completion.
*/
export const createKnowledgePage = <ThrowOnError extends boolean = false>(
options: Options<CreateKnowledgePageData, ThrowOnError>
) =>
(options.client ?? client).post<
CreateKnowledgePageResponses,
CreateKnowledgePageErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/knowledge-base/pages",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
* Knowledge-base constellation graph
*
* Return pages as nodes linked by shared tags, for the constellation view.
*/
export const getKnowledgeBaseGraph = <ThrowOnError extends boolean = false>(
options: Options<GetKnowledgeBaseGraphData, ThrowOnError>
) =>
(options.client ?? client).get<
GetKnowledgeBaseGraphResponses,
GetKnowledgeBaseGraphErrors,
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/knowledge-base/graph", ...options });
/**
* Export the knowledge base as an OKF bundle
*
* Return a portable OKF bundle: a nested index.md, one <id>.md per page, and history logs.
*/
export const exportKnowledgeBase = <ThrowOnError extends boolean = false>(
options: Options<ExportKnowledgeBaseData, ThrowOnError>
) =>
(options.client ?? client).get<
ExportKnowledgeBaseResponses,
ExportKnowledgeBaseErrors,
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/knowledge-base/export", ...options });
/**
* Get a knowledge-base page
*
* Return a single page as an OKF document (frontmatter + markdown body).
*/
export const getKnowledgePage = <ThrowOnError extends boolean = false>(
options: Options<GetKnowledgePageData, ThrowOnError>
) =>
(options.client ?? client).get<GetKnowledgePageResponses, GetKnowledgePageErrors, ThrowOnError>({
url: "/v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}",
...options,
});
/**
* Delete a knowledge-base node
*
* Delete a folder or page and its whole subtree (pages' mental models are removed too).
*/
export const deleteKnowledgeNode = <ThrowOnError extends boolean = false>(
options: Options<DeleteKnowledgeNodeData, ThrowOnError>
) =>
(options.client ?? client).delete<
DeleteKnowledgeNodeResponses,
DeleteKnowledgeNodeErrors,
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}", ...options });
/**
* Rename or move a knowledge-base node
*
* Rename a node (set `name`) and/or move it under another folder (set `parent_id`, null for the root).
*/
export const updateKnowledgeNode = <ThrowOnError extends boolean = false>(
options: Options<UpdateKnowledgeNodeData, ThrowOnError>
) =>
(options.client ?? client).patch<
UpdateKnowledgeNodeResponses,
UpdateKnowledgeNodeErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
* List directives
*
@@ -1153,6 +1153,42 @@ export type CreateDirectiveRequest = {
tags?: Array<string>;
};
/**
* CreateFolderRequest
*
* Create a folder under an optional parent folder.
*/
export type CreateFolderRequest = {
/**
* Name
*/
name: string;
/**
* Parent Id
*/
parent_id?: string | null;
};
/**
* CreateKnowledgePageResponse
*
* Result of creating a page: the node id, its mental model, and the refresh op.
*/
export type CreateKnowledgePageResponse = {
/**
* Page Id
*/
page_id: string;
/**
* Mental Model Id
*/
mental_model_id: string;
/**
* Operation Id
*/
operation_id?: string | null;
};
/**
* CreateMentalModelRequest
*
@@ -1215,6 +1251,35 @@ export type CreateMentalModelResponse = {
operation_id: string;
};
/**
* CreatePageRequest
*
* Create a page (a mental model + tree node) under an optional parent folder.
*/
export type CreatePageRequest = {
/**
* Name
*/
name: string;
/**
* Source Query
*/
source_query: string;
/**
* Parent Id
*/
parent_id?: string | null;
/**
* Tags
*/
tags?: Array<string> | null;
/**
* Max Tokens
*/
max_tokens?: number | null;
trigger?: MentalModelTriggerInput | null;
};
/**
* CreateWebhookRequest
*
@@ -1956,6 +2021,184 @@ export type IncludeOptions = {
source_facts?: SourceFactsIncludeOptions | null;
};
/**
* KnowledgeNode
*
* A node in the knowledge-base tree — a folder or a page.
*
* Pages carry ``description``/``tags`` from their backing mental model. The
* knowledge base is client-managed (CRUD); ``managed`` lets a client tag a node
* as system-owned vs. hand-authored.
*/
export type KnowledgeNode = {
/**
* Id
*/
id: string;
/**
* Kind
*/
kind: "folder" | "page";
/**
* Name
*/
name: string;
/**
* Parent Id
*/
parent_id?: string | null;
/**
* Mental Model Id
*
* Backing mental model id (pages only).
*/
mental_model_id?: string | null;
/**
* Managed
*
* Client-set flag: true = system-owned, false = hand-authored.
*/
managed?: boolean;
/**
* Description
*
* Page source query (OKF `description`).
*/
description?: string | null;
/**
* Tags
*/
tags?: Array<string>;
/**
* Timestamp
*
* Last refresh (page) or last update (folder).
*/
timestamp?: string | null;
/**
* Children
*/
children?: Array<KnowledgeNode>;
};
/**
* KnowledgePageBundleFile
*
* One file in a portable OKF bundle.
*/
export type KnowledgePageBundleFile = {
/**
* Path
*/
path: string;
/**
* Content
*/
content: string;
};
/**
* KnowledgePageBundleResponse
*
* A portable OKF bundle — a flat set of markdown files (index + pages + logs).
*/
export type KnowledgePageBundleResponse = {
/**
* Files
*/
files: Array<KnowledgePageBundleFile>;
};
/**
* KnowledgePageGraphResponse
*
* Constellation graph of knowledge pages linked by shared tags.
*/
export type KnowledgePageGraphResponse = {
/**
* Nodes
*/
nodes: Array<{
[key: string]: unknown;
}>;
/**
* Edges
*/
edges: Array<{
[key: string]: unknown;
}>;
/**
* Total Pages
*/
total_pages: number;
/**
* Total Edges
*/
total_edges: number;
};
/**
* KnowledgePageResponse
*
* A knowledge page rendered as an OKF document.
*/
export type KnowledgePageResponse = {
/**
* Id
*/
id: string;
/**
* Name
*/
name: string;
/**
* Type
*
* OKF document type — from a `type:<x>` tag, else 'knowledge-page'.
*/
type: string;
/**
* Description
*
* The source query that rebuilds the page.
*/
description?: string | null;
/**
* Tags
*/
tags?: Array<string>;
/**
* Timestamp
*
* Last refresh time (falls back to creation).
*/
timestamp?: string | null;
/**
* Body
*
* The page's synthesized markdown body.
*/
body?: string | null;
/**
* Markdown
*
* The full OKF document: YAML frontmatter + markdown body.
*/
markdown: string;
};
/**
* KnowledgeTreeResponse
*
* The knowledge base as a nested folder/page tree.
*/
export type KnowledgeTreeResponse = {
/**
* Roots
*/
roots: Array<KnowledgeNode>;
};
/**
* LLMRequestEntry
*
@@ -3938,6 +4181,22 @@ export type UpdateMentalModelRequest = {
trigger?: MentalModelTriggerInput | null;
};
/**
* UpdateNodeRequest
*
* Rename and/or move a node. Each field applies only when present.
*/
export type UpdateNodeRequest = {
/**
* Name
*/
name?: string | null;
/**
* Parent Id
*/
parent_id?: string | null;
};
/**
* UpdateWebhookRequest
*
@@ -5271,6 +5530,313 @@ export type ClearMentalModelResponses = {
export type ClearMentalModelResponse = ClearMentalModelResponses[keyof ClearMentalModelResponses];
export type GetKnowledgeBaseTreeData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/knowledge-base/tree";
};
export type GetKnowledgeBaseTreeErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetKnowledgeBaseTreeError =
GetKnowledgeBaseTreeErrors[keyof GetKnowledgeBaseTreeErrors];
export type GetKnowledgeBaseTreeResponses = {
/**
* Successful Response
*/
200: KnowledgeTreeResponse;
};
export type GetKnowledgeBaseTreeResponse =
GetKnowledgeBaseTreeResponses[keyof GetKnowledgeBaseTreeResponses];
export type CreateKnowledgeFolderData = {
body: CreateFolderRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/knowledge-base/folders";
};
export type CreateKnowledgeFolderErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type CreateKnowledgeFolderError =
CreateKnowledgeFolderErrors[keyof CreateKnowledgeFolderErrors];
export type CreateKnowledgeFolderResponses = {
/**
* Successful Response
*/
201: KnowledgeNode;
};
export type CreateKnowledgeFolderResponse =
CreateKnowledgeFolderResponses[keyof CreateKnowledgeFolderResponses];
export type CreateKnowledgePageData = {
body: CreatePageRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/knowledge-base/pages";
};
export type CreateKnowledgePageErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type CreateKnowledgePageError = CreateKnowledgePageErrors[keyof CreateKnowledgePageErrors];
export type CreateKnowledgePageResponses = {
/**
* Successful Response
*/
201: CreateKnowledgePageResponse;
};
export type CreateKnowledgePageResponse2 =
CreateKnowledgePageResponses[keyof CreateKnowledgePageResponses];
export type GetKnowledgeBaseGraphData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/knowledge-base/graph";
};
export type GetKnowledgeBaseGraphErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetKnowledgeBaseGraphError =
GetKnowledgeBaseGraphErrors[keyof GetKnowledgeBaseGraphErrors];
export type GetKnowledgeBaseGraphResponses = {
/**
* Successful Response
*/
200: KnowledgePageGraphResponse;
};
export type GetKnowledgeBaseGraphResponse =
GetKnowledgeBaseGraphResponses[keyof GetKnowledgeBaseGraphResponses];
export type ExportKnowledgeBaseData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/knowledge-base/export";
};
export type ExportKnowledgeBaseErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type ExportKnowledgeBaseError = ExportKnowledgeBaseErrors[keyof ExportKnowledgeBaseErrors];
export type ExportKnowledgeBaseResponses = {
/**
* Successful Response
*/
200: KnowledgePageBundleResponse;
};
export type ExportKnowledgeBaseResponse =
ExportKnowledgeBaseResponses[keyof ExportKnowledgeBaseResponses];
export type GetKnowledgePageData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
/**
* Page Id
*/
page_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}";
};
export type GetKnowledgePageErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetKnowledgePageError = GetKnowledgePageErrors[keyof GetKnowledgePageErrors];
export type GetKnowledgePageResponses = {
/**
* Successful Response
*/
200: KnowledgePageResponse;
};
export type GetKnowledgePageResponse = GetKnowledgePageResponses[keyof GetKnowledgePageResponses];
export type DeleteKnowledgeNodeData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
/**
* Node Id
*/
node_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}";
};
export type DeleteKnowledgeNodeErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type DeleteKnowledgeNodeError = DeleteKnowledgeNodeErrors[keyof DeleteKnowledgeNodeErrors];
export type DeleteKnowledgeNodeResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type UpdateKnowledgeNodeData = {
body: UpdateNodeRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
/**
* Node Id
*/
node_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}";
};
export type UpdateKnowledgeNodeErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UpdateKnowledgeNodeError = UpdateKnowledgeNodeErrors[keyof UpdateKnowledgeNodeErrors];
export type UpdateKnowledgeNodeResponses = {
/**
* Successful Response
*/
200: KnowledgeNode;
};
export type UpdateKnowledgeNodeResponse =
UpdateKnowledgeNodeResponses[keyof UpdateKnowledgeNodeResponses];
export type ListDirectivesData = {
body?: never;
headers?: {
@@ -9,6 +9,7 @@ import { Sidebar } from "@/components/sidebar";
import { DataView } from "@/components/data-view";
import { DocumentsView } from "@/components/documents-view";
import { EntitiesView } from "@/components/entities-view";
import { KnowledgeBaseView } from "@/components/knowledge-base-view";
import { ThinkView } from "@/components/think-view";
import { SearchDebugView } from "@/components/search-debug-view";
import { BankProfileView } from "@/components/bank-profile-view";
@@ -57,7 +58,7 @@ import {
import { LlmHealthDialog } from "@/components/llm-health-dialog";
import { ExtractDialog } from "@/components/extract-dialog";
type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "profile";
type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "knowledge" | "profile";
type DataSubTab = "world" | "experience" | "observations" | "mental-models";
type BankConfigTab =
| "general"
@@ -638,6 +639,13 @@ export default function BankPage() {
<EntitiesView />
</div>
)}
{/* Knowledge base Tab — KnowledgeBaseView renders its own header. */}
{view === "knowledge" && (
<div>
<KnowledgeBaseView />
</div>
)}
</div>
</main>
</div>
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client";
export async function GET(request: NextRequest) {
try {
const bankId = request.nextUrl.searchParams.get("bank_id");
if (!bankId) {
return NextResponse.json(
localizeApiErrorPayload(request, {
error: "bank_id is required",
errorKey: "api.errors.validation.bankIdRequired",
}),
{ status: 400 }
);
}
const response = await fetch(dataplaneBankUrl(bankId, "/knowledge-base/export"), {
headers: getDataplaneHeaders(),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
return NextResponse.json(error, { status: response.status });
}
return NextResponse.json(await response.json(), { status: 200 });
} catch (error) {
console.error("Failed to export knowledge base:", error);
return NextResponse.json(
localizeApiErrorPayload(request, {
error: "Failed to export knowledge base",
errorKey: "api.errors.knowledgeBase.export",
}),
{ status: 500 }
);
}
}
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client";
export async function POST(request: NextRequest) {
try {
const bankId = request.nextUrl.searchParams.get("bank_id");
if (!bankId) {
return NextResponse.json(
localizeApiErrorPayload(request, {
error: "bank_id is required",
errorKey: "api.errors.validation.bankIdRequired",
}),
{ status: 400 }
);
}
const body = await request.json();
const response = await fetch(dataplaneBankUrl(bankId, "/knowledge-base/folders"), {
method: "POST",
headers: getDataplaneHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
return NextResponse.json(error, { status: response.status });
}
return NextResponse.json(await response.json(), { status: response.status });
} catch (error) {
console.error("Failed to create folder:", error);
return NextResponse.json(
localizeApiErrorPayload(request, {
error: "Failed to create folder",
errorKey: "api.errors.knowledgeBase.createFolder",
}),
{ status: 500 }
);
}
}
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client";
export async function GET(request: NextRequest) {
try {
const bankId = request.nextUrl.searchParams.get("bank_id");
if (!bankId) {
return NextResponse.json(
localizeApiErrorPayload(request, {
error: "bank_id is required",
errorKey: "api.errors.validation.bankIdRequired",
}),
{ status: 400 }
);
}
const response = await fetch(dataplaneBankUrl(bankId, "/knowledge-base/graph"), {
headers: getDataplaneHeaders(),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
return NextResponse.json(error, { status: response.status });
}
return NextResponse.json(await response.json(), { status: 200 });
} catch (error) {
console.error("Failed to fetch knowledge base graph:", error);
return NextResponse.json(
localizeApiErrorPayload(request, {
error: "Failed to fetch knowledge base graph",
errorKey: "api.errors.knowledgeBase.graph",
}),
{ status: 500 }
);
}
}
@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from "next/server";
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client";
function bankId(request: NextRequest): string | null {
return request.nextUrl.searchParams.get("bank_id");
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ nodeId: string }> }
) {
try {
const { nodeId } = await params;
const bank = bankId(request);
if (!bank) {
return NextResponse.json(
localizeApiErrorPayload(request, {
error: "bank_id is required",
errorKey: "api.errors.validation.bankIdRequired",
}),
{ status: 400 }
);
}
const body = await request.json();
const response = await fetch(
dataplaneBankUrl(
bank,
`/knowledge-base/nodes/${encodeURIComponent(decodeURIComponent(nodeId))}`
),
{
method: "PATCH",
headers: getDataplaneHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(body),
}
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
return NextResponse.json(error, { status: response.status });
}
return NextResponse.json(await response.json(), { status: 200 });
} catch (error) {
console.error("Failed to update node:", error);
return NextResponse.json(
localizeApiErrorPayload(request, {
error: "Failed to update node",
errorKey: "api.errors.knowledgeBase.updateNode",
}),
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ nodeId: string }> }
) {
try {
const { nodeId } = await params;
const bank = bankId(request);
if (!bank) {
return NextResponse.json(
localizeApiErrorPayload(request, {
error: "bank_id is required",
errorKey: "api.errors.validation.bankIdRequired",
}),
{ status: 400 }
);
}
const response = await fetch(
dataplaneBankUrl(
bank,
`/knowledge-base/nodes/${encodeURIComponent(decodeURIComponent(nodeId))}`
),
{ method: "DELETE", headers: getDataplaneHeaders() }
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
return NextResponse.json(error, { status: response.status });
}
return NextResponse.json(await response.json(), { status: 200 });
} catch (error) {
console.error("Failed to delete node:", error);
return NextResponse.json(
localizeApiErrorPayload(request, {
error: "Failed to delete node",
errorKey: "api.errors.knowledgeBase.deleteNode",
}),
{ status: 500 }
);
}
}
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ pageId: string }> }
) {
try {
const { pageId } = await params;
const bankId = request.nextUrl.searchParams.get("bank_id");
if (!bankId) {
return NextResponse.json(
localizeApiErrorPayload(request, {
error: "bank_id is required",
errorKey: "api.errors.validation.bankIdRequired",
}),
{ status: 400 }
);
}
const response = await fetch(
dataplaneBankUrl(
bankId,
`/knowledge-base/pages/${encodeURIComponent(decodeURIComponent(pageId))}`
),
{ headers: getDataplaneHeaders() }
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
return NextResponse.json(error, { status: response.status });
}
return NextResponse.json(await response.json(), { status: 200 });
} catch (error) {
console.error("Failed to fetch knowledge page:", error);
return NextResponse.json(
localizeApiErrorPayload(request, {
error: "Failed to fetch knowledge page",
errorKey: "api.errors.knowledgeBase.fetchPage",
}),
{ status: 500 }
);
}
}
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client";
export async function POST(request: NextRequest) {
try {
const bankId = request.nextUrl.searchParams.get("bank_id");
if (!bankId) {
return NextResponse.json(
localizeApiErrorPayload(request, {
error: "bank_id is required",
errorKey: "api.errors.validation.bankIdRequired",
}),
{ status: 400 }
);
}
const body = await request.json();
const response = await fetch(dataplaneBankUrl(bankId, "/knowledge-base/pages"), {
method: "POST",
headers: getDataplaneHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
return NextResponse.json(error, { status: response.status });
}
return NextResponse.json(await response.json(), { status: response.status });
} catch (error) {
console.error("Failed to create page:", error);
return NextResponse.json(
localizeApiErrorPayload(request, {
error: "Failed to create page",
errorKey: "api.errors.knowledgeBase.createPage",
}),
{ status: 500 }
);
}
}
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client";
export async function GET(request: NextRequest) {
try {
const bankId = request.nextUrl.searchParams.get("bank_id");
if (!bankId) {
return NextResponse.json(
localizeApiErrorPayload(request, {
error: "bank_id is required",
errorKey: "api.errors.validation.bankIdRequired",
}),
{ status: 400 }
);
}
const response = await fetch(dataplaneBankUrl(bankId, "/knowledge-base/tree"), {
headers: getDataplaneHeaders(),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
return NextResponse.json(error, { status: response.status });
}
return NextResponse.json(await response.json(), { status: 200 });
} catch (error) {
console.error("Failed to fetch knowledge base tree:", error);
return NextResponse.json(
localizeApiErrorPayload(request, {
error: "Failed to fetch knowledge base tree",
errorKey: "api.errors.knowledgeBase.tree",
}),
{ status: 500 }
);
}
}
@@ -0,0 +1,684 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { useTranslations } from "next-intl";
import { client, type KnowledgeNode } from "@/lib/api";
import { useBank } from "@/lib/bank-context";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
ChevronDown,
ChevronRight,
Download,
FilePlus,
FileText,
Folder,
FolderOpen,
FolderPlus,
Loader2,
Network,
Trash2,
X,
} from "lucide-react";
import { formatAbsoluteDateTime, formatRelativeTime } from "@/lib/relative-time";
import { CompactMarkdown } from "./compact-markdown";
import { Constellation } from "./constellation";
import type { GraphData, GraphLink, GraphNode } from "./graph-2d";
type ViewMode = "tree" | "graph";
type GraphResponse = Awaited<ReturnType<typeof client.getKnowledgeBaseGraph>>;
type PageDetail = Awaited<ReturnType<typeof client.getKnowledgePage>>;
const FALLBACK_COLOR = "#0074d9";
function flatten(nodes: KnowledgeNode[], out: KnowledgeNode[] = []): KnowledgeNode[] {
for (const n of nodes) {
out.push(n);
if (n.children?.length) flatten(n.children, out);
}
return out;
}
export function KnowledgeBaseView() {
const t = useTranslations("knowledgeBase");
const { currentBank } = useBank();
const [roots, setRoots] = useState<KnowledgeNode[]>([]);
const [loading, setLoading] = useState(false);
const [view, setView] = useState<ViewMode>("tree");
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<PageDetail | null>(null);
const [loadingDetail, setLoadingDetail] = useState(false);
const [graph, setGraph] = useState<GraphResponse | null>(null);
const [graphLoading, setGraphLoading] = useState(false);
const [exporting, setExporting] = useState(false);
const [createKind, setCreateKind] = useState<"folder" | "page" | null>(null);
const [form, setForm] = useState({ name: "", sourceQuery: "", parentId: "" });
const [creating, setCreating] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<KnowledgeNode | null>(null);
const [deleting, setDeleting] = useState(false);
const loadTree = useCallback(async () => {
if (!currentBank) return;
setLoading(true);
try {
const result = await client.getKnowledgeTree(currentBank);
setRoots(result.roots || []);
} catch {
// toast handled by interceptor
} finally {
setLoading(false);
}
}, [currentBank]);
const loadGraph = useCallback(async () => {
if (!currentBank) return;
setGraphLoading(true);
try {
setGraph(await client.getKnowledgeBaseGraph(currentBank));
} catch {
// toast handled by interceptor
} finally {
setGraphLoading(false);
}
}, [currentBank]);
useEffect(() => {
if (currentBank) {
setSelected(null);
setGraph(null);
loadTree();
}
}, [currentBank, loadTree]);
useEffect(() => {
if (view === "graph" && currentBank && !graph && !graphLoading) loadGraph();
}, [view, currentBank, graph, graphLoading, loadGraph]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setSelected(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const allNodes = useMemo(() => flatten(roots), [roots]);
const folders = useMemo(() => allNodes.filter((n) => n.kind === "folder"), [allNodes]);
const folderCount = folders.length;
const pageCount = allNodes.length - folderCount;
const openPage = useCallback(
async (pageId: string) => {
if (!currentBank) return;
setLoadingDetail(true);
try {
setSelected(await client.getKnowledgePage(currentBank, pageId));
} catch {
// toast handled by interceptor
} finally {
setLoadingDetail(false);
}
},
[currentBank]
);
const toggleFolder = useCallback((id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const openCreate = (kind: "folder" | "page", parentId = "") => {
setForm({ name: "", sourceQuery: "", parentId });
setCreateKind(kind);
};
const handleCreate = async () => {
if (!currentBank || !createKind || !form.name.trim()) return;
if (createKind === "page" && !form.sourceQuery.trim()) return;
setCreating(true);
try {
const parent_id = form.parentId || null;
if (createKind === "folder") {
await client.createKnowledgeFolder(currentBank, {
name: form.name.trim(),
parent_id,
});
} else {
await client.createKnowledgePage(currentBank, {
name: form.name.trim(),
source_query: form.sourceQuery.trim(),
parent_id,
});
}
if (parent_id) setExpanded((prev) => new Set(prev).add(parent_id));
setCreateKind(null);
await loadTree();
setGraph(null);
} catch {
// toast handled by interceptor
} finally {
setCreating(false);
}
};
const handleDelete = async () => {
if (!currentBank || !deleteTarget) return;
setDeleting(true);
try {
await client.deleteKnowledgeNode(currentBank, deleteTarget.id);
if (selected?.id === deleteTarget.id) setSelected(null);
setDeleteTarget(null);
await loadTree();
setGraph(null);
} catch {
// toast handled by interceptor
} finally {
setDeleting(false);
}
};
const handleExport = async () => {
if (!currentBank) return;
setExporting(true);
try {
const bundle = await client.exportKnowledgeBase(currentBank);
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${currentBank}-okf.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch {
// toast handled by interceptor
} finally {
setExporting(false);
}
};
// ── Constellation (graph view) — clustered by parent folder ───────────────
const typeColors = useMemo(() => {
const colors = new Map<string, string>();
for (const n of graph?.nodes ?? []) colors.set(n.data.type, n.data.color);
return colors;
}, [graph]);
const constellationData = useMemo<GraphData>(() => {
if (!graph) return { nodes: [], links: [] };
const nodes: GraphNode[] = graph.nodes.map((n) => ({
id: n.data.id,
label: n.data.label,
color: n.data.color,
group: n.data.type,
}));
const links: GraphLink[] = graph.edges.map((e) => ({
source: e.data.source,
target: e.data.target,
color: e.data.color,
weight: e.data.weight,
}));
return { nodes, links };
}, [graph]);
const nodeWeights = useMemo(() => {
const weights = new Map<string, number>();
for (const link of constellationData.links) {
const w = typeof link.weight === "number" && link.weight > 0 ? link.weight : 1;
weights.set(link.source, (weights.get(link.source) || 0) + w);
weights.set(link.target, (weights.get(link.target) || 0) + w);
}
return weights;
}, [constellationData]);
const maxNodeWeight = useMemo(() => {
let max = 1;
for (const w of nodeWeights.values()) if (w > max) max = w;
return max;
}, [nodeWeights]);
const nodeSizeFn = useCallback(
(node: GraphNode) => 4 + Math.sqrt((nodeWeights.get(node.id) || 0) / maxNodeWeight) * 10,
[nodeWeights, maxNodeWeight]
);
return (
<div>
<div className="flex items-start justify-between gap-4 mb-2">
<div>
<h1 className="text-3xl font-bold text-foreground">{t("title")}</h1>
<p className="text-muted-foreground mt-1">{t("description")}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => openCreate("folder")}>
<FolderPlus className="w-4 h-4 mr-2" />
{t("newFolder")}
</Button>
<Button variant="outline" size="sm" onClick={() => openCreate("page")}>
<FilePlus className="w-4 h-4 mr-2" />
{t("newPage")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={exporting || !pageCount}
>
{exporting ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Download className="w-4 h-4 mr-2" />
)}
{t("exportButton")}
</Button>
</div>
</div>
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-muted-foreground">
{view === "graph"
? t("graphCount", { pages: graph?.total_pages ?? 0, links: graph?.total_edges ?? 0 })
: t("count", { folders: folderCount, pages: pageCount })}
</div>
<div className="flex items-center gap-2 bg-muted rounded-lg p-1">
<button
onClick={() => setView("tree")}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
view === "tree"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Folder className="w-4 h-4" />
{t("viewTree")}
</button>
<button
onClick={() => setView("graph")}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
view === "graph"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Network className="w-4 h-4" />
{t("viewGraph")}
</button>
</div>
</div>
{view === "tree" ? (
<div className="border border-border rounded-lg overflow-hidden min-h-[480px]">
{loading ? (
<div className="flex items-center justify-center py-24">
<Loader2 className="w-7 h-7 text-muted-foreground animate-spin" />
</div>
) : roots.length > 0 ? (
<ul className="py-2">
{roots.map((node) => (
<TreeRow
key={node.id}
node={node}
depth={0}
expanded={expanded}
selectedId={selected?.id ?? null}
onToggle={toggleFolder}
onOpenPage={openPage}
onAddChild={openCreate}
onDelete={setDeleteTarget}
t={t}
/>
))}
</ul>
) : (
<div className="flex items-center justify-center py-24">
<div className="text-center max-w-md px-6">
<FolderOpen className="w-8 h-8 mx-auto mb-3 text-muted-foreground opacity-60" />
<div className="text-sm text-muted-foreground">{t("empty")}</div>
<div className="text-xs text-muted-foreground mt-1">{t("emptyHint")}</div>
</div>
</div>
)}
</div>
) : (
<div className="border border-border rounded-lg overflow-hidden">
{graphLoading ? (
<div className="flex items-center justify-center py-24">
<Loader2 className="w-7 h-7 text-muted-foreground animate-spin" />
</div>
) : constellationData.nodes.length > 0 ? (
<Constellation
data={constellationData}
height={620}
onNodeClick={(node) => openPage(node.id)}
nodeSizeFn={nodeSizeFn}
clusterKeyFn={(node) => node.group ?? null}
clusterColorFn={(key) => typeColors.get(key) || FALLBACK_COLOR}
clusterLabelFn={(key) => key}
sizeLegendLabel={t("sizeLegendLabel")}
compactLabels
/>
) : (
<div className="flex items-center justify-center py-24">
<div className="text-sm text-muted-foreground">{t("empty")}</div>
</div>
)}
</div>
)}
{/* Page detail panel */}
{(selected || loadingDetail) && (
<div className="fixed right-0 top-0 h-screen w-[460px] bg-card border-l-2 border-primary shadow-2xl z-50 overflow-y-auto animate-in slide-in-from-right duration-300 ease-out">
<div className="p-5">
<div className="flex justify-between items-start mb-4 pb-4 border-b border-border">
<div className="min-w-0 flex-1">
<h3 className="text-xl font-bold text-card-foreground truncate">
{selected?.name ?? t("loadingPage")}
</h3>
{selected?.description && (
<p className="text-sm text-muted-foreground mt-1 italic">
&ldquo;{selected.description}&rdquo;
</p>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setSelected(null)}
className="h-8 w-8 p-0 flex-shrink-0"
aria-label={t("close")}
>
<X className="w-4 h-4" />
</Button>
</div>
{loadingDetail && !selected ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 text-muted-foreground animate-spin" />
</div>
) : selected ? (
<div className="space-y-4">
{selected.tags.length > 0 && (
<div className="flex items-center gap-2 flex-wrap text-xs">
{selected.tags.map((tag) => (
<span
key={tag}
className="px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400"
>
{tag}
</span>
))}
</div>
)}
{selected.timestamp && (
<div
className="text-xs text-muted-foreground"
title={formatAbsoluteDateTime(selected.timestamp)}
>
{t("updatedLabel")} {formatRelativeTime(selected.timestamp)}
</div>
)}
{selected.body ? (
<div className="prose prose-sm dark:prose-invert max-w-none border-t border-border pt-4">
<CompactMarkdown>{selected.body}</CompactMarkdown>
</div>
) : (
<p className="text-sm text-muted-foreground italic border-t border-border pt-4">
{t("noBody")}
</p>
)}
</div>
) : null}
</div>
</div>
)}
{/* Create folder/page dialog */}
<Dialog open={createKind !== null} onOpenChange={(o) => !o && setCreateKind(null)}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{createKind === "folder" ? t("createFolderTitle") : t("createPageTitle")}
</DialogTitle>
<DialogDescription>{t("description")}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">{t("fieldName")}</label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
autoFocus
/>
</div>
{createKind === "page" && (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{t("fieldSourceQuery")}
</label>
<Textarea
value={form.sourceQuery}
onChange={(e) => setForm({ ...form, sourceQuery: e.target.value })}
className="min-h-[100px]"
/>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">{t("fieldParent")}</label>
<Select
value={form.parentId || "__root__"}
onValueChange={(v) => setForm({ ...form, parentId: v === "__root__" ? "" : v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__root__">{t("rootFolder")}</SelectItem>
{folders.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateKind(null)} disabled={creating}>
{t("cancel")}
</Button>
<Button
onClick={handleCreate}
disabled={
creating || !form.name.trim() || (createKind === "page" && !form.sourceQuery.trim())
}
>
{creating ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t("create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete confirmation */}
<AlertDialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteButton")}</AlertDialogTitle>
<AlertDialogDescription>
{t("deleteConfirm", { name: deleteTarget?.name ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-row justify-end space-x-2">
<AlertDialogCancel className="mt-0">{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t("deleteButton")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
function TreeRow({
node,
depth,
expanded,
selectedId,
onToggle,
onOpenPage,
onAddChild,
onDelete,
t,
}: {
node: KnowledgeNode;
depth: number;
expanded: Set<string>;
selectedId: string | null;
onToggle: (id: string) => void;
onOpenPage: (id: string) => void;
onAddChild: (kind: "folder" | "page", parentId: string) => void;
onDelete: (node: KnowledgeNode) => void;
t: ReturnType<typeof useTranslations>;
}) {
const isFolder = node.kind === "folder";
const isOpen = expanded.has(node.id);
const isActive = selectedId === node.id;
return (
<li>
<div
className={`group flex items-center gap-1.5 pr-2 py-1.5 cursor-pointer border-l-2 transition-colors ${
isActive
? "bg-primary/10 border-primary text-foreground"
: "border-transparent hover:bg-muted text-foreground"
}`}
style={{ paddingLeft: `${depth * 18 + 10}px` }}
onClick={() => (isFolder ? onToggle(node.id) : onOpenPage(node.id))}
>
{isFolder ? (
<>
{isOpen ? (
<ChevronDown className="w-3.5 h-3.5 flex-shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="w-3.5 h-3.5 flex-shrink-0 text-muted-foreground" />
)}
{isOpen ? (
<FolderOpen className="w-4 h-4 flex-shrink-0 text-amber-500" />
) : (
<Folder className="w-4 h-4 flex-shrink-0 text-amber-500" />
)}
</>
) : (
<>
<span className="w-3.5 flex-shrink-0" />
<FileText className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
</>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm truncate">{node.name}</span>
{!isFolder && node.managed && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-violet-500/10 text-violet-600 dark:text-violet-400 flex-shrink-0">
{t("autoBadge")}
</span>
)}
</div>
</div>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{isFolder && (
<>
<button
className="p-1 rounded hover:bg-background text-muted-foreground hover:text-foreground"
title={t("newFolder")}
onClick={(e) => {
e.stopPropagation();
onAddChild("folder", node.id);
}}
>
<FolderPlus className="w-3.5 h-3.5" />
</button>
<button
className="p-1 rounded hover:bg-background text-muted-foreground hover:text-foreground"
title={t("newPage")}
onClick={(e) => {
e.stopPropagation();
onAddChild("page", node.id);
}}
>
<FilePlus className="w-3.5 h-3.5" />
</button>
</>
)}
<button
className="p-1 rounded hover:bg-background text-muted-foreground hover:text-red-600"
title={t("deleteButton")}
onClick={(e) => {
e.stopPropagation();
onDelete(node);
}}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{isFolder && isOpen && node.children?.length > 0 && (
<ul>
{node.children.map((child) => (
<TreeRow
key={child.id}
node={child}
depth={depth + 1}
expanded={expanded}
selectedId={selectedId}
onToggle={onToggle}
onOpenPage={onOpenPage}
onAddChild={onAddChild}
onDelete={onDelete}
t={t}
/>
))}
</ul>
)}
</li>
);
}
@@ -10,6 +10,7 @@ import {
Database,
FileText,
Users,
Network,
ChevronLeft,
ChevronRight,
Settings,
@@ -17,7 +18,7 @@ import {
import { cn } from "@/lib/utils";
import Link from "next/link";
type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "profile";
type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "knowledge" | "profile";
interface SidebarProps {
currentTab: NavItem;
@@ -35,6 +36,7 @@ export function Sidebar({ currentTab, onTabChange }: SidebarProps) {
}
const navItems = [
{ id: "knowledge" as NavItem, label: t("knowledge"), icon: Network },
{ id: "data" as NavItem, label: t("memories"), icon: Database },
{ id: "recall" as NavItem, label: t("recall"), icon: Search },
{ id: "reflect" as NavItem, label: t("reflect"), icon: Sparkles },
+122
View File
@@ -38,6 +38,19 @@ export interface WebhookHttpConfig {
params: Record<string, string>;
}
export interface KnowledgeNode {
id: string;
kind: "folder" | "page";
name: string;
parent_id: string | null;
mental_model_id: string | null;
managed: boolean;
description: string | null;
tags: string[];
timestamp: string | null;
children: KnowledgeNode[];
}
export interface Webhook {
id: string;
bank_id: string | null;
@@ -593,6 +606,115 @@ export class ControlPlaneClient {
}>(`/api/entities/graph?${queryParams}`);
}
/**
* Get the knowledge base as a nested folder/page tree.
*/
async getKnowledgeTree(bankId: string) {
return this.fetchApi<{ roots: KnowledgeNode[] }>(
`/api/knowledge-base/tree?bank_id=${encodeURIComponent(bankId)}`
);
}
/**
* Get the knowledge-base constellation graph (pages linked by shared tags).
*/
async getKnowledgeBaseGraph(bankId: string) {
return this.fetchApi<{
nodes: Array<{
data: { id: string; label: string; type: string; tagCount: number; color: string };
}>;
edges: Array<{
data: {
id: string;
source: string;
target: string;
sharedTags: string[];
weight: number;
color: string;
};
}>;
total_pages: number;
total_edges: number;
}>(`/api/knowledge-base/graph?bank_id=${encodeURIComponent(bankId)}`);
}
/**
* Get a single knowledge page rendered as an OKF document.
*/
async getKnowledgePage(bankId: string, pageId: string) {
return this.fetchApi<{
id: string;
name: string;
type: string;
description: string | null;
tags: string[];
timestamp: string | null;
body: string | null;
markdown: string;
}>(
`/api/knowledge-base/pages/${encodeURIComponent(pageId)}?bank_id=${encodeURIComponent(bankId)}`
);
}
/**
* Create a folder, optionally under a parent folder.
*/
async createKnowledgeFolder(
bankId: string,
body: { name: string; parent_id?: string | null }
) {
return this.fetchApi<KnowledgeNode>(
`/api/knowledge-base/folders?bank_id=${encodeURIComponent(bankId)}`,
{ method: "POST", body: JSON.stringify(body) }
);
}
/**
* Create a page (mental model + tree node). Content is generated asynchronously.
*/
async createKnowledgePage(
bankId: string,
body: { name: string; source_query: string; parent_id?: string | null; tags?: string[] }
) {
return this.fetchApi<{ page_id: string; mental_model_id: string; operation_id: string | null }>(
`/api/knowledge-base/pages?bank_id=${encodeURIComponent(bankId)}`,
{ method: "POST", body: JSON.stringify(body) }
);
}
/**
* Rename and/or move a node. Pass `parent_id: null` to move to the root.
*/
async updateKnowledgeNode(
bankId: string,
nodeId: string,
body: { name?: string; parent_id?: string | null }
) {
return this.fetchApi<KnowledgeNode>(
`/api/knowledge-base/nodes/${encodeURIComponent(nodeId)}?bank_id=${encodeURIComponent(bankId)}`,
{ method: "PATCH", body: JSON.stringify(body) }
);
}
/**
* Delete a node and its whole subtree.
*/
async deleteKnowledgeNode(bankId: string, nodeId: string) {
return this.fetchApi<{ status: string }>(
`/api/knowledge-base/nodes/${encodeURIComponent(nodeId)}?bank_id=${encodeURIComponent(bankId)}`,
{ method: "DELETE" }
);
}
/**
* Export the knowledge base as a portable OKF bundle (markdown files).
*/
async exportKnowledgeBase(bankId: string) {
return this.fetchApi<{ files: Array<{ path: string; content: string }> }>(
`/api/knowledge-base/export?bank_id=${encodeURIComponent(bankId)}`
);
}
/**
* Get entity details
*/
+50 -1
View File
@@ -147,7 +147,8 @@
"memoryBank": "Speicherbank",
"expandSidebar": "Seitenleiste ausklappen",
"collapseSidebar": "Seitenleiste einklappen",
"collapse": "Einklappen"
"collapse": "Einklappen",
"knowledge": "Knowledge base"
},
"overviewAndOperations": "Übersichtsstatistik und Hintergrundoperationen für diesen Speicherbank.",
"operations": "Operationen",
@@ -1712,7 +1713,55 @@
"update": "Webhook konnte nicht aktualisiert werden",
"delete": "Webhook konnte nicht gelöscht werden",
"deliveries": "Webhook-Zustellungen konnten nicht abgerufen werden"
},
"knowledgeBase": {
"tree": "Failed to fetch knowledge base tree",
"graph": "Failed to fetch knowledge base graph",
"export": "Failed to export knowledge base",
"createFolder": "Failed to create folder",
"createPage": "Failed to create page",
"fetchPage": "Failed to fetch knowledge page",
"updateNode": "Failed to update node",
"deleteNode": "Failed to delete node"
}
}
},
"knowledgeBase": {
"title": "Knowledge base",
"description": "Organize your knowledge into folders and pages, linked by shared tags.",
"loading": "Loading knowledge base…",
"loadingPage": "Loading page…",
"empty": "No folders or pages yet.",
"emptyHint": "Create a folder or a page to get started.",
"viewTree": "Tree",
"viewGraph": "Graph",
"newFolder": "New folder",
"newPage": "New page",
"exportButton": "Export OKF bundle",
"count": "{folders} folders · {pages} pages",
"graphCount": "{pages} pages · {links} shared-tag links",
"sizeLegendLabel": "shared-tag links",
"updatedLabel": "Updated",
"close": "Close",
"noBody": "This page has no content yet.",
"deleteConfirm": "Delete \"{name}\" and everything inside it?",
"deleteButton": "Delete",
"cancel": "Cancel",
"create": "Create",
"creating": "Creating…",
"fieldName": "Name",
"fieldSourceQuery": "Source query (the question that rebuilds this page)",
"fieldParent": "Parent folder",
"rootFolder": "Root",
"selectPagePrompt": "Select a page to read it.",
"createFolderTitle": "New folder",
"createPageTitle": "New page",
"fieldMission": "Mission (what this folder should collect)",
"missionPlaceholder": "e.g. Track all incidents for the payments service",
"editMissionTitle": "Edit folder mission",
"editMission": "Edit mission",
"saveMission": "Save mission",
"autoBadge": "auto",
"missionNone": "No mission set"
}
}
+50 -1
View File
@@ -148,7 +148,8 @@
"memoryBank": "Memory Bank",
"expandSidebar": "Expand sidebar",
"collapseSidebar": "Collapse sidebar",
"collapse": "Collapse"
"collapse": "Collapse",
"knowledge": "Knowledge base"
},
"overviewAndOperations": "Overview statistics and background operations for this memory bank.",
"operations": "Operations",
@@ -1712,7 +1713,55 @@
"update": "Failed to update webhook",
"delete": "Failed to delete webhook",
"deliveries": "Failed to fetch webhook deliveries"
},
"knowledgeBase": {
"tree": "Failed to fetch knowledge base tree",
"graph": "Failed to fetch knowledge base graph",
"export": "Failed to export knowledge base",
"createFolder": "Failed to create folder",
"createPage": "Failed to create page",
"fetchPage": "Failed to fetch knowledge page",
"updateNode": "Failed to update node",
"deleteNode": "Failed to delete node"
}
}
},
"knowledgeBase": {
"title": "Knowledge base",
"description": "Organize your knowledge into folders and pages, linked by shared tags.",
"loading": "Loading knowledge base…",
"loadingPage": "Loading page…",
"empty": "No folders or pages yet.",
"emptyHint": "Create a folder or a page to get started.",
"viewTree": "Tree",
"viewGraph": "Graph",
"newFolder": "New folder",
"newPage": "New page",
"exportButton": "Export OKF bundle",
"count": "{folders} folders · {pages} pages",
"graphCount": "{pages} pages · {links} shared-tag links",
"sizeLegendLabel": "shared-tag links",
"updatedLabel": "Updated",
"close": "Close",
"noBody": "This page has no content yet.",
"deleteConfirm": "Delete \"{name}\" and everything inside it?",
"deleteButton": "Delete",
"cancel": "Cancel",
"create": "Create",
"creating": "Creating…",
"fieldName": "Name",
"fieldSourceQuery": "Source query (the question that rebuilds this page)",
"fieldParent": "Parent folder",
"rootFolder": "Root",
"selectPagePrompt": "Select a page to read it.",
"createFolderTitle": "New folder",
"createPageTitle": "New page",
"fieldMission": "Mission (what this folder should collect)",
"missionPlaceholder": "e.g. Track all incidents for the payments service",
"editMissionTitle": "Edit folder mission",
"editMission": "Edit mission",
"saveMission": "Save mission",
"autoBadge": "auto",
"missionNone": "No mission set"
}
}
+50 -1
View File
@@ -147,7 +147,8 @@
"memoryBank": "Banco de memoria",
"expandSidebar": "Expandir barra lateral",
"collapseSidebar": "Contraer barra lateral",
"collapse": "Contraer"
"collapse": "Contraer",
"knowledge": "Knowledge base"
},
"overviewAndOperations": "Estadísticas generales y operaciones en segundo plano de este banco de memoria.",
"operations": "Operaciones",
@@ -1712,7 +1713,55 @@
"update": "No se pudo actualizar el webhook",
"delete": "No se pudo eliminar el webhook",
"deliveries": "No se pudieron obtener las entregas del webhook"
},
"knowledgeBase": {
"tree": "Failed to fetch knowledge base tree",
"graph": "Failed to fetch knowledge base graph",
"export": "Failed to export knowledge base",
"createFolder": "Failed to create folder",
"createPage": "Failed to create page",
"fetchPage": "Failed to fetch knowledge page",
"updateNode": "Failed to update node",
"deleteNode": "Failed to delete node"
}
}
},
"knowledgeBase": {
"title": "Knowledge base",
"description": "Organize your knowledge into folders and pages, linked by shared tags.",
"loading": "Loading knowledge base…",
"loadingPage": "Loading page…",
"empty": "No folders or pages yet.",
"emptyHint": "Create a folder or a page to get started.",
"viewTree": "Tree",
"viewGraph": "Graph",
"newFolder": "New folder",
"newPage": "New page",
"exportButton": "Export OKF bundle",
"count": "{folders} folders · {pages} pages",
"graphCount": "{pages} pages · {links} shared-tag links",
"sizeLegendLabel": "shared-tag links",
"updatedLabel": "Updated",
"close": "Close",
"noBody": "This page has no content yet.",
"deleteConfirm": "Delete \"{name}\" and everything inside it?",
"deleteButton": "Delete",
"cancel": "Cancel",
"create": "Create",
"creating": "Creating…",
"fieldName": "Name",
"fieldSourceQuery": "Source query (the question that rebuilds this page)",
"fieldParent": "Parent folder",
"rootFolder": "Root",
"selectPagePrompt": "Select a page to read it.",
"createFolderTitle": "New folder",
"createPageTitle": "New page",
"fieldMission": "Mission (what this folder should collect)",
"missionPlaceholder": "e.g. Track all incidents for the payments service",
"editMissionTitle": "Edit folder mission",
"editMission": "Edit mission",
"saveMission": "Save mission",
"autoBadge": "auto",
"missionNone": "No mission set"
}
}
+50 -1
View File
@@ -147,7 +147,8 @@
"memoryBank": "Banque de mémoire",
"expandSidebar": "Développer la barre latérale",
"collapseSidebar": "Réduire la barre latérale",
"collapse": "Réduire"
"collapse": "Réduire",
"knowledge": "Knowledge base"
},
"overviewAndOperations": "Statistiques générales et opérations en arrière-plan pour cette banque mémoire.",
"operations": "Opérations",
@@ -1712,7 +1713,55 @@
"update": "Impossible de mettre à jour le webhook",
"delete": "Impossible de supprimer le webhook",
"deliveries": "Impossible de récupérer les livraisons du webhook"
},
"knowledgeBase": {
"tree": "Failed to fetch knowledge base tree",
"graph": "Failed to fetch knowledge base graph",
"export": "Failed to export knowledge base",
"createFolder": "Failed to create folder",
"createPage": "Failed to create page",
"fetchPage": "Failed to fetch knowledge page",
"updateNode": "Failed to update node",
"deleteNode": "Failed to delete node"
}
}
},
"knowledgeBase": {
"title": "Knowledge base",
"description": "Organize your knowledge into folders and pages, linked by shared tags.",
"loading": "Loading knowledge base…",
"loadingPage": "Loading page…",
"empty": "No folders or pages yet.",
"emptyHint": "Create a folder or a page to get started.",
"viewTree": "Tree",
"viewGraph": "Graph",
"newFolder": "New folder",
"newPage": "New page",
"exportButton": "Export OKF bundle",
"count": "{folders} folders · {pages} pages",
"graphCount": "{pages} pages · {links} shared-tag links",
"sizeLegendLabel": "shared-tag links",
"updatedLabel": "Updated",
"close": "Close",
"noBody": "This page has no content yet.",
"deleteConfirm": "Delete \"{name}\" and everything inside it?",
"deleteButton": "Delete",
"cancel": "Cancel",
"create": "Create",
"creating": "Creating…",
"fieldName": "Name",
"fieldSourceQuery": "Source query (the question that rebuilds this page)",
"fieldParent": "Parent folder",
"rootFolder": "Root",
"selectPagePrompt": "Select a page to read it.",
"createFolderTitle": "New folder",
"createPageTitle": "New page",
"fieldMission": "Mission (what this folder should collect)",
"missionPlaceholder": "e.g. Track all incidents for the payments service",
"editMissionTitle": "Edit folder mission",
"editMission": "Edit mission",
"saveMission": "Save mission",
"autoBadge": "auto",
"missionNone": "No mission set"
}
}
+50 -1
View File
@@ -147,7 +147,8 @@
"memoryBank": "メモリバンク",
"expandSidebar": "サイドバーを展開",
"collapseSidebar": "サイドバーを折りたたむ",
"collapse": "折りたたむ"
"collapse": "折りたたむ",
"knowledge": "Knowledge base"
},
"overviewAndOperations": "このメモリバンクの概要統計とバックグラウンド操作。",
"operations": "操作",
@@ -1712,7 +1713,55 @@
"update": "Webhookの更新に失敗しました",
"delete": "Webhookの削除に失敗しました",
"deliveries": "Webhook配信の取得に失敗しました"
},
"knowledgeBase": {
"tree": "Failed to fetch knowledge base tree",
"graph": "Failed to fetch knowledge base graph",
"export": "Failed to export knowledge base",
"createFolder": "Failed to create folder",
"createPage": "Failed to create page",
"fetchPage": "Failed to fetch knowledge page",
"updateNode": "Failed to update node",
"deleteNode": "Failed to delete node"
}
}
},
"knowledgeBase": {
"title": "Knowledge base",
"description": "Organize your knowledge into folders and pages, linked by shared tags.",
"loading": "Loading knowledge base…",
"loadingPage": "Loading page…",
"empty": "No folders or pages yet.",
"emptyHint": "Create a folder or a page to get started.",
"viewTree": "Tree",
"viewGraph": "Graph",
"newFolder": "New folder",
"newPage": "New page",
"exportButton": "Export OKF bundle",
"count": "{folders} folders · {pages} pages",
"graphCount": "{pages} pages · {links} shared-tag links",
"sizeLegendLabel": "shared-tag links",
"updatedLabel": "Updated",
"close": "Close",
"noBody": "This page has no content yet.",
"deleteConfirm": "Delete \"{name}\" and everything inside it?",
"deleteButton": "Delete",
"cancel": "Cancel",
"create": "Create",
"creating": "Creating…",
"fieldName": "Name",
"fieldSourceQuery": "Source query (the question that rebuilds this page)",
"fieldParent": "Parent folder",
"rootFolder": "Root",
"selectPagePrompt": "Select a page to read it.",
"createFolderTitle": "New folder",
"createPageTitle": "New page",
"fieldMission": "Mission (what this folder should collect)",
"missionPlaceholder": "e.g. Track all incidents for the payments service",
"editMissionTitle": "Edit folder mission",
"editMission": "Edit mission",
"saveMission": "Save mission",
"autoBadge": "auto",
"missionNone": "No mission set"
}
}
+50 -1
View File
@@ -147,7 +147,8 @@
"memoryBank": "메모리 뱅크",
"expandSidebar": "사이드바 펼치기",
"collapseSidebar": "사이드바 접기",
"collapse": "접기"
"collapse": "접기",
"knowledge": "Knowledge base"
},
"overviewAndOperations": "이 메모리 뱅크의 개요 통계 및 백그라운드 작업.",
"operations": "작업",
@@ -1712,7 +1713,55 @@
"update": "Webhook을 업데이트하지 못했습니다",
"delete": "Webhook을 삭제하지 못했습니다",
"deliveries": "Webhook 전달 내역을 가져오지 못했습니다"
},
"knowledgeBase": {
"tree": "Failed to fetch knowledge base tree",
"graph": "Failed to fetch knowledge base graph",
"export": "Failed to export knowledge base",
"createFolder": "Failed to create folder",
"createPage": "Failed to create page",
"fetchPage": "Failed to fetch knowledge page",
"updateNode": "Failed to update node",
"deleteNode": "Failed to delete node"
}
}
},
"knowledgeBase": {
"title": "Knowledge base",
"description": "Organize your knowledge into folders and pages, linked by shared tags.",
"loading": "Loading knowledge base…",
"loadingPage": "Loading page…",
"empty": "No folders or pages yet.",
"emptyHint": "Create a folder or a page to get started.",
"viewTree": "Tree",
"viewGraph": "Graph",
"newFolder": "New folder",
"newPage": "New page",
"exportButton": "Export OKF bundle",
"count": "{folders} folders · {pages} pages",
"graphCount": "{pages} pages · {links} shared-tag links",
"sizeLegendLabel": "shared-tag links",
"updatedLabel": "Updated",
"close": "Close",
"noBody": "This page has no content yet.",
"deleteConfirm": "Delete \"{name}\" and everything inside it?",
"deleteButton": "Delete",
"cancel": "Cancel",
"create": "Create",
"creating": "Creating…",
"fieldName": "Name",
"fieldSourceQuery": "Source query (the question that rebuilds this page)",
"fieldParent": "Parent folder",
"rootFolder": "Root",
"selectPagePrompt": "Select a page to read it.",
"createFolderTitle": "New folder",
"createPageTitle": "New page",
"fieldMission": "Mission (what this folder should collect)",
"missionPlaceholder": "e.g. Track all incidents for the payments service",
"editMissionTitle": "Edit folder mission",
"editMission": "Edit mission",
"saveMission": "Save mission",
"autoBadge": "auto",
"missionNone": "No mission set"
}
}
+50 -1
View File
@@ -147,7 +147,8 @@
"memoryBank": "Banco de memória",
"expandSidebar": "Expandir barra lateral",
"collapseSidebar": "Recolher barra lateral",
"collapse": "Recolher"
"collapse": "Recolher",
"knowledge": "Knowledge base"
},
"overviewAndOperations": "Estatísticas gerais e operações em segundo plano para este banco de memória.",
"operations": "Operações",
@@ -1712,7 +1713,55 @@
"update": "Falha ao atualizar webhook",
"delete": "Falha ao excluir webhook",
"deliveries": "Falha ao buscar entregas do webhook"
},
"knowledgeBase": {
"tree": "Failed to fetch knowledge base tree",
"graph": "Failed to fetch knowledge base graph",
"export": "Failed to export knowledge base",
"createFolder": "Failed to create folder",
"createPage": "Failed to create page",
"fetchPage": "Failed to fetch knowledge page",
"updateNode": "Failed to update node",
"deleteNode": "Failed to delete node"
}
}
},
"knowledgeBase": {
"title": "Knowledge base",
"description": "Organize your knowledge into folders and pages, linked by shared tags.",
"loading": "Loading knowledge base…",
"loadingPage": "Loading page…",
"empty": "No folders or pages yet.",
"emptyHint": "Create a folder or a page to get started.",
"viewTree": "Tree",
"viewGraph": "Graph",
"newFolder": "New folder",
"newPage": "New page",
"exportButton": "Export OKF bundle",
"count": "{folders} folders · {pages} pages",
"graphCount": "{pages} pages · {links} shared-tag links",
"sizeLegendLabel": "shared-tag links",
"updatedLabel": "Updated",
"close": "Close",
"noBody": "This page has no content yet.",
"deleteConfirm": "Delete \"{name}\" and everything inside it?",
"deleteButton": "Delete",
"cancel": "Cancel",
"create": "Create",
"creating": "Creating…",
"fieldName": "Name",
"fieldSourceQuery": "Source query (the question that rebuilds this page)",
"fieldParent": "Parent folder",
"rootFolder": "Root",
"selectPagePrompt": "Select a page to read it.",
"createFolderTitle": "New folder",
"createPageTitle": "New page",
"fieldMission": "Mission (what this folder should collect)",
"missionPlaceholder": "e.g. Track all incidents for the payments service",
"editMissionTitle": "Edit folder mission",
"editMission": "Edit mission",
"saveMission": "Save mission",
"autoBadge": "auto",
"missionNone": "No mission set"
}
}
@@ -147,7 +147,8 @@
"memoryBank": "記憶庫",
"expandSidebar": "展開側邊欄",
"collapseSidebar": "收起側邊欄",
"collapse": "收起"
"collapse": "收起",
"knowledge": "Knowledge base"
},
"overviewAndOperations": "檢視此記憶庫的概覽統計和背景作業。",
"operations": "操作",
@@ -1712,7 +1713,55 @@
"update": "未能更新 Webhook",
"delete": "未能刪除 Webhook",
"deliveries": "未能載入 Webhook 投遞"
},
"knowledgeBase": {
"tree": "Failed to fetch knowledge base tree",
"graph": "Failed to fetch knowledge base graph",
"export": "Failed to export knowledge base",
"createFolder": "Failed to create folder",
"createPage": "Failed to create page",
"fetchPage": "Failed to fetch knowledge page",
"updateNode": "Failed to update node",
"deleteNode": "Failed to delete node"
}
}
},
"knowledgeBase": {
"title": "Knowledge base",
"description": "Organize your knowledge into folders and pages, linked by shared tags.",
"loading": "Loading knowledge base…",
"loadingPage": "Loading page…",
"empty": "No folders or pages yet.",
"emptyHint": "Create a folder or a page to get started.",
"viewTree": "Tree",
"viewGraph": "Graph",
"newFolder": "New folder",
"newPage": "New page",
"exportButton": "Export OKF bundle",
"count": "{folders} folders · {pages} pages",
"graphCount": "{pages} pages · {links} shared-tag links",
"sizeLegendLabel": "shared-tag links",
"updatedLabel": "Updated",
"close": "Close",
"noBody": "This page has no content yet.",
"deleteConfirm": "Delete \"{name}\" and everything inside it?",
"deleteButton": "Delete",
"cancel": "Cancel",
"create": "Create",
"creating": "Creating…",
"fieldName": "Name",
"fieldSourceQuery": "Source query (the question that rebuilds this page)",
"fieldParent": "Parent folder",
"rootFolder": "Root",
"selectPagePrompt": "Select a page to read it.",
"createFolderTitle": "New folder",
"createPageTitle": "New page",
"fieldMission": "Mission (what this folder should collect)",
"missionPlaceholder": "e.g. Track all incidents for the payments service",
"editMissionTitle": "Edit folder mission",
"editMission": "Edit mission",
"saveMission": "Save mission",
"autoBadge": "auto",
"missionNone": "No mission set"
}
}
@@ -147,7 +147,8 @@
"memoryBank": "记忆库",
"expandSidebar": "展开侧边栏",
"collapseSidebar": "收起侧边栏",
"collapse": "收起"
"collapse": "收起",
"knowledge": "Knowledge base"
},
"overviewAndOperations": "查看此记忆库的概览统计和后台操作。",
"operations": "操作",
@@ -1712,7 +1713,55 @@
"update": "更新 Webhook 失败",
"delete": "删除 Webhook 失败",
"deliveries": "获取 Webhook 投递失败"
},
"knowledgeBase": {
"tree": "Failed to fetch knowledge base tree",
"graph": "Failed to fetch knowledge base graph",
"export": "Failed to export knowledge base",
"createFolder": "Failed to create folder",
"createPage": "Failed to create page",
"fetchPage": "Failed to fetch knowledge page",
"updateNode": "Failed to update node",
"deleteNode": "Failed to delete node"
}
}
},
"knowledgeBase": {
"title": "Knowledge base",
"description": "Organize your knowledge into folders and pages, linked by shared tags.",
"loading": "Loading knowledge base…",
"loadingPage": "Loading page…",
"empty": "No folders or pages yet.",
"emptyHint": "Create a folder or a page to get started.",
"viewTree": "Tree",
"viewGraph": "Graph",
"newFolder": "New folder",
"newPage": "New page",
"exportButton": "Export OKF bundle",
"count": "{folders} folders · {pages} pages",
"graphCount": "{pages} pages · {links} shared-tag links",
"sizeLegendLabel": "shared-tag links",
"updatedLabel": "Updated",
"close": "Close",
"noBody": "This page has no content yet.",
"deleteConfirm": "Delete \"{name}\" and everything inside it?",
"deleteButton": "Delete",
"cancel": "Cancel",
"create": "Create",
"creating": "Creating…",
"fieldName": "Name",
"fieldSourceQuery": "Source query (the question that rebuilds this page)",
"fieldParent": "Parent folder",
"rootFolder": "Root",
"selectPagePrompt": "Select a page to read it.",
"createFolderTitle": "New folder",
"createPageTitle": "New page",
"fieldMission": "Mission (what this folder should collect)",
"missionPlaceholder": "e.g. Track all incidents for the payments service",
"editMissionTitle": "Edit folder mission",
"editMission": "Edit mission",
"saveMission": "Save mission",
"autoBadge": "auto",
"missionNone": "No mission set"
}
}
@@ -147,7 +147,8 @@
"memoryBank": "記憶庫",
"expandSidebar": "展開側邊欄",
"collapseSidebar": "收起側邊欄",
"collapse": "收起"
"collapse": "收起",
"knowledge": "Knowledge base"
},
"overviewAndOperations": "檢視此記憶庫的概覽統計和背景作業。",
"operations": "操作",
@@ -1712,7 +1713,55 @@
"update": "更新 Webhook 失敗",
"delete": "刪除 Webhook 失敗",
"deliveries": "取得 Webhook 投遞失敗"
},
"knowledgeBase": {
"tree": "Failed to fetch knowledge base tree",
"graph": "Failed to fetch knowledge base graph",
"export": "Failed to export knowledge base",
"createFolder": "Failed to create folder",
"createPage": "Failed to create page",
"fetchPage": "Failed to fetch knowledge page",
"updateNode": "Failed to update node",
"deleteNode": "Failed to delete node"
}
}
},
"knowledgeBase": {
"title": "Knowledge base",
"description": "Organize your knowledge into folders and pages, linked by shared tags.",
"loading": "Loading knowledge base…",
"loadingPage": "Loading page…",
"empty": "No folders or pages yet.",
"emptyHint": "Create a folder or a page to get started.",
"viewTree": "Tree",
"viewGraph": "Graph",
"newFolder": "New folder",
"newPage": "New page",
"exportButton": "Export OKF bundle",
"count": "{folders} folders · {pages} pages",
"graphCount": "{pages} pages · {links} shared-tag links",
"sizeLegendLabel": "shared-tag links",
"updatedLabel": "Updated",
"close": "Close",
"noBody": "This page has no content yet.",
"deleteConfirm": "Delete \"{name}\" and everything inside it?",
"deleteButton": "Delete",
"cancel": "Cancel",
"create": "Create",
"creating": "Creating…",
"fieldName": "Name",
"fieldSourceQuery": "Source query (the question that rebuilds this page)",
"fieldParent": "Parent folder",
"rootFolder": "Root",
"selectPagePrompt": "Select a page to read it.",
"createFolderTitle": "New folder",
"createPageTitle": "New page",
"fieldMission": "Mission (what this folder should collect)",
"missionPlaceholder": "e.g. Track all incidents for the payments service",
"editMissionTitle": "Edit folder mission",
"editMission": "Edit mission",
"saveMission": "Save mission",
"autoBadge": "auto",
"missionNone": "No mission set"
}
}
+936
View File
@@ -2010,6 +2010,531 @@
}
}
},
"/v1/default/banks/{bank_id}/knowledge-base/tree": {
"get": {
"tags": [
"Knowledge Base"
],
"summary": "Get the knowledge-base tree",
"description": "Return the knowledge base as a nested tree of folders and pages.",
"operationId": "get_knowledge_base_tree",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KnowledgeTreeResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/knowledge-base/folders": {
"post": {
"tags": [
"Knowledge Base"
],
"summary": "Create a knowledge-base folder",
"description": "Create a folder, optionally nested under a parent folder.",
"operationId": "create_knowledge_folder",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateFolderRequest"
}
}
}
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KnowledgeNode"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/knowledge-base/pages": {
"post": {
"tags": [
"Knowledge Base"
],
"summary": "Create a knowledge-base page",
"description": "Create a page (a mental model + tree node). Content is generated asynchronously; use the returned operation_id to track completion.",
"operationId": "create_knowledge_page",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePageRequest"
}
}
}
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateKnowledgePageResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/knowledge-base/graph": {
"get": {
"tags": [
"Knowledge Base"
],
"summary": "Knowledge-base constellation graph",
"description": "Return pages as nodes linked by shared tags, for the constellation view.",
"operationId": "get_knowledge_base_graph",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KnowledgePageGraphResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/knowledge-base/export": {
"get": {
"tags": [
"Knowledge Base"
],
"summary": "Export the knowledge base as an OKF bundle",
"description": "Return a portable OKF bundle: a nested index.md, one <id>.md per page, and history logs.",
"operationId": "export_knowledge_base",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KnowledgePageBundleResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}": {
"get": {
"tags": [
"Knowledge Base"
],
"summary": "Get a knowledge-base page",
"description": "Return a single page as an OKF document (frontmatter + markdown body).",
"operationId": "get_knowledge_page",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "page_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Page Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KnowledgePageResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}": {
"patch": {
"tags": [
"Knowledge Base"
],
"summary": "Rename or move a knowledge-base node",
"description": "Rename a node (set `name`) and/or move it under another folder (set `parent_id`, null for the root).",
"operationId": "update_knowledge_node",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "node_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Node Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateNodeRequest"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KnowledgeNode"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"delete": {
"tags": [
"Knowledge Base"
],
"summary": "Delete a knowledge-base node",
"description": "Delete a folder or page and its whole subtree (pages' mental models are removed too).",
"operationId": "delete_knowledge_node",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "node_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Node Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/directives": {
"get": {
"tags": [
@@ -7744,6 +8269,61 @@
"title": "CreateDirectiveRequest",
"description": "Request model for creating a directive."
},
"CreateFolderRequest": {
"properties": {
"name": {
"type": "string",
"title": "Name"
},
"parent_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Parent Id"
}
},
"type": "object",
"required": [
"name"
],
"title": "CreateFolderRequest",
"description": "Create a folder under an optional parent folder."
},
"CreateKnowledgePageResponse": {
"properties": {
"page_id": {
"type": "string",
"title": "Page Id"
},
"mental_model_id": {
"type": "string",
"title": "Mental Model Id"
},
"operation_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Operation Id"
}
},
"type": "object",
"required": [
"page_id",
"mental_model_id"
],
"title": "CreateKnowledgePageResponse",
"description": "Result of creating a page: the node id, its mental model, and the refresh op."
},
"CreateMentalModelRequest": {
"properties": {
"id": {
@@ -7838,6 +8418,71 @@
"title": "CreateMentalModelResponse",
"description": "Response model for mental model creation."
},
"CreatePageRequest": {
"properties": {
"name": {
"type": "string",
"title": "Name"
},
"source_query": {
"type": "string",
"title": "Source Query"
},
"parent_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Parent Id"
},
"tags": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Tags"
},
"max_tokens": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Max Tokens"
},
"trigger": {
"anyOf": [
{
"$ref": "#/components/schemas/MentalModelTrigger-Input"
},
{
"type": "null"
}
]
}
},
"type": "object",
"required": [
"name",
"source_query"
],
"title": "CreatePageRequest",
"description": "Create a page (a mental model + tree node) under an optional parent folder."
},
"CreateWebhookRequest": {
"properties": {
"url": {
@@ -9064,6 +9709,268 @@
"title": "IncludeOptions",
"description": "Options for including additional data in recall results."
},
"KnowledgeNode": {
"properties": {
"id": {
"type": "string",
"title": "Id"
},
"kind": {
"type": "string",
"enum": [
"folder",
"page"
],
"title": "Kind"
},
"name": {
"type": "string",
"title": "Name"
},
"parent_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Parent Id"
},
"mental_model_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Mental Model Id",
"description": "Backing mental model id (pages only)."
},
"managed": {
"type": "boolean",
"title": "Managed",
"description": "Client-set flag: true = system-owned, false = hand-authored.",
"default": false
},
"description": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Description",
"description": "Page source query (OKF `description`)."
},
"tags": {
"items": {
"type": "string"
},
"type": "array",
"title": "Tags",
"default": []
},
"timestamp": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Timestamp",
"description": "Last refresh (page) or last update (folder)."
},
"children": {
"items": {
"$ref": "#/components/schemas/KnowledgeNode"
},
"type": "array",
"title": "Children",
"default": []
}
},
"type": "object",
"required": [
"id",
"kind",
"name"
],
"title": "KnowledgeNode",
"description": "A node in the knowledge-base tree \u2014 a folder or a page.\n\nPages carry ``description``/``tags`` from their backing mental model. The\nknowledge base is client-managed (CRUD); ``managed`` lets a client tag a node\nas system-owned vs. hand-authored."
},
"KnowledgePageBundleFile": {
"properties": {
"path": {
"type": "string",
"title": "Path"
},
"content": {
"type": "string",
"title": "Content"
}
},
"type": "object",
"required": [
"path",
"content"
],
"title": "KnowledgePageBundleFile",
"description": "One file in a portable OKF bundle."
},
"KnowledgePageBundleResponse": {
"properties": {
"files": {
"items": {
"$ref": "#/components/schemas/KnowledgePageBundleFile"
},
"type": "array",
"title": "Files"
}
},
"type": "object",
"required": [
"files"
],
"title": "KnowledgePageBundleResponse",
"description": "A portable OKF bundle \u2014 a flat set of markdown files (index + pages + logs)."
},
"KnowledgePageGraphResponse": {
"properties": {
"nodes": {
"items": {
"additionalProperties": true,
"type": "object"
},
"type": "array",
"title": "Nodes"
},
"edges": {
"items": {
"additionalProperties": true,
"type": "object"
},
"type": "array",
"title": "Edges"
},
"total_pages": {
"type": "integer",
"title": "Total Pages"
},
"total_edges": {
"type": "integer",
"title": "Total Edges"
}
},
"type": "object",
"required": [
"nodes",
"edges",
"total_pages",
"total_edges"
],
"title": "KnowledgePageGraphResponse",
"description": "Constellation graph of knowledge pages linked by shared tags."
},
"KnowledgePageResponse": {
"properties": {
"id": {
"type": "string",
"title": "Id"
},
"name": {
"type": "string",
"title": "Name"
},
"type": {
"type": "string",
"title": "Type",
"description": "OKF document type \u2014 from a `type:<x>` tag, else 'knowledge-page'."
},
"description": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Description",
"description": "The source query that rebuilds the page."
},
"tags": {
"items": {
"type": "string"
},
"type": "array",
"title": "Tags",
"default": []
},
"timestamp": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Timestamp",
"description": "Last refresh time (falls back to creation)."
},
"body": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Body",
"description": "The page's synthesized markdown body."
},
"markdown": {
"type": "string",
"title": "Markdown",
"description": "The full OKF document: YAML frontmatter + markdown body."
}
},
"type": "object",
"required": [
"id",
"name",
"type",
"markdown"
],
"title": "KnowledgePageResponse",
"description": "A knowledge page rendered as an OKF document."
},
"KnowledgeTreeResponse": {
"properties": {
"roots": {
"items": {
"$ref": "#/components/schemas/KnowledgeNode"
},
"type": "array",
"title": "Roots"
}
},
"type": "object",
"required": [
"roots"
],
"title": "KnowledgeTreeResponse",
"description": "The knowledge base as a nested folder/page tree."
},
"LLMRequestEntry": {
"properties": {
"id": {
@@ -12797,6 +13704,35 @@
}
}
},
"UpdateNodeRequest": {
"properties": {
"name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Name"
},
"parent_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Parent Id"
}
},
"type": "object",
"title": "UpdateNodeRequest",
"description": "Rename and/or move a node. Each field applies only when present."
},
"UpdateWebhookRequest": {
"properties": {
"url": {
@@ -0,0 +1,2 @@
node_modules/
dist/
@@ -0,0 +1,52 @@
# hindsight-coding-opencode
Reflect-only [Hindsight](https://vectorize.io/hindsight) long-term memory for coding agents in
[OpenCode](https://opencode.ai), plus a one-shot **backfill** that ingests a repo's git history and
past developer conversations into a Hindsight bank.
## What it does
- **Runtime plugin** — exposes a single tool, `hindsight_reflect(question)`. The agent asks memory a
question phrased from the bug's symptom; Hindsight `reflect` returns a synthesized, root-cause
answer drawn from the ingested history. No recall, no auto-injection.
- **Backfill command** — `hindsight-coding-backfill`:
1. creates/updates the bank with the reflect mission, **observations disabled**, and two named
**retain strategies**`git` and `chat`;
2. ingests **every** git commit (full message + full diff, no pre-filtering) under `git`;
3. ingests each developer conversation **raw** (never pre-summarized) under `chat`;
4. tags every item with a `REF-ID` so a reflected fact traces back to its commit/session.
Git and chat use **different Hindsight retain strategies** (per-item `strategy`), so each content
type is extracted with settings suited to it — in one bank, one pass.
## Backfill
```bash
hindsight-coding-backfill \
--repo /path/to/repo \
--conversations sessions.json \
--bank myproject \
--api-url http://localhost:8888 \
[--limit 10] [--reset] [--concurrency 8]
```
`sessions.json`: `[{ "id": "s1", "turns": [{"role":"user","text":"..."}, {"role":"assistant","text":"..."}] }, ...]`
Tip: run with `--limit 10` first to validate the setup before a full-history ingest.
## Plugin
Add to `opencode.json` and configure via env:
```json
{ "plugin": ["/path/to/hindsight-coding-opencode"] }
```
```bash
HINDSIGHT_API_URL=http://localhost:8888 # default
HINDSIGHT_BANK_ID=myproject
# HINDSIGHT_API_TOKEN=... (optional)
# HINDSIGHT_DISABLED=1 (hard off-switch — inert plugin, for a no-memory baseline)
```
Local Hindsight: `docker run -d -p 8888:8888 -p 9999:9999 -e HINDSIGHT_API_LLM_PROVIDER=gemini -e HINDSIGHT_API_LLM_API_KEY=$GEMINI_API_KEY -e HINDSIGHT_API_LLM_MODEL=gemini-2.5-flash ghcr.io/vectorize-io/hindsight:latest`
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
{
"name": "@vectorize-io/opencode-coding",
"version": "0.1.0",
"description": "Reflect-only Hindsight long-term memory for coding agents in OpenCode, with a git+chat backfill command.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"hindsight-coding-backfill": "dist/backfill.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@opencode-ai/plugin": "^1.3.13"
},
"devDependencies": {
"@types/node": "^26.1.0",
"tsup": "^8.3.0",
"typescript": "^5.6.0"
}
}
@@ -0,0 +1,330 @@
#!/usr/bin/env node
/**
* hindsight-coding-backfill — one-shot setup + ingest of a repo's history into a Hindsight bank,
* for the reflect-only coding plugin.
*
* It (1) creates/updates the bank with the reflect mission, observations DISABLED, and two named
* retain strategies — `git` and `chat` — then (2) ingests EVERY git commit (full message + full
* diff, no pre-filtering) under the `git` strategy, and (3) ingests each developer conversation
* RAW (never pre-summarized) under the `chat` strategy. Each item carries a REF-ID tracer so a
* reflected fact can be traced back to its commit/session.
*
* Usage:
* hindsight-coding-backfill --repo <path> [--conversations <sessions.json>] --bank <id> \
* [--api-url http://localhost:8888] [--api-token X] [--limit N] [--reset] [--concurrency 8]
*
* conversations.json: [{ "id": "s1", "turns": [{"role":"user","text":"..."}, ...] }, ...]
*/
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
// ── retain strategies (the two "different strategies" for git vs conversations) ─────────────
const GIT_MISSION =
"You are ingesting a single git commit: its message and its full diff. Extract the concrete " +
"technical DECISION and the CAUSE/INVARIANT it encodes, bound to the specific code entities " +
"(functions, methods, files) and behaviors it changes. Preserve exact identifiers, paths, and " +
"literal values verbatim. Preserve the 'REF-ID: <token>' marker verbatim in every fact. Capture " +
"both WHAT changed and WHY.";
const CHAT_MISSION =
"You are ingesting a raw developer conversation (a JSON user/assistant transcript). Extract the "
+ "FEWEST facts that capture the OUTCOME — do NOT emit one fact per message, per intermediate "
+ "proposal, or per tool step; that fragments the decision and reads as contradictory out of order. "
+ "Prefer: (1) ONE consolidated fact stating the FINAL, settled decision and its exact rule/values "
+ "unambiguously; and (2) at most one fact for the key alternative that was REJECTED and why. "
+ "CRITICAL: a conversation usually REVISES its answer — an early proposal gets changed. Record ONLY "
+ "the FINAL state as the decision / what is in effect. A superseded proposal must appear ONLY inside "
+ "the rejected fact ('initially proposed X, changed to Y because…'), NEVER as its own 'decided' "
+ "fact. If the same setting changes several times, keep only the LAST. Make unmistakably clear which "
+ "choice WON. Quote literal values/identifiers verbatim. Preserve the 'REF-ID: <token>' marker in "
+ "each fact. Do not invent; capture only what was actually settled.";
const REFLECT_MISSION =
"You are a debugging assistant with the project's past decisions in memory (git rationale and " +
"developer chats). Given a bug's SYMPTOM, find the past decision whose rationale explains the ROOT " +
"CAUSE — not one that merely shares vocabulary. Answer with the PRECISE fix: state the EXACT rule " +
"and the LITERAL values, identifiers, strings, numbers, or set members that were decided — quote " +
"them VERBATIM, never paraphrase, generalize, or omit them (give the actual mapping/value/threshold, " +
"e.g. the specific words a symbol maps to or the exact number, not 'the project standard'). Name " +
"the function/file to change and cite the REF-ID(s).";
// CUSTOM extraction prompt for chats — replaces the default extractor's rules entirely, so we get a
// TINY number of coherent facts (final decision + optional rejection), not a fact per message.
const CHAT_CUSTOM_INSTRUCTIONS =
"You are reading ONE developer conversation (JSON user/assistant turns) about a coding decision. It "
+ "typically PROPOSES options and then REVISES them — only the LAST state is real.\n\n"
+ "Extract AT MOST 2 facts:\n"
+ "1. THE DECISION — a single fact stating the FINAL, in-effect rule and its EXACT values/identifiers, "
+ "unambiguously (e.g. \"round_cents uses ROUND_HALF_DOWN so half-cents round toward zero, matching the "
+ "legacy ledger\"). Quote literals verbatim.\n"
+ "2. THE REJECTION (only if a notable alternative was tried) — one fact of the form \"initially "
+ "proposed X, but changed to Y because Z\".\n\n"
+ "HARD RULES:\n"
+ "- NEVER emit a separate fact per message, per intermediate proposal, or per tool step.\n"
+ "- A superseded proposal appears ONLY inside fact #2 — NEVER as its own 'decided' fact.\n"
+ "- If a setting changed several times, keep ONLY the last as the decision.\n"
+ "- Emit just 1 fact when there is no meaningful rejected alternative.\n"
+ "- Preserve the 'REF-ID: <token>' marker from the transcript in each fact. Do not invent.";
const RETAIN_STRATEGIES = {
git: { retain_mission: GIT_MISSION, retain_extraction_mode: "verbose" },
// chunk big enough to hold a WHOLE typical chat in ONE chunk (these run ~2.5k tokens / ~10k chars;
// the 3000 default was SPLITTING them -> per-chunk fragments). ~12k stays well under a 16k-context
// model, so the custom "≤2 facts" prompt sees the full proposal→revision arc and emits the final
// decision. (Very long chats would still split and fall back to the consolidation layer.)
chat: { retain_extraction_mode: "custom", retain_custom_instructions: CHAT_CUSTOM_INSTRUCTIONS,
retain_chunk_size: 12000 },
};
// Knowledge PAGES (OKF pages = mental models) = a developer's durable mental model of the codebase,
// CONSOLIDATED from the ingested MEMORY (commit history + past conversations) — NOT mirrored from the
// current source (which would need constant re-sync). A universal 4-page taxonomy that generalizes to
// any repo; the curator populates each from history+chats and can spawn per-component sub-pages.
const PAGES = [
{ name: "Component map",
source_query: "From this project's commit history and past discussions, what are the main "
+ "components/modules/subsystems, what is each responsible for, and how do they relate to or "
+ "depend on one another? Describe the structure and responsibilities." },
{ name: "Core concepts",
source_query: "What are the core concepts, domain abstractions, and key entities in this project — "
+ "the vocabulary a developer must understand? For each, explain what it represents and its role, "
+ "drawn from how they are introduced and discussed across the history and conversations." },
{ name: "Conventions and patterns",
source_query: "What conventions, idioms, and recurring patterns does this project follow — its "
+ "approach to testing, error handling, naming, structure, and how changes are typically made? "
+ "Describe how THIS project does things, as evidenced across its history and discussions." },
{ name: "Key decisions and rationale",
source_query: "What are the significant technical decisions made in this project and the rationale "
+ "behind them — the durable 'why we do it this way' a developer should know? Summarize the "
+ "decisions and their reasoning from the commit rationales and past conversations." },
];
// ── args ────────────────────────────────────────────────────────────────────
function arg(name: string, def?: string): string | undefined {
const i = process.argv.indexOf(`--${name}`);
if (i >= 0 && i + 1 < process.argv.length) return process.argv[i + 1];
return process.argv.includes(`--${name}`) ? "true" : def;
}
const REPO = arg("repo");
const CONV = arg("conversations");
const BANK = arg("bank");
const API_URL = (arg("api-url", "http://localhost:8888") as string).replace(/\/$/, "");
const API_TOKEN = arg("api-token");
const LIMIT = arg("limit") ? Number(arg("limit")) : undefined;
const RESET = process.argv.includes("--reset");
const NO_PAGES = process.argv.includes("--no-pages");
const CONCURRENCY = Number(arg("concurrency", "8"));
if (!REPO || !BANK) {
console.error("usage: hindsight-coding-backfill --repo <path> --bank <id> [--conversations f.json] [--api-url U] [--limit N] [--reset] [--no-pages]");
process.exit(1);
}
// ── Hindsight HTTP helpers (raw fetch — no client dep) ────────────────────────
const H = (): Record<string, string> => {
const h: Record<string, string> = { "Content-Type": "application/json" };
if (API_TOKEN) h["Authorization"] = `Bearer ${API_TOKEN}`;
return h;
};
const bankUrl = (suffix = "") => `${API_URL}/v1/default/banks/${encodeURIComponent(BANK!)}${suffix}`;
async function req(method: string, url: string, body?: unknown): Promise<Response> {
const r = await fetch(url, { method, headers: H(), body: body ? JSON.stringify(body) : undefined });
if (!r.ok && r.status !== 404) throw new Error(`${method} ${url} -> ${r.status} ${await r.text()}`);
return r;
}
async function configureBank() {
if (RESET) {
await req("DELETE", bankUrl());
console.log(`[bank] reset ${BANK}`);
}
// base config: reflect mission + observations ON (consolidated patterns enrich pages) + git default
await req("PUT", bankUrl(), {
name: BANK,
reflect_mission: REFLECT_MISSION,
enable_observations: true,
observations_mission: "Consolidate durable knowledge about THIS codebase — recurring patterns, "
+ "conventions, module responsibilities, and how components relate — from the ingested commits "
+ "and conversations. Favor stable structural understanding over one-off details.",
retain_mission: GIT_MISSION,
retain_extraction_mode: "verbose",
});
// named strategies (git / chat) — per-item strategy overrides the default
await req("PATCH", bankUrl("/config"), {
updates: { retain_strategies: RETAIN_STRATEGIES, retain_default_strategy: "git" },
});
console.log(`[bank] configured ${BANK}: reflect mission set, observations OFF, strategies {git, chat}`);
}
const opIds: string[] = [];
async function retain(content: string, context: string, documentId: string, tags: string[],
strategy: string, opts: { timestamp?: string; metadata?: Record<string, string> } = {}) {
// ASYNC: enqueue extraction server-side and return immediately. Sync retain blocks on the
// extraction LLM and times out under parallel load / large diffs; async decouples them.
const item: Record<string, unknown> = { content, context, document_id: documentId, tags, strategy };
if (opts.timestamp) item.timestamp = opts.timestamp; // when the content occurred (temporal ranking)
if (opts.metadata) item.metadata = opts.metadata; // source provenance (returned with recalls)
const r = await req("POST", bankUrl("/memories"), { items: [item], async: true });
try {
const j = (await r.json()) as { operation_id?: string };
if (j.operation_id) opIds.push(j.operation_id);
} catch {
/* ignore */
}
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
let failures = 0;
async function pool<T>(items: T[], n: number, fn: (x: T, i: number) => Promise<void>) {
let i = 0, done = 0;
async function worker() {
while (i < items.length) {
const idx = i++;
try {
await fn(items[idx], idx);
} catch (e) {
failures++;
console.warn(` ! item ${idx} failed to enqueue: ${(e as Error).message?.slice(0, 120)}`);
}
if (++done % 25 === 0) console.log(` ${done}/${items.length}`);
}
}
await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
}
async function drain(ids: string[], label: string, maxMs = 60 * 60 * 1000) {
// Poll each enqueued operation by id until terminal. The /operations LIST endpoint only shows
// active ops (completed ones drop off), so per-id GET is the reliable "done" signal.
if (!ids.length) return;
console.log(`[wait] draining ${ids.length} ${label} operations …`);
const start = Date.now();
const TERMINAL = new Set(["completed", "failed", "cancelled", "error"]);
const pending = new Set(ids);
let failed = 0;
while (pending.size && Date.now() - start < maxMs) {
await Promise.all(
[...pending].map(async (id) => {
try {
const r = await fetch(bankUrl(`/operations/${id}`), { headers: H() });
if (!r.ok) return;
const st = (((await r.json()) as { status?: string }).status || "").toLowerCase();
if (TERMINAL.has(st)) {
pending.delete(id);
if (st !== "completed") failed++;
}
} catch {
/* transient — retry next cycle */
}
}),
);
if (pending.size) {
console.log(`${pending.size}/${ids.length} ${label} ops pending`);
await sleep(5000);
}
}
console.log(`[wait] ${label} drained — ${ids.length - pending.size} done, ${failed} failed` +
(pending.size ? `, ${pending.size} still pending at timeout` : ""));
}
async function createPages() {
// Knowledge pages generate by reflecting over the EXTRACTED facts, so this must run AFTER the
// git+chat extraction has drained. Each page becomes a mental model reflect consults first.
console.log(`[pages] creating ${PAGES.length} knowledge pages …`);
const pageOps: string[] = [];
for (const p of PAGES) {
try {
// fact_types = ALL (world+experience+observation) so a page draws from raw facts AND
// consolidated observations, not a narrow default; refresh after consolidation keeps it live.
const body = { ...p, trigger: { fact_types: ["world", "experience", "observation"],
refresh_after_consolidation: true } };
const r = await req("POST", bankUrl("/knowledge-base/pages"), body);
const j = (await r.json()) as { operation_id?: string; page_id?: string };
if (j.operation_id) pageOps.push(j.operation_id);
console.log(` created page '${p.name}' -> ${j.page_id || "?"}`);
} catch (e) {
console.warn(` ! page '${p.name}' failed: ${(e as Error).message?.slice(0, 140)}`);
}
}
await drain(pageOps, "page-generation");
}
// ── git ───────────────────────────────────────────────────────────────────────
function git(...args: string[]): string {
return execFileSync("git", ["-C", REPO!, ...args], { encoding: "utf8", maxBuffer: 1 << 28 });
}
async function ingestGit() {
// NEWEST-first: recent commits (the project's own decision commits) extract before the ancient
// upstream noise, so the decisions that matter aren't starved at the tail of the extraction queue.
let shas = git("rev-list", "HEAD").trim().split("\n").filter(Boolean);
if (LIMIT) shas = shas.slice(0, LIMIT); // most recent N (validate the machine on a slice first)
const repoName = REPO!.replace(/\/+$/, "").split("/").pop() || "repo";
console.log(`[git] ingesting ${shas.length} commits (full message + full diff, no filter) …`);
const US = "\x1f";
await pool(shas, CONCURRENCY, async (sha) => {
// one call for the commit header (everything git gives for free) + subject + body
const [h, an, ae, aISO, cISO, subj, body] =
(git("show", "-s", `--format=%H${US}%an${US}%ae${US}%aI${US}%cI${US}%s${US}%b`, sha)).split(US);
const msg = (subj + (body?.trim() ? "\n\n" + body.trim() : "")).trim();
const diff = git("show", "--format=", sha); // FULL diff, uncapped
const content =
`REF-ID: git:${sha.slice(0, 12)}\n` +
`Git commit ${sha.slice(0, 12)} in the ${repoName} repository (${an}, ${aISO}).\n\n` +
`Message:\n${msg}\n\nDiff:\n${diff}`;
await retain(content, `git commit in ${repoName}`, `git:${sha}`, ["source:git"], "git", {
timestamp: aISO, // set the memory's timestamp to the commit's author date
metadata: { source: "git", repo: repoName, commit: h, short_sha: sha.slice(0, 12),
author: an, author_email: ae, authored_at: aISO, committed_at: cISO, subject: subj },
});
});
console.log(`[git] done: ${shas.length} commits ingested under strategy 'git'`);
}
// ── conversations ──────────────────────────────────────────────────────────────
async function ingestChats() {
if (!CONV) {
console.log("[chat] no --conversations file; skipping");
return;
}
type Turn = { role: string; text: string; timestamp?: string };
type Session = { id?: string; turns: Turn[] };
const sessions = JSON.parse(readFileSync(CONV, "utf8")) as Session[];
console.log(`[chat] ingesting ${sessions.length} chats (RAW, JSON user/assistant transcript) …`);
const NOW = Date.now(); // anchor synthesized times to a real, ABSOLUTE clock (not a fabricated epoch)
await pool(sessions, CONCURRENCY, async (s, i) => {
const id = s.id || `s${i}`;
// each turn gets an ABSOLUTE timestamp. Use the turn's own if provided; otherwise synthesize from
// the real current time, staggered per session (1h back each) + 1 min/turn to preserve ordering.
const sessBase = NOW - i * 3600000;
const ts = (t: { timestamp?: string }, j: number) => t.timestamp || new Date(sessBase + j * 60000).toISOString();
// JSON conversation format (Hindsight-preferred). Leading system turn carries the REF-ID tracer.
const turns = [{ role: "system", content: `REF-ID: chat:${id}`, timestamp: new Date(sessBase).toISOString() },
...(s.turns || []).map((t, j) => ({ role: t.role, content: t.text, timestamp: ts(t, j + 1) }))];
await retain(JSON.stringify(turns), "developer chat", `chat:${id}`, ["source:chat"], "chat", {
timestamp: new Date(sessBase).toISOString(),
metadata: { source: "chat", chat: id, ref_id: `chat:${id}` },
});
});
console.log(`[chat] done: ${sessions.length} chats ingested (JSON) under strategy 'chat'`);
}
async function main() {
console.log(`hindsight-coding-backfill -> ${API_URL} bank=${BANK}`);
await configureBank();
// chats FIRST: they're few and carry the decisions that make memory necessary — ingesting them
// before the (large) git flood keeps them from being starved in the server's extraction queue.
await ingestChats();
await ingestGit();
await drain(opIds, "extraction");
// knowledge pages are synthesized from the extracted facts, so create them AFTER the drain.
if (NO_PAGES) console.log("[pages] skipped (--no-pages)");
else await createPages();
console.log(`\n✅ backfill complete${failures ? ` (${failures} items failed to enqueue)` : ""}. ` +
"Point the plugin at this bank via HINDSIGHT_BANK_ID.");
}
main().catch((e) => {
console.error("backfill failed:", e.message || e);
process.exit(1);
});
@@ -0,0 +1,140 @@
/**
* hindsight-coding-opencode — long-term memory for coding agents, reflect + INJECT.
*
* READ: when a task arrives, the plugin asks the project's memory (Hindsight `reflect`) about the
* task's symptom and PUSHES the synthesized root-cause answer into the system prompt.
* WRITE (opt-in): with HINDSIGHT_RETAIN_SESSIONS on, it also binds the live session INTO memory —
* every few turns it upserts the full user/assistant transcript (tool calls/comments dropped) under a
* stable per-session document_id, so future sessions can recall it. Off by default (benchmark: a
* pre-backfilled bank must not be polluted by the agent writing its own solves back).
*
* Env: HINDSIGHT_API_URL (default http://localhost:8888), HINDSIGHT_BANK_ID, HINDSIGHT_API_TOKEN,
* HINDSIGHT_DISABLED (hard off-switch), HINDSIGHT_RETAIN_SESSIONS (enable live write),
* HINDSIGHT_RETAIN_EVERY_TURNS (default 5).
*/
import type { Plugin } from "@opencode-ai/plugin";
const env = (k: string, d = "") => process.env[k] ?? d;
const HindsightCodingPlugin: Plugin = async () => {
if (env("HINDSIGHT_DISABLED")) return {}; // inert: same agent, no memory (baseline parity)
const apiUrl = env("HINDSIGHT_API_URL", "http://localhost:8888").replace(/\/$/, "");
const apiToken = env("HINDSIGHT_API_TOKEN") || undefined;
const bankId = env("HINDSIGHT_BANK_ID", "coding");
// Reflect ONCE per session (on the task message); the surfaced memory is injected every turn.
const memory = new Map<string, string>();
const reflected = new Set<string>();
const TIMEOUT_MS = Number(env("HINDSIGHT_REFLECT_TIMEOUT_MS")) || 120000;
async function reflect(query: string): Promise<string> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (apiToken) headers["Authorization"] = `Bearer ${apiToken}`;
// Bounded: never hang the agent if the memory server is slow/loaded — skip injection instead.
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
try {
const resp = await fetch(`${apiUrl}/v1/default/banks/${encodeURIComponent(bankId)}/reflect`, {
method: "POST",
headers,
body: JSON.stringify({ query, budget: "high" }),
signal: ctrl.signal,
});
if (!resp.ok) throw new Error(`reflect ${resp.status}`);
const data = (await resp.json()) as { text?: string };
return (data.text || "").trim();
} finally {
clearTimeout(timer);
}
}
const textOf = (parts: { type?: string; text?: string }[]) =>
(parts || []).filter((p) => p?.type === "text" && p.text).map((p) => p!.text).join("\n").trim();
// ── live session write-back (opt-in) ────────────────────────────────────────
const RETAIN_SESSIONS = ["1", "true"].includes(env("HINDSIGHT_RETAIN_SESSIONS").toLowerCase());
const RETAIN_EVERY = Number(env("HINDSIGHT_RETAIN_EVERY_TURNS")) || 5;
const sessionState = new Map<string, { startTs: string; retainedUsers: number }>();
async function retainSession(sid: string, turns: { role: string; content: string; timestamp?: string }[], startTs: string) {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (apiToken) headers["Authorization"] = `Bearer ${apiToken}`;
// stable document_id per session => same-id UPSERT: Hindsight reprocesses the FULL conversation,
// so the distributed decision is extracted from the whole thing (not scattered per-turn).
await fetch(`${apiUrl}/v1/default/banks/${encodeURIComponent(bankId)}/memories`, {
method: "POST", headers,
body: JSON.stringify({ items: [{
content: JSON.stringify(turns), // JSON user/assistant transcript (roles preserved)
context: "opencode agent session",
document_id: `conversation:${sid}`, // stable per session (upsert), timestamp carried below
tags: ["source:chat"],
strategy: "chat",
timestamp: startTs,
metadata: { source: "chat", session_id: sid, ref_id: `conversation:${sid}` },
}], async: true }),
}).catch(() => {}); // best-effort; never break the agent
}
return {
// On the task message, reflect on its symptom and cache the surfaced decision for this session.
"chat.message": async (input: { sessionID?: string }, output: { parts: { type?: string; text?: string }[] }) => {
const sid = input.sessionID;
if (!sid || reflected.has(sid)) return; // once per session
const q = textOf(output.parts);
if (!q) return;
reflected.add(sid);
try {
const ans = await reflect(q);
if (ans) memory.set(sid, ans);
} catch {
/* memory is best-effort — never break the agent */
}
},
// Push the surfaced decision into the system prompt (every turn, so it persists across interventions).
"experimental.chat.system.transform": async (input: { sessionID?: string }, output: { system: string[] }) => {
const mem = input.sessionID ? memory.get(input.sessionID) : undefined;
if (!mem) return;
output.system.push(
"Relevant project memory, surfaced from THIS repository's git history and past developer " +
"conversations — a past decision that likely explains this issue. If it states an EXACT rule " +
"or literal values (specific strings, numbers, set members, mappings), apply them PRECISELY as " +
"given — the hidden tests depend on those exact choices; do not substitute your own guess. " +
"Verify against the current code before editing:\n\n" + mem,
);
},
// WRITE-BACK (opt-in): bind the live session into memory. Every RETAIN_EVERY user turns, upsert the
// FULL filtered transcript (user/assistant TEXT only — tool calls/outputs & reasoning dropped) under
// a stable per-session document_id. Note: our injected memory lives in the SYSTEM prompt, not in
// messages, so it is never re-ingested (no feedback loop).
"experimental.chat.messages.transform": async (
_input: unknown,
output: { messages: { info?: { role?: string; sessionID?: string; time?: { created?: number } }; parts: { type?: string; text?: string }[] }[] },
) => {
if (!RETAIN_SESSIONS) return;
const msgs = output.messages || [];
const sid = msgs.find((m) => m.info?.sessionID)?.info?.sessionID;
if (!sid) return;
const turns: { role: string; content: string; timestamp?: string }[] = [];
for (const m of msgs) {
const role = m.info?.role;
if (role !== "user" && role !== "assistant") continue; // drop non-conversational message roles
const text = textOf(m.parts); // text parts only => drops tool calls/comments
if (!text) continue;
const created = m.info?.time?.created; // per-turn timestamp (Unix ms) -> ISO
turns.push({ role, content: text, ...(created ? { timestamp: new Date(created).toISOString() } : {}) });
}
const users = turns.filter((t) => t.role === "user").length;
let st = sessionState.get(sid);
if (!st) { st = { startTs: new Date().toISOString(), retainedUsers: 0 }; sessionState.set(sid, st); }
if (turns.length && users - st.retainedUsers >= RETAIN_EVERY) {
st.retainedUsers = users;
void retainSession(sid, turns, st.startTs);
}
},
};
};
export default HindsightCodingPlugin;
export { HindsightCodingPlugin };
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "dist",
"types": ["node"]
},
"include": ["src"]
}
@@ -0,0 +1,10 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: { index: "src/index.ts", backfill: "src/backfill.ts" },
format: ["esm"],
target: "node18",
clean: true,
dts: { entry: "src/index.ts" },
shims: false,
});
+173
View File
@@ -0,0 +1,173 @@
# hindsight-fs
Mount a [Hindsight](https://github.com/vectorize-io/hindsight) memory bank's **knowledge base** as a live, auto-refreshing folder of markdown files on your local disk.
The knowledge base is a tree of **folders** and **pages**. hindsight-fs mirrors it one-to-one: each folder becomes a directory, each page becomes a real `.md` file (the page's [Open Knowledge Format](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf) document — YAML frontmatter + markdown body). A background loop re-syncs from the API on an interval, so ordinary shell tools — `ls`, `cat`, `grep`, `rg`, `find`, `fzf`, your editor, anything — just work against current memory. Think of it as a read-only filesystem view over an agent's living knowledge, in the spirit of [Supermemory's SMFS](https://supermemory.ai/docs/smfs/overview).
```
./kb/
├── profile/ # ← folder "Profile"
│ ├── user-preferences.md # ← page "User Preferences"
│ └── communication.md
├── policies/
│ └── billing-policy.md
├── project-status.md # ← a root-level page
└── .hindsight-fs/ # control data (config, state, daemon log, index.md)
```
Each page file looks like:
```markdown
---
id: 0f3c…
type: knowledge-page
title: User Preferences
tags: [ui, comms]
timestamp: "2026-06-25T10:00:00Z"
---
The user prefers dark mode and async, written communication. They dislike
status meetings and want concise PR descriptions.
```
## Install
```bash
npm install -g @vectorize-io/hindsight-fs
# or run without installing:
npx @vectorize-io/hindsight-fs --help
```
Requires Node.js ≥ 18.
## Quick start
```bash
# Mirror a bank into ./memory and keep it fresh in the background
hindsight-fs start ./memory --bank my-agent --api-url http://localhost:8000 --interval 15
# Now use plain shell tools — these are real files
ls ./memory
cat ./kb/profile/user-preferences.md
grep -ril "dark mode" ./memory
# See what's going on
hindsight-fs status ./memory
hindsight-fs logs ./memory
# Stop the background refresher
hindsight-fs stop ./memory
```
## Commands
| Command | Description |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `mount [dir]` | Mirror the bank into `dir` and keep it refreshed in the **foreground** (Ctrl-C to stop). `--detach` backgrounds it; `--once` does a single pass. |
| `start [dir]` | Mount in the **background** (alias for `mount --detach`). |
| `stop [dir]` | Stop the background daemon for `dir`. |
| `restart [dir]` | Restart the background daemon. |
| `sync [dir]` | Run a single refresh pass and exit. |
| `status [dir]` | Show daemon + last-sync health. `--json` for a machine-readable report; **exits non-zero when unhealthy**. |
| `list` | List the bank's knowledge-base folders + pages without writing files. |
| `logs [dir]` | Print the tail of the background daemon log. |
| `unmount [dir]` | Stop the daemon and remove mirrored files + control data. |
## Options
| Flag | Env var | Default | Description |
| ---------------------- | ----------------------- | ----------------------- | --------------------------------------------------------- |
| `-b, --bank <id>` | `HINDSIGHT_BANK_ID` | — | Bank to mirror (required). |
| `-u, --api-url <url>` | `HINDSIGHT_API_URL` | `http://localhost:8000` | API base URL. |
| `-t, --token <token>` | `HINDSIGHT_API_TOKEN` | — | Bearer token, if the API requires auth. |
| `-i, --interval <sec>` | `HINDSIGHT_FS_INTERVAL` | `30` | Refresh interval in seconds. |
| `-d, --dir <path>` | `HINDSIGHT_FS_DIR` | `./hindsight-fs` | Mount directory (overrides the positional arg). |
| `--writable` | | | Make mirrored files editable (default: read-only `0444`). |
Settings are remembered per mount in `<dir>/.hindsight-fs/config.json`, so after the first `start`/`mount` you can just run `hindsight-fs status ./memory` (or `sync`, `stop`) without re-passing `--bank`/`--api-url`.
## How syncing works
It's **pull-based polling**, not a push/webhook or a kernel filesystem. Each tick the engine:
1. Fetches the knowledge-base tree (`GET …/knowledge-base/tree`) and the page bundle (`GET …/knowledge-base/export`) — two requests, regardless of bank size.
2. Mirrors the tree to disk: creates folder directories, writes each page's markdown at its nested path, reconciles against disk (writes new/changed/tampered files, leaves identical files untouched), and prunes files + emptied folders for pages that no longer exist.
3. Records a per-file content hash and the last-sync time in `.hindsight-fs/state.json`.
The staleness window is therefore up to one `--interval`. There is no diffing on the wire — the whole list is fetched each tick — but only files whose **bytes actually differ** are rewritten, so disk churn is minimal.
## One-way mirror — agents can't edit it
Pages are owned by the API, so the mirror is strictly read-only at the filesystem level, enforced two ways:
- **Read-only files (default).** Mirrored files are written with mode `0444`, so an agent's in-place edit, `>>`, or editor-save fails immediately with `EACCES`. Pass `--writable` to opt out (e.g. if you want to scratch-edit locally).
- **Tamper-revert backstop.** Change detection compares the **on-disk bytes** against the freshly rendered content — not just the last API hash — so if a file drifts anyway (a force-`chmod`, an external tool), it's overwritten on the next tick and reset to `0444`. `status` and the sync log report a `reverted` count when this happens.
> A hard, instantaneous block (writes rejected at the VFS layer) would require a FUSE read-only mount; that's a heavier, kernel-extension dependency and is intentionally **not** how this works today. `0444` + revert blocks ordinary agent/editor writes without any system dependency. If you need true `EROFS` semantics, open an issue.
## Other guarantees
- **Safe on errors.** A transient API/network failure never wipes the mirror; the previous files are left in place and the error is recorded in `status`.
- **Pruning.** When a page is removed from the bank, its file (and any emptied folder) is removed on the next successful sync.
- **Atomic writes.** Files are written to a temp file and renamed, so readers never see a half-written document.
- **Quiet files.** Frontmatter carries no per-poll timestamp, so an unchanged model keeps identical bytes and mtime across refreshes — editors and file watchers stay calm.
## Monitoring & healthchecks
`status` doubles as a healthcheck. It combines two signals — is the daemon process alive, and did a sync succeed recently — into one verdict, and **exits non-zero when the mount is unhealthy**:
| `status` | Meaning | Exit |
| -------- | ---------------------------------------------------------- | ---- |
| `ok` | daemon alive and a sync succeeded within the stale window | `0` |
| `failed` | daemon alive but the last sync errored (bad URL/auth/bank) | `1` |
| `stale` | daemon alive but no fresh sync (wedged, or never synced) | `1` |
| `dead` | no daemon running | `1` |
```bash
# Human-readable (adds a "Health:" line)
hindsight-fs status ./memory
# Machine-readable + scriptable exit code
hindsight-fs status ./memory --json
# Use it as a guard / in a watchdog / container healthcheck
if ! hindsight-fs status ./memory --json >/dev/null; then
hindsight-fs restart ./memory
fi
```
The `--json` report looks like:
```json
{
"healthy": false,
"status": "stale",
"mount": "/…/memory",
"bank": "my-agent",
"mode": "read-only",
"daemon": { "running": true, "pid": 78765, "startedAt": "…", "intervalSeconds": 15 },
"lastSync": { "at": "…", "ok": true, "ageSeconds": 92, "error": null },
"staleAfterSeconds": 45,
"mirroredFiles": 12
}
```
A sync is "stale" once it's older than `max(interval × 3, 15s)`; override with `--stale-after <seconds>`. The same verdict is available programmatically via `computeHealth(config)`.
## Programmatic use
The package also exports its engine for embedding in other tools:
```ts
import { runSync, runLoop, resolveConfig } from "@vectorize-io/hindsight-fs";
const config = await resolveConfig({ dir: "./memory", bankId: "my-agent" }, { requireBank: true });
await runSync(config); // one pass
```
## Development
```bash
npm run build # tsc → dist/
npm test # vitest
```
+45
View File
@@ -0,0 +1,45 @@
{
"name": "@vectorize-io/hindsight-fs",
"version": "0.1.0",
"description": "Mount a Hindsight memory bank's mental models as a live, auto-refreshing local folder of markdown files — ls, cat, grep and friends just work.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"hindsight-fs": "dist/cli.js",
"hsfs": "dist/cli.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"pretest": "tsc",
"test": "vitest run tests",
"prepublishOnly": "npm run build"
},
"keywords": [
"ai-agents",
"hindsight",
"memory",
"mental-models",
"filesystem",
"markdown",
"cli"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/vectorize-io/hindsight.git",
"directory": "hindsight-tools/hindsight-fs"
},
"engines": {
"node": ">=18"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.4",
"vitest": "^4.1.2"
}
}
+371
View File
@@ -0,0 +1,371 @@
#!/usr/bin/env node
/**
* hindsight-fs CLI.
*
* Mirrors a Hindsight bank's knowledge base (folders + pages) as a folder of
* markdown files that stay current via a background refresh loop. Once mounted,
* ordinary shell tools (ls, cat, grep, find, rg, fzf …) work against real files.
*/
import { promises as fs } from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveConfig, saveConfig, type ConfigOverrides, type MountConfig } from "./config.js";
import { runSync } from "./sync.js";
import { runLoop } from "./loop.js";
import { HindsightFsClient } from "./client.js";
import { startDaemon, stopDaemon, logPath } from "./daemon.js";
import { planMirror } from "./format.js";
import { loadState } from "./state.js";
import { computeHealth } from "./health.js";
import { CONTROL_DIR } from "./paths.js";
// ── Arg parsing ────────────────────────────────────────
interface ParsedArgs {
command: string;
positionals: string[];
flags: Record<string, string | boolean>;
}
const FLAG_ALIASES: Record<string, string> = {
b: "bank",
u: "api-url",
t: "token",
i: "interval",
d: "dir",
h: "help",
v: "version",
};
const BOOLEAN_FLAGS = new Set([
"once",
"detach",
"help",
"version",
"full",
"quiet",
"writable",
"json",
]);
function parseArgs(argv: string[]): ParsedArgs {
const positionals: string[] = [];
const flags: Record<string, string | boolean> = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg.startsWith("--")) {
const eq = arg.indexOf("=");
let name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
name = FLAG_ALIASES[name] ?? name;
if (eq !== -1) {
flags[name] = arg.slice(eq + 1);
} else if (BOOLEAN_FLAGS.has(name)) {
flags[name] = true;
} else {
flags[name] = argv[++i] ?? "";
}
} else if (arg.startsWith("-") && arg.length > 1) {
const name = FLAG_ALIASES[arg.slice(1)] ?? arg.slice(1);
if (BOOLEAN_FLAGS.has(name)) {
flags[name] = true;
} else {
flags[name] = argv[++i] ?? "";
}
} else {
positionals.push(arg);
}
}
const command = positionals.shift() ?? "help";
return { command, positionals, flags };
}
function overridesFrom(args: ParsedArgs): ConfigOverrides {
const o: ConfigOverrides = {};
// For dir-taking commands, the first positional is the mount dir.
if (args.positionals[0]) o.dir = args.positionals[0];
if (typeof args.flags.dir === "string" && args.flags.dir) o.dir = args.flags.dir;
if (typeof args.flags.bank === "string" && args.flags.bank) o.bankId = args.flags.bank;
if (typeof args.flags["api-url"] === "string" && args.flags["api-url"])
o.apiUrl = args.flags["api-url"];
if (typeof args.flags.token === "string" && args.flags.token) o.apiToken = args.flags.token;
if (typeof args.flags.interval === "string" && args.flags.interval) {
o.intervalSeconds = Number(args.flags.interval);
}
if (args.flags.writable === true) o.writable = true;
return o;
}
// ── Output helpers ─────────────────────────────────────
function out(msg: string): void {
process.stdout.write(msg + "\n");
}
function err(msg: string): void {
process.stderr.write(msg + "\n");
}
function stamp(): string {
return new Date().toISOString().replace("T", " ").slice(0, 19);
}
// ── Commands ───────────────────────────────────────────
async function cmdSync(args: ParsedArgs): Promise<void> {
const config = await resolveConfig(overridesFrom(args), { requireBank: true });
await saveConfig(config);
const result = await runSync(config);
const reverted = result.reverted > 0 ? `, ${result.reverted} reverted` : "";
out(
`Synced ${result.total} pages / ${result.folders} folders into ${config.dir} ` +
`(${result.written} updated, ${result.unchanged} unchanged, ${result.removed} removed${reverted})`
);
}
async function cmdMount(args: ParsedArgs): Promise<void> {
const config = await resolveConfig(overridesFrom(args), { requireBank: true });
await saveConfig(config);
if (args.flags.once === true) {
await cmdSync(args);
return;
}
if (args.flags.detach === true) {
const { pid, alreadyRunning } = await startDaemon(config);
if (alreadyRunning) {
out(`Already mounted at ${config.dir} (daemon pid ${pid}).`);
} else {
out(`Mounted bank "${config.bankId}" at ${config.dir} in background (pid ${pid}).`);
out(`Logs: ${logPath(config.dir)} — stop with: hindsight-fs stop ${config.dir}`);
}
return;
}
// Foreground: run until Ctrl-C.
const controller = new AbortController();
const onSignal = () => controller.abort();
process.on("SIGINT", onSignal);
process.on("SIGTERM", onSignal);
await runLoop(config, {
signal: controller.signal,
log: (m) => err(`[${stamp()}] ${m}`),
});
}
async function cmdStart(args: ParsedArgs): Promise<void> {
await cmdMount({ ...args, flags: { ...args.flags, detach: true } });
}
async function cmdStop(args: ParsedArgs): Promise<void> {
const config = await resolveConfig(overridesFrom(args));
const { stopped, pid } = await stopDaemon(config.dir);
if (stopped) out(`Stopped daemon (pid ${pid}) for ${config.dir}.`);
else if (pid) out(`Daemon for ${config.dir} was not running (cleaned up stale pid ${pid}).`);
else out(`No daemon registered for ${config.dir}.`);
}
async function cmdRestart(args: ParsedArgs): Promise<void> {
await cmdStop(args);
await cmdStart(args);
}
async function cmdStatus(args: ParsedArgs): Promise<void> {
const config = await resolveConfig(overridesFrom(args));
const staleAfterSeconds =
typeof args.flags["stale-after"] === "string" && args.flags["stale-after"]
? Number(args.flags["stale-after"])
: undefined;
const report = await computeHealth(config, { staleAfterSeconds });
if (args.flags.json === true) {
out(JSON.stringify(report, null, 2));
} else {
const d = report.daemon;
const age =
report.lastSync.ageSeconds === null ? "never" : `${report.lastSync.ageSeconds}s ago`;
out(`Mount: ${report.mount}`);
out(`Bank: ${report.bank || "(unset)"}`);
out(`API: ${report.apiUrl}`);
out(
`Mode: ${report.mode === "writable" ? "writable (one-way; edits reverted on refresh)" : "read-only (edits blocked)"}`
);
out(`Daemon: ${d.running ? `running (pid ${d.pid})` : "stopped"}`);
if (d.startedAt) out(`Interval: ${d.intervalSeconds}s (started ${d.startedAt})`);
out(
`Last sync: ${report.lastSync.at ?? "never"} (${age})${report.lastSync.ok ? "" : " (FAILED)"}`
);
if (report.lastSync.error) out(`Last error: ${report.lastSync.error}`);
out(`Mirrored: ${report.mirroredFiles} file(s)`);
out(`Health: ${report.healthy ? "ok" : report.status.toUpperCase()}`);
}
if (!report.healthy) process.exitCode = 1;
}
async function cmdList(args: ParsedArgs): Promise<void> {
const config = await resolveConfig(overridesFrom(args), { requireBank: true });
const client = new HindsightFsClient({ apiUrl: config.apiUrl, apiToken: config.apiToken });
const snapshot = await client.loadKnowledge(config.bankId);
const plan = planMirror(snapshot);
if (plan.dirs.length === 0 && plan.files.length === 0) {
out(`No knowledge base in bank "${config.bankId}".`);
return;
}
for (const dir of plan.dirs) out(`${dir}/`);
for (const page of plan.files) out(page.relPath);
}
async function cmdUnmount(args: ParsedArgs): Promise<void> {
const config = await resolveConfig(overridesFrom(args));
await stopDaemon(config.dir);
const state = await loadState(config.dir, config.bankId, config.apiUrl);
let removed = 0;
for (const entry of Object.values(state.files)) {
try {
await fs.unlink(path.join(config.dir, entry.file));
removed++;
} catch {
/* already gone */
}
}
await fs.rm(path.join(config.dir, CONTROL_DIR), { recursive: true, force: true });
out(`Unmounted ${config.dir} — removed ${removed} mirrored file(s) and control data.`);
}
async function cmdLogs(args: ParsedArgs): Promise<void> {
const config = await resolveConfig(overridesFrom(args));
try {
const log = await fs.readFile(logPath(config.dir), "utf8");
const lines = log.split("\n");
const tail = lines.slice(Math.max(0, lines.length - 40)).join("\n");
process.stdout.write(tail.endsWith("\n") ? tail : tail + "\n");
} catch {
out(`No logs for ${config.dir}.`);
}
}
/** Hidden entrypoint used by the detached daemon process. */
async function cmdRun(args: ParsedArgs): Promise<void> {
const config = await resolveConfig(overridesFrom(args), { requireBank: true });
const controller = new AbortController();
const onSignal = () => controller.abort();
process.on("SIGTERM", onSignal);
process.on("SIGINT", onSignal);
await runLoop(config, {
signal: controller.signal,
log: (m) => process.stdout.write(`[${stamp()}] ${m}\n`),
});
process.exit(0);
}
async function readVersion(): Promise<string> {
try {
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
const pkg = JSON.parse(await fs.readFile(pkgPath, "utf8")) as { version?: string };
return pkg.version ?? "0.0.0";
} catch {
return "0.0.0";
}
}
function printHelp(): void {
out(`hindsight-fs — mount a Hindsight bank's knowledge base as a live local folder
USAGE
hindsight-fs <command> [dir] [options]
COMMANDS
mount [dir] Mirror the bank into <dir> and keep it refreshed (foreground; Ctrl-C to stop)
Add --detach to run in the background, or --once for a single pass.
start [dir] Mount in the background (alias for: mount --detach)
stop [dir] Stop the background daemon for <dir>
restart [dir] Restart the background daemon
sync [dir] Run a single refresh pass and exit
status [dir] Show daemon + last-sync health for <dir>
Add --json for a machine-readable report. Exits non-zero when
the mount is unhealthy (dead, failed, or stale).
list List the bank's knowledge-base folders + pages (no files written)
logs [dir] Print the tail of the background daemon log
unmount [dir] Stop the daemon and delete mirrored files + control data
help Show this help
version Show the version
OPTIONS
-b, --bank <id> Bank to mirror (env: HINDSIGHT_BANK_ID)
-u, --api-url <url> API base URL (env: HINDSIGHT_API_URL, default http://localhost:8000)
-t, --token <token> Bearer token (env: HINDSIGHT_API_TOKEN)
-i, --interval <sec> Refresh interval in seconds (default 30)
-d, --dir <path> Mount directory (overrides positional; env: HINDSIGHT_FS_DIR)
--writable Make mirrored files editable (default: read-only)
--once For 'mount': run a single pass instead of looping
--detach For 'mount': run in the background
--json For 'status': print a machine-readable health report
--stale-after <s> For 'status': seconds before a sync is "stale"
(default: max(interval × 3, 15))
FILES
Folders become directories and pages become <name>.md (YAML frontmatter +
markdown body), nested to match the knowledge-base tree. The mirror is one-way
(API → disk). By default files are read-only (mode 0444) so agents cannot edit
them; any drift is also reverted on the next refresh. Control data lives in
<dir>/.hindsight-fs/ (config, state, daemon log, index.md).
EXAMPLES
hindsight-fs mount ./kb --bank my-agent --interval 15
hindsight-fs start ./kb --bank my-agent
ls -R ./kb && cat ./kb/policies/billing-policy.md
grep -ril "net-30" ./kb
hindsight-fs stop ./kb`);
}
// ── Dispatch ───────────────────────────────────────────
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
if (args.flags.version === true || args.command === "version") {
out(`hindsight-fs ${await readVersion()}`);
return;
}
if (args.flags.help === true || args.command === "help") {
printHelp();
return;
}
switch (args.command) {
case "mount":
return cmdMount(args);
case "start":
return cmdStart(args);
case "stop":
return cmdStop(args);
case "restart":
return cmdRestart(args);
case "sync":
return cmdSync(args);
case "status":
return cmdStatus(args);
case "list":
case "ls":
return cmdList(args);
case "logs":
return cmdLogs(args);
case "unmount":
case "umount":
return cmdUnmount(args);
case "__run":
return cmdRun(args);
default:
err(`Unknown command: ${args.command}\n`);
printHelp();
process.exitCode = 1;
}
}
main().catch((e) => {
err(`Error: ${e instanceof Error ? e.message : String(e)}`);
process.exitCode = 1;
});
+109
View File
@@ -0,0 +1,109 @@
/**
* Minimal Hindsight API client — only the knowledge-base read endpoints needed
* to mirror a bank's knowledge base into a folder. Uses the global `fetch`
* (Node 18+). Intentionally dependency-free so the CLI stays npx-installable.
*
* Two endpoints, fetched once per sync:
* - GET /knowledge-base/tree → the folder/page hierarchy (no page bodies)
* - GET /knowledge-base/export → every page's OKF markdown in one bundle
* We join them by page id, so a bank of any size is two HTTP calls.
*/
export interface KnowledgeNode {
id: string;
kind: "folder" | "page";
name: string;
parent_id: string | null;
mental_model_id?: string | null;
mission?: string | null;
managed?: boolean;
description?: string | null;
tags?: string[];
timestamp?: string | null;
children: KnowledgeNode[];
}
export interface KnowledgeSnapshot {
/** Top-level folder/page nodes (each with nested `children`). */
roots: KnowledgeNode[];
/** page id → its full OKF markdown document (frontmatter + body). */
content: Map<string, string>;
}
export interface ClientOptions {
apiUrl: string;
apiToken?: string;
}
export class HindsightFsClient {
private readonly apiUrl: string;
private readonly apiToken?: string;
constructor(opts: ClientOptions) {
this.apiUrl = opts.apiUrl.replace(/\/+$/, "");
this.apiToken = opts.apiToken;
}
private headers(): Record<string, string> {
const h: Record<string, string> = {
Accept: "application/json",
"User-Agent": "hindsight-fs/0.1.0",
};
if (this.apiToken) h.Authorization = `Bearer ${this.apiToken}`;
return h;
}
private base(bankId: string): string {
return `${this.apiUrl}/v1/default/banks/${encodeURIComponent(bankId)}/knowledge-base`;
}
private async getJson<T>(url: string, what: string, bankId: string): Promise<T> {
const resp = await fetch(url, { headers: this.headers() });
if (!resp.ok) {
const body = await resp.text().catch(() => "");
throw new ApiError(
`Failed to ${what} for bank "${bankId}" (HTTP ${resp.status})`,
resp.status,
body
);
}
return (await resp.json()) as T;
}
/** Fetch the knowledge-base tree + page contents and join them. */
async loadKnowledge(bankId: string): Promise<KnowledgeSnapshot> {
const tree = await this.getJson<{ roots?: KnowledgeNode[] }>(
`${this.base(bankId)}/tree`,
"fetch knowledge-base tree",
bankId
);
const bundle = await this.getJson<{ files?: { path: string; content: string }[] }>(
`${this.base(bankId)}/export`,
"export knowledge base",
bankId
);
// The bundle holds `<page-id>.md` (the page doc), `index.md`, and
// `<page-id>.log.md` (history). We only want the page docs.
const content = new Map<string, string>();
for (const file of bundle.files ?? []) {
if (file.path === "index.md" || file.path.endsWith(".log.md")) continue;
if (file.path.endsWith(".md")) {
content.set(file.path.slice(0, -".md".length), file.content);
}
}
return { roots: tree.roots ?? [], content };
}
}
export class ApiError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly body: string
) {
super(message);
this.name = "ApiError";
}
}
+130
View File
@@ -0,0 +1,130 @@
/**
* Configuration resolution for hindsight-fs.
*
* Priority (highest first): explicit CLI flags > saved mount config
* (<dir>/.hindsight-fs/config.json) > environment variables > defaults.
*
* The saved config is written when a folder is first mounted so that later
* commands run inside that folder (status/stop/sync) reuse the same bank and
* endpoint without re-passing flags.
*/
import { promises as fs } from "node:fs";
import * as path from "node:path";
import { CONTROL_DIR } from "./paths.js";
export interface MountConfig {
/** Absolute path to the mount directory. */
dir: string;
/** Hindsight API base URL (no trailing slash). */
apiUrl: string;
/** Bearer token, if the API requires auth. */
apiToken?: string;
/** Bank whose knowledge base is mirrored. */
bankId: string;
/** Refresh interval in seconds for the sync loop. */
intervalSeconds: number;
/**
* When false (the default), mirrored files are written read-only so agents
* cannot edit them. Set true to opt into editable files (still one-way: any
* edit is overwritten on the next refresh).
*/
writable: boolean;
}
/** Overrides supplied directly on the command line (all optional). */
export interface ConfigOverrides {
dir?: string;
apiUrl?: string;
apiToken?: string;
bankId?: string;
intervalSeconds?: number;
writable?: boolean;
}
export const DEFAULT_API_URL = "http://localhost:8000";
export const DEFAULT_INTERVAL_SECONDS = 30;
export const DEFAULT_DIR = "./hindsight-fs";
function stripTrailingSlash(url: string): string {
return url.replace(/\/+$/, "");
}
/** Partial config persisted alongside a mount. */
interface SavedConfig {
apiUrl?: string;
apiToken?: string;
bankId?: string;
intervalSeconds?: number;
writable?: boolean;
}
async function readSavedConfig(dir: string): Promise<SavedConfig> {
const file = path.join(dir, CONTROL_DIR, "config.json");
try {
return JSON.parse(await fs.readFile(file, "utf8")) as SavedConfig;
} catch {
return {};
}
}
/**
* Resolve the effective config for a command.
*
* `requireBank` controls whether a missing bank id is a hard error (true for
* commands that talk to the API; false for read-only local commands).
*/
export async function resolveConfig(
overrides: ConfigOverrides,
opts: { requireBank?: boolean } = {}
): Promise<MountConfig> {
const dir = path.resolve(overrides.dir ?? process.env.HINDSIGHT_FS_DIR ?? DEFAULT_DIR);
const saved = await readSavedConfig(dir);
const apiUrl = stripTrailingSlash(
overrides.apiUrl ?? saved.apiUrl ?? process.env.HINDSIGHT_API_URL ?? DEFAULT_API_URL
);
const apiToken =
overrides.apiToken ?? saved.apiToken ?? process.env.HINDSIGHT_API_TOKEN ?? undefined;
const bankId = overrides.bankId ?? saved.bankId ?? process.env.HINDSIGHT_BANK_ID ?? "";
if (opts.requireBank && !bankId) {
throw new Error(
"No bank specified. Pass --bank <id>, set HINDSIGHT_BANK_ID, or run inside an already-mounted folder."
);
}
const intervalRaw =
overrides.intervalSeconds ??
saved.intervalSeconds ??
(process.env.HINDSIGHT_FS_INTERVAL ? Number(process.env.HINDSIGHT_FS_INTERVAL) : undefined) ??
DEFAULT_INTERVAL_SECONDS;
const intervalSeconds =
Number.isFinite(intervalRaw) && intervalRaw >= 1
? Math.floor(intervalRaw)
: DEFAULT_INTERVAL_SECONDS;
const writable = overrides.writable ?? saved.writable ?? false;
return { dir, apiUrl, apiToken, bankId, intervalSeconds, writable };
}
/** Persist the parts of a config worth remembering for a mounted folder. */
export async function saveConfig(config: MountConfig): Promise<void> {
const controlDir = path.join(config.dir, CONTROL_DIR);
await fs.mkdir(controlDir, { recursive: true });
const saved: SavedConfig = {
apiUrl: config.apiUrl,
apiToken: config.apiToken,
bankId: config.bankId,
intervalSeconds: config.intervalSeconds,
writable: config.writable,
};
await fs.writeFile(
path.join(controlDir, "config.json"),
JSON.stringify(saved, null, 2) + "\n",
"utf8"
);
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Background daemon management. `start` spawns a detached copy of this CLI in
* `__run` mode (the hidden loop entrypoint), writing a pidfile and a log under
* the mount's control directory. `stop` signals it; `status` reports liveness.
*/
import { promises as fs } from "node:fs";
import { openSync } from "node:fs";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import * as path from "node:path";
import { CONTROL_DIR, PID_FILE, LOG_FILE } from "./paths.js";
import { saveConfig, type MountConfig } from "./config.js";
interface PidRecord {
pid: number;
bankId: string;
intervalSeconds: number;
startedAt: string;
}
function controlPath(dir: string, file: string): string {
return path.join(dir, CONTROL_DIR, file);
}
/** True if a process with `pid` is currently alive. */
export function isAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err) {
return (err as NodeJS.ErrnoException).code === "EPERM";
}
}
export async function readPidRecord(dir: string): Promise<PidRecord | null> {
try {
return JSON.parse(await fs.readFile(controlPath(dir, PID_FILE), "utf8")) as PidRecord;
} catch {
return null;
}
}
async function removePidFile(dir: string): Promise<void> {
try {
await fs.unlink(controlPath(dir, PID_FILE));
} catch {
/* ignore */
}
}
/** Resolve the path to this CLI's entry script for re-spawning. */
function cliEntry(): string {
// dist/daemon.js → dist/cli.js
return path.join(path.dirname(fileURLToPath(import.meta.url)), "cli.js");
}
export interface StartResult {
pid: number;
alreadyRunning: boolean;
}
export async function startDaemon(config: MountConfig): Promise<StartResult> {
const existing = await readPidRecord(config.dir);
if (existing && isAlive(existing.pid)) {
return { pid: existing.pid, alreadyRunning: true };
}
await fs.mkdir(path.join(config.dir, CONTROL_DIR), { recursive: true });
await saveConfig(config);
const logFd = openSync(controlPath(config.dir, LOG_FILE), "a");
const child = spawn(process.execPath, [cliEntry(), "__run", "--dir", config.dir], {
detached: true,
stdio: ["ignore", logFd, logFd],
env: process.env,
});
child.unref();
if (child.pid === undefined) {
throw new Error("Failed to spawn daemon process");
}
const record: PidRecord = {
pid: child.pid,
bankId: config.bankId,
intervalSeconds: config.intervalSeconds,
startedAt: new Date().toISOString(),
};
await fs.writeFile(
controlPath(config.dir, PID_FILE),
JSON.stringify(record, null, 2) + "\n",
"utf8"
);
return { pid: child.pid, alreadyRunning: false };
}
export interface StopResult {
stopped: boolean;
pid: number | null;
}
export async function stopDaemon(dir: string): Promise<StopResult> {
const record = await readPidRecord(dir);
if (!record) return { stopped: false, pid: null };
if (!isAlive(record.pid)) {
await removePidFile(dir);
return { stopped: false, pid: record.pid };
}
try {
process.kill(record.pid, "SIGTERM");
} catch {
/* may have just exited */
}
// Give it a moment to exit, then force-kill if needed.
for (let i = 0; i < 50 && isAlive(record.pid); i++) {
await new Promise((r) => setTimeout(r, 100));
}
if (isAlive(record.pid)) {
try {
process.kill(record.pid, "SIGKILL");
} catch {
/* ignore */
}
}
await removePidFile(dir);
return { stopped: true, pid: record.pid };
}
export interface DaemonStatus {
running: boolean;
pid: number | null;
record: PidRecord | null;
}
export async function daemonStatus(dir: string): Promise<DaemonStatus> {
const record = await readPidRecord(dir);
if (!record) return { running: false, pid: null, record: null };
const running = isAlive(record.pid);
if (!running) await removePidFile(dir);
return { running, pid: running ? record.pid : null, record };
}
export function logPath(dir: string): string {
return controlPath(dir, LOG_FILE);
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Turn a knowledge-base snapshot (folder/page tree + page contents) into a
* concrete on-disk mirror plan: which directories to create (folders) and which
* `.md` files to write (pages), at their nested paths.
*/
import type { KnowledgeNode, KnowledgeSnapshot } from "./client.js";
import { stringifyFrontmatter, type Frontmatter } from "./frontmatter.js";
const PAGE_PLACEHOLDER = "_This page has not been generated yet._";
/** Map a folder/page name to a safe path segment (no slashes, lowercase). */
export function slug(name: string): string {
const safe = name
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/\.+/g, ".");
return safe || "untitled";
}
export interface PageFile {
/** Relative path under the mount, e.g. "engineering/runbooks/orders.md". */
relPath: string;
content: string;
}
export interface MirrorPlan {
/** Folder directories to create, in tree order (parents before children). */
dirs: string[];
/** Page files to write at their nested paths. */
files: PageFile[];
folderCount: number;
pageCount: number;
}
/** Ensure sibling nodes get distinct path segments even if their names collide. */
function uniqueSegment(base: string, id: string, used: Set<string>): string {
if (!used.has(base)) {
used.add(base);
return base;
}
const suffix = id.replace(/[^a-z0-9]/gi, "").slice(-6) || "x";
let seg = `${base}-${suffix}`;
while (used.has(seg)) seg = `${seg}-x`;
used.add(seg);
return seg;
}
function pageContent(node: KnowledgeNode, snapshot: KnowledgeSnapshot): string {
const doc = snapshot.content.get(node.id);
if (doc && doc.trim().length > 0) return doc.endsWith("\n") ? doc : `${doc}\n`;
// Fallback OKF-ish doc when the page body hasn't synthesized yet.
const fm: Frontmatter = {
id: node.id,
type: "knowledge-page",
title: node.name,
tags: node.tags ?? [],
timestamp: node.timestamp ?? null,
};
return `${stringifyFrontmatter(fm)}\n\n${PAGE_PLACEHOLDER}\n`;
}
/** Walk the tree into a flat list of directories + page files at nested paths. */
export function planMirror(snapshot: KnowledgeSnapshot): MirrorPlan {
const dirs: string[] = [];
const files: PageFile[] = [];
let folderCount = 0;
let pageCount = 0;
function walk(nodes: KnowledgeNode[], parentDir: string): void {
const used = new Set<string>();
const ordered = [...nodes].sort((a, b) => a.name.localeCompare(b.name));
for (const node of ordered) {
const seg = uniqueSegment(slug(node.name), node.id, used);
const rel = parentDir ? `${parentDir}/${seg}` : seg;
if (node.kind === "folder") {
folderCount++;
dirs.push(rel);
walk(node.children ?? [], rel);
} else {
pageCount++;
files.push({ relPath: `${rel}.md`, content: pageContent(node, snapshot) });
}
}
}
walk(snapshot.roots, "");
return { dirs, files, folderCount, pageCount };
}
/** Build the `index.md` overview written into the control directory. */
export function renderIndex(
snapshot: KnowledgeSnapshot,
config: { bankId: string; apiUrl: string }
): string {
const plan = planMirror(snapshot);
const frontmatter: Frontmatter = {
bank: config.bankId,
api_url: config.apiUrl,
folders: plan.folderCount,
pages: plan.pageCount,
};
const lines: string[] = [];
function walk(nodes: KnowledgeNode[], depth: number, parentDir: string, used: Set<string>): void {
for (const node of [...nodes].sort((a, b) => a.name.localeCompare(b.name))) {
const seg = uniqueSegment(slug(node.name), node.id, used);
const rel = parentDir ? `${parentDir}/${seg}` : seg;
const indent = " ".repeat(depth);
if (node.kind === "folder") {
const mission = node.mission ? ` — _${node.mission}_` : "";
lines.push(`${indent}- **${node.name}/**${mission}`);
walk(node.children ?? [], depth + 1, rel, new Set<string>());
} else {
const auto = node.managed ? " ·auto" : "";
lines.push(`${indent}- [\`${node.name}\`](../${rel}.md)${auto}`);
}
}
}
walk(snapshot.roots, 0, "", new Set<string>());
const body = lines.length ? lines.join("\n") : "_This bank has no knowledge base yet._";
return `${stringifyFrontmatter(frontmatter)}\n\n# Knowledge base — \`${config.bankId}\`\n\n${body}\n`;
}
@@ -0,0 +1,123 @@
/**
* A tiny YAML frontmatter serializer/parser for flat metadata maps.
*
* We only ever emit scalars (string | number | boolean | null) and arrays of
* scalars, so a full YAML dependency is overkill. Double-quoted scalars use
* JSON string syntax, which is a valid subset of YAML, giving us correct
* escaping of colons, quotes, and newlines for free.
*/
export type FrontmatterValue = string | number | boolean | null | string[];
export type Frontmatter = Record<string, FrontmatterValue>;
/** True when a string is safe to emit unquoted as a YAML plain scalar. */
function isPlainSafe(s: string): boolean {
if (s.length === 0) return false;
if (s !== s.trim()) return false;
// Avoid anything that could be interpreted as structure or a non-string type.
if (/[:#\[\]{}&*!|>'"%@`,]/.test(s)) return false;
if (/^[-?]/.test(s)) return false;
if (/^(true|false|null|yes|no|on|off|~)$/i.test(s)) return false;
if (/^[-+]?[0-9]/.test(s)) return false; // could parse as number/date
if (/[\n\r\t]/.test(s)) return false;
return true;
}
function emitScalar(value: string | number | boolean | null): string {
if (value === null) return "null";
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "null";
return isPlainSafe(value) ? value : JSON.stringify(value);
}
/** Serialize a frontmatter map to a `---`-delimited YAML block (no trailing newline). */
export function stringifyFrontmatter(data: Frontmatter): string {
const lines: string[] = ["---"];
for (const [key, value] of Object.entries(data)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
if (value.length === 0) {
lines.push(`${key}: []`);
} else {
lines.push(`${key}: [${value.map((v) => emitScalar(v)).join(", ")}]`);
}
} else {
lines.push(`${key}: ${emitScalar(value)}`);
}
}
lines.push("---");
return lines.join("\n");
}
export interface ParsedDocument {
frontmatter: Frontmatter;
body: string;
}
function parseScalar(raw: string): FrontmatterValue {
const t = raw.trim();
if (t === "null" || t === "~" || t === "") return null;
if (t === "true") return true;
if (t === "false") return false;
if (/^".*"$/.test(t)) {
try {
return JSON.parse(t) as string;
} catch {
return t.slice(1, -1);
}
}
if (/^[-+]?\d+(\.\d+)?$/.test(t)) return Number(t);
return t;
}
/**
* Parse a document that may begin with a frontmatter block. Lossy by design —
* sufficient for round-tripping what `stringifyFrontmatter` writes and for tests.
*/
export function parseDocument(text: string): ParsedDocument {
if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) {
return { frontmatter: {}, body: text };
}
const normalized = text.replace(/\r\n/g, "\n");
const end = normalized.indexOf("\n---", 3);
if (end === -1) return { frontmatter: {}, body: text };
const block = normalized.slice(4, end);
let rest = normalized.slice(end + 4);
if (rest.startsWith("\n")) rest = rest.slice(1);
const frontmatter: Frontmatter = {};
for (const line of block.split("\n")) {
if (!line.trim()) continue;
const idx = line.indexOf(":");
if (idx === -1) continue;
const key = line.slice(0, idx).trim();
const valueRaw = line.slice(idx + 1).trim();
if (/^\[.*\]$/.test(valueRaw)) {
const inner = valueRaw.slice(1, -1).trim();
frontmatter[key] = inner ? splitFlowItems(inner).map((v) => String(parseScalar(v))) : [];
} else {
frontmatter[key] = parseScalar(valueRaw);
}
}
return { frontmatter, body: rest };
}
/** Split `a, "b, c", d` respecting JSON-quoted items. */
function splitFlowItems(inner: string): string[] {
const items: string[] = [];
let current = "";
let inQuote = false;
for (let i = 0; i < inner.length; i++) {
const ch = inner[i];
if (ch === '"' && inner[i - 1] !== "\\") inQuote = !inQuote;
if (ch === "," && !inQuote) {
items.push(current.trim());
current = "";
} else {
current += ch;
}
}
if (current.trim()) items.push(current.trim());
return items;
}
@@ -0,0 +1,99 @@
/**
* Health assessment for a mount — used by `status` (human + `--json`) and
* exported for programmatic healthchecks/watchdogs.
*
* Two orthogonal signals are combined into one verdict:
* - liveness: is the daemon process actually alive?
* - freshness: did a sync succeed recently (within the stale threshold)?
*/
import { daemonStatus } from "./daemon.js";
import { loadState } from "./state.js";
import type { MountConfig } from "./config.js";
export type HealthStatus = "ok" | "stale" | "failed" | "dead";
export interface HealthReport {
/** True only when status === "ok". Drives the process exit code. */
healthy: boolean;
status: HealthStatus;
mount: string;
bank: string;
apiUrl: string;
mode: "read-only" | "writable";
daemon: {
running: boolean;
pid: number | null;
startedAt: string | null;
intervalSeconds: number | null;
};
lastSync: {
at: string | null;
ok: boolean;
/** Seconds since the last sync attempt, or null if it never ran. */
ageSeconds: number | null;
error: string | null;
};
/** A sync older than this many seconds is considered stale. */
staleAfterSeconds: number;
mirroredFiles: number;
}
export interface HealthOptions {
/** Override the stale threshold; default is max(interval × 3, 15s). */
staleAfterSeconds?: number;
/** Injectable clock (epoch ms) for testing. */
now?: number;
}
export async function computeHealth(
config: MountConfig,
opts: HealthOptions = {}
): Promise<HealthReport> {
const ds = await daemonStatus(config.dir);
const state = await loadState(config.dir, config.bankId, config.apiUrl);
const now = opts.now ?? Date.now();
const intervalSeconds = ds.record?.intervalSeconds ?? config.intervalSeconds;
const staleAfterSeconds = opts.staleAfterSeconds ?? Math.max(intervalSeconds * 3, 15);
const ageSeconds = state.lastSyncAt
? Math.max(0, Math.round((now - Date.parse(state.lastSyncAt)) / 1000))
: null;
let status: HealthStatus;
if (!ds.running) {
status = "dead";
} else if (state.lastSyncAt === null) {
status = "stale"; // up but hasn't completed a first sync yet
} else if (!state.lastSyncOk) {
status = "failed"; // looping but the API keeps erroring
} else if (ageSeconds === null || ageSeconds >= staleAfterSeconds) {
status = "stale"; // alive but wedged — no fresh sync (>= so --stale-after 0 = always stale)
} else {
status = "ok";
}
return {
healthy: status === "ok",
status,
mount: config.dir,
bank: state.bankId || config.bankId || "",
apiUrl: state.apiUrl || config.apiUrl,
mode: config.writable ? "writable" : "read-only",
daemon: {
running: ds.running,
pid: ds.pid,
startedAt: ds.record?.startedAt ?? null,
intervalSeconds: ds.record?.intervalSeconds ?? null,
},
lastSync: {
at: state.lastSyncAt,
ok: state.lastSyncOk,
ageSeconds,
error: state.lastError,
},
staleAfterSeconds,
mirroredFiles: Object.keys(state.files).length,
};
}
+24
View File
@@ -0,0 +1,24 @@
/**
* hindsight-fs — mirror a Hindsight bank's knowledge base as a live local folder.
*
* Programmatic entry point. The CLI (`hindsight-fs`) is in cli.ts.
*/
export { runSync, type SyncResult } from "./sync.js";
export { runLoop, type RunLoopOptions, type LoopLogger } from "./loop.js";
export { resolveConfig, saveConfig, type MountConfig, type ConfigOverrides } from "./config.js";
export {
HindsightFsClient,
ApiError,
type KnowledgeNode,
type KnowledgeSnapshot,
} from "./client.js";
export { startDaemon, stopDaemon, daemonStatus, type DaemonStatus } from "./daemon.js";
export {
computeHealth,
type HealthReport,
type HealthStatus,
type HealthOptions,
} from "./health.js";
export { planMirror, renderIndex, slug, type MirrorPlan, type PageFile } from "./format.js";
export { stringifyFrontmatter, parseDocument, type Frontmatter } from "./frontmatter.js";
+54
View File
@@ -0,0 +1,54 @@
/**
* The refresh loop shared by foreground `mount` and the background daemon.
* Runs an immediate sync, then repeats every `intervalSeconds` until aborted.
*/
import { runSync } from "./sync.js";
import type { MountConfig } from "./config.js";
export type LoopLogger = (message: string) => void;
export interface RunLoopOptions {
signal?: AbortSignal;
log?: LoopLogger;
}
const delay = (ms: number, signal?: AbortSignal): Promise<void> =>
new Promise((resolve) => {
if (signal?.aborted) return resolve();
const t = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(t);
resolve();
},
{ once: true }
);
});
export async function runLoop(config: MountConfig, opts: RunLoopOptions = {}): Promise<void> {
const log = opts.log ?? (() => {});
const signal = opts.signal;
log(
`mounting bank "${config.bankId}" at ${config.dir} (every ${config.intervalSeconds}s, ${config.apiUrl})`
);
while (!signal?.aborted) {
try {
const result = await runSync(config);
const reverted = result.reverted > 0 ? `, ${result.reverted} reverted` : "";
log(
`synced ${result.total} pages / ${result.folders} folders — ${result.written} updated, ` +
`${result.unchanged} unchanged, ${result.removed} removed${reverted}`
);
} catch (err) {
log(`sync failed: ${err instanceof Error ? err.message : String(err)}`);
}
if (signal?.aborted) break;
await delay(config.intervalSeconds * 1000, signal);
}
log("mount stopped");
}
@@ -0,0 +1,9 @@
/** Shared filesystem layout constants for a mount directory. */
/** Hidden control sub-directory inside every mount (config, state, daemon). */
export const CONTROL_DIR = ".hindsight-fs";
export const STATE_FILE = "state.json";
export const PID_FILE = "daemon.pid";
export const LOG_FILE = "daemon.log";
export const INDEX_FILE = "index.md";
+67
View File
@@ -0,0 +1,67 @@
/**
* Persistent sync state for a mount. Tracks which file mirrors each knowledge
* page (keyed by its relative path) and a content hash so unchanged pages are
* not rewritten (keeps mtimes stable for editors, watchers, and `ls -la`), plus
* the folder directories created, so removed folders are pruned.
*/
import { promises as fs } from "node:fs";
import { createHash } from "node:crypto";
import * as path from "node:path";
import { CONTROL_DIR, STATE_FILE } from "./paths.js";
export interface FileEntry {
/** Path relative to the mount root (e.g. "policies/billing.md"). */
file: string;
/** sha256 of the rendered document. */
hash: string;
}
export interface SyncState {
version: 1;
bankId: string;
apiUrl: string;
lastSyncAt: string | null;
lastSyncOk: boolean;
lastError: string | null;
/** Relative page path → file entry. */
files: Record<string, FileEntry>;
/** Folder directories created (relative paths), for pruning removed folders. */
dirs: string[];
}
export function emptyState(bankId: string, apiUrl: string): SyncState {
return {
version: 1,
bankId,
apiUrl,
lastSyncAt: null,
lastSyncOk: false,
lastError: null,
files: {},
dirs: [],
};
}
export function hashContent(content: string): string {
return createHash("sha256").update(content, "utf8").digest("hex");
}
function statePath(dir: string): string {
return path.join(dir, CONTROL_DIR, STATE_FILE);
}
export async function loadState(dir: string, bankId: string, apiUrl: string): Promise<SyncState> {
try {
const raw = JSON.parse(await fs.readFile(statePath(dir), "utf8")) as SyncState;
if (raw.version === 1 && raw.files) return { ...raw, dirs: raw.dirs ?? [] };
} catch {
/* fall through to empty */
}
return emptyState(bankId, apiUrl);
}
export async function saveState(dir: string, state: SyncState): Promise<void> {
await fs.mkdir(path.join(dir, CONTROL_DIR), { recursive: true });
await fs.writeFile(statePath(dir), JSON.stringify(state, null, 2) + "\n", "utf8");
}
+187
View File
@@ -0,0 +1,187 @@
/**
* The sync engine: fetch a bank's knowledge base (folder/page tree + page
* contents) and mirror it as a nested folder of markdown files in the mount
* directory — folders become directories, pages become `.md` files.
*
* The mirror is strictly one-way (API → disk). Two mechanisms enforce that:
*
* 1. Mirrored files are written read-only (mode 0444 unless `config.writable`),
* so an agent's in-place edit or editor-save fails with EACCES.
* 2. Every pass compares the *on-disk* bytes against the freshly rendered
* content, so any drift — a tampered file, a force-chmod edit, a partial
* write — is reverted on the next tick, even when the page is unchanged
* server-side.
*/
import { promises as fs } from "node:fs";
import * as path from "node:path";
import { HindsightFsClient } from "./client.js";
import { planMirror, renderIndex } from "./format.js";
import { hashContent, loadState, saveState, type SyncState } from "./state.js";
import { CONTROL_DIR, INDEX_FILE } from "./paths.js";
import type { MountConfig } from "./config.js";
export interface SyncResult {
/** Pages mirrored. */
total: number;
/** Folder directories in the mirror. */
folders: number;
/** Files (re)written because they were new, changed, or tampered with. */
written: number;
/** Files left untouched because disk already matched the API. */
unchanged: number;
/** Files removed because their page no longer exists in the bank. */
removed: number;
/** Subset of `written` that were rewritten because the on-disk copy drifted. */
reverted: number;
syncedAt: string;
}
const READONLY_MODE = 0o444;
const WRITABLE_MODE = 0o644;
/** Write `content` to `file` atomically (temp file + rename within the same dir). */
async function atomicWrite(file: string, content: string, mode: number): Promise<void> {
const tmp = `${file}.${process.pid}.tmp`;
await fs.writeFile(tmp, content, "utf8");
await fs.rename(tmp, file);
await fs.chmod(file, mode);
}
async function readFileOrNull(file: string): Promise<string | null> {
try {
return await fs.readFile(file, "utf8");
} catch {
return null;
}
}
/** Re-assert the desired permission bits (cheap; guards against a force-chmod). */
async function enforceMode(file: string, mode: number): Promise<void> {
try {
await fs.chmod(file, mode);
} catch {
/* file vanished between write and chmod — next tick recreates it */
}
}
/**
* Perform a single sync pass.
*
* Pruning of files/folders for removed pages happens only after a successful
* fetch, so a transient API/network error never wipes the existing mirror.
*/
export async function runSync(config: MountConfig): Promise<SyncResult> {
const syncedAt = new Date().toISOString();
const mode = config.writable ? WRITABLE_MODE : READONLY_MODE;
await fs.mkdir(path.join(config.dir, CONTROL_DIR), { recursive: true });
const state = await loadState(config.dir, config.bankId, config.apiUrl);
const client = new HindsightFsClient({ apiUrl: config.apiUrl, apiToken: config.apiToken });
let snapshot;
try {
snapshot = await client.loadKnowledge(config.bankId);
} catch (err) {
state.lastSyncAt = syncedAt;
state.lastSyncOk = false;
state.lastError = err instanceof Error ? err.message : String(err);
await saveState(config.dir, state);
throw err;
}
const plan = planMirror(snapshot);
// Create folder directories first (parents before children — plan.dirs is in
// tree order). The control dir is excluded by the .hindsight-fs prefix.
for (const dir of plan.dirs) {
await fs.mkdir(path.join(config.dir, dir), { recursive: true });
}
let written = 0;
let unchanged = 0;
let reverted = 0;
const nextFiles: SyncState["files"] = {};
for (const page of plan.files) {
const hash = hashContent(page.content);
const prev = state.files[page.relPath];
const absPath = path.join(config.dir, page.relPath);
await fs.mkdir(path.dirname(absPath), { recursive: true });
// Source of truth is the bytes on disk — so a local edit is detected and
// overwritten even when the page is identical to last time.
const onDisk = await readFileOrNull(absPath);
if (onDisk === null || onDisk !== page.content) {
await atomicWrite(absPath, page.content, mode);
written++;
if (onDisk !== null && prev && prev.hash === hash) reverted++;
} else {
await enforceMode(absPath, mode);
unchanged++;
}
nextFiles[page.relPath] = { file: page.relPath, hash };
}
// Prune files whose pages no longer exist.
let removed = 0;
for (const [rel, entry] of Object.entries(state.files)) {
if (!nextFiles[rel]) {
await safeUnlink(path.join(config.dir, entry.file));
removed++;
}
}
// Prune folder directories that no longer exist (deepest first so they empty
// out before removal); only remove ones we created and that are now gone.
const liveDirs = new Set(plan.dirs);
const goneDirs = state.dirs.filter((d) => !liveDirs.has(d)).sort((a, b) => b.length - a.length);
for (const dir of goneDirs) {
await safeRmdir(path.join(config.dir, dir));
}
const newState: SyncState = {
version: 1,
bankId: config.bankId,
apiUrl: config.apiUrl,
lastSyncAt: syncedAt,
lastSyncOk: true,
lastError: null,
files: nextFiles,
dirs: plan.dirs,
};
await saveState(config.dir, newState);
await atomicWrite(
path.join(config.dir, CONTROL_DIR, INDEX_FILE),
renderIndex(snapshot, config),
mode
);
return {
total: plan.pageCount,
folders: plan.folderCount,
written,
unchanged,
removed,
reverted,
syncedAt,
};
}
async function safeUnlink(p: string): Promise<void> {
try {
await fs.chmod(p, WRITABLE_MODE).catch(() => {});
await fs.unlink(p);
} catch {
/* already gone */
}
}
async function safeRmdir(p: string): Promise<void> {
try {
await fs.rmdir(p);
} catch {
/* non-empty (has unmirrored files) or already gone — leave it */
}
}
@@ -0,0 +1,318 @@
/**
* End-to-end integration tests.
*
* These spawn the *real* compiled CLI (`dist/cli.js`) against a *real* (mock)
* HTTP server, and exercise the mirror with *real* bash commands — ls, cat,
* grep, find, wc, head, stat — exactly how an agent or a human would use it.
*
* `pretest` builds dist/ before vitest runs, so the binary exists.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import { createServer, type Server } from "node:http";
import { execFile } from "node:child_process";
import { promises as fs } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { KnowledgeNode } from "../src/client.js";
const CLI = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../dist/cli.js");
const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
// ── Mock Hindsight API (knowledge-base tree + export) ──
/** Mutable bank contents; tests reassign these to simulate API changes. */
let bankTree: KnowledgeNode[] = [];
let bankContent: Record<string, string> = {};
let server: Server;
let baseUrl: string;
beforeAll(async () => {
server = createServer((req, res) => {
res.setHeader("content-type", "application/json");
if (req.url && req.url.includes("/knowledge-base/tree")) {
res.end(JSON.stringify({ roots: bankTree }));
} else if (req.url && req.url.includes("/knowledge-base/export")) {
const files = Object.entries(bankContent).map(([id, content]) => ({
path: `${id}.md`,
content,
}));
res.end(JSON.stringify({ files }));
} else {
res.statusCode = 404;
res.end("{}");
}
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const addr = server.address();
if (addr && typeof addr === "object") baseUrl = `http://127.0.0.1:${addr.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
// ── Process helpers ────────────────────────────────────
interface RunResult {
code: number;
stdout: string;
stderr: string;
}
/** Run a command, always resolving with the exit code (never throws on non-zero). */
function run(cmd: string, args: string[], cwd?: string): Promise<RunResult> {
const env = { ...process.env };
for (const k of Object.keys(env)) if (k.startsWith("HINDSIGHT_")) delete env[k];
return new Promise((resolve) => {
execFile(cmd, args, { cwd, env }, (err, stdout, stderr) => {
const code =
err && typeof (err as { code?: unknown }).code === "number"
? (err as { code: number }).code
: err
? 1
: 0;
resolve({ code, stdout: stdout.toString(), stderr: stderr.toString() });
});
});
}
function cli(args: string[]): Promise<RunResult> {
return run("node", [CLI, ...args]);
}
function sh(command: string, cwd?: string): Promise<RunResult> {
return run("bash", ["-c", command], cwd);
}
async function waitFor(
predicate: () => Promise<boolean>,
timeoutMs = 8000,
stepMs = 150
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await predicate()) return true;
await new Promise((r) => setTimeout(r, stepMs));
}
return false;
}
async function fileExists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
// ── Fixtures ───────────────────────────────────────────
// A small knowledge base: a "Profile" folder holding one page, plus a root page.
function sampleTree(): KnowledgeNode[] {
return [
{
id: "profile",
kind: "folder",
name: "Profile",
parent_id: null,
mission: "Everything about the user",
children: [
{
id: "user-preferences",
kind: "page",
name: "User Preferences",
parent_id: "profile",
children: [],
},
],
},
{ id: "project-status", kind: "page", name: "Project Status", parent_id: null, children: [] },
];
}
function sampleContent(): Record<string, string> {
return {
"user-preferences":
"---\nid: user-preferences\ntype: knowledge-page\ntitle: User Preferences\n---\n\n" +
"The user prefers dark mode and async, written communication.\n",
"project-status":
"---\nid: project-status\ntitle: Project Status\n---\n\n" +
"Phase 2 is in progress; the API freeze is next week.\n",
};
}
let dir: string;
const startedDirs = new Set<string>();
const PREFS = path.join("profile", "user-preferences.md");
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "hsfs-e2e-"));
bankTree = sampleTree();
bankContent = sampleContent();
});
afterEach(async () => {
for (const d of startedDirs) await cli(["stop", d]).catch(() => {});
startedDirs.clear();
await fs.rm(dir, { recursive: true, force: true });
});
// ── Tests ──────────────────────────────────────────────
describe("e2e: real CLI + real bash", () => {
it("mirrors the tree and the nested files work with ls / cat / grep / find / wc / head", async () => {
const synced = await cli(["sync", dir, "--bank", "demo", "--api-url", baseUrl]);
expect(synced.code).toBe(0);
expect(synced.stdout).toContain("2 pages / 1 folders");
// Folder became a directory; pages are at their nested paths.
expect(await fileExists(path.join(dir, PREFS))).toBe(true);
expect(await fileExists(path.join(dir, "project-status.md"))).toBe(true);
// cat — frontmatter + body are real file content
const cat = await sh(`cat "${path.join(dir, PREFS)}"`);
expect(cat.stdout).toContain("id: user-preferences");
expect(cat.stdout).toContain("dark mode and async");
// grep -rl — content is searchable across the tree
const grep = await sh(`grep -rl "API freeze" "${dir}"`);
expect(grep.code).toBe(0);
expect(grep.stdout.trim()).toBe(path.join(dir, "project-status.md"));
// find — two page files (excluding the hidden control dir)
const find = await sh(
`find "${dir}" -name '*.md' -not -path '*/.hindsight-fs/*' | wc -l | tr -d ' '`
);
expect(find.stdout.trim()).toBe("2");
// head + wc — ordinary text tooling
const head = await sh(`head -1 "${path.join(dir, "project-status.md")}"`);
expect(head.stdout.trim()).toBe("---");
const wc = await sh(`wc -l < "${path.join(dir, PREFS)}" | tr -d ' '`);
expect(Number(wc.stdout.trim())).toBeGreaterThan(4);
});
it("blocks an agent's write with read-only files", async () => {
await cli(["sync", dir, "--bank", "demo", "--api-url", baseUrl]);
const file = path.join(dir, PREFS);
const stat = await sh(`ls -l "${file}" | cut -c1-10`);
expect(stat.stdout.trim()).toBe("-r--r--r--");
if (!isRoot) {
const write = await sh(`echo "AGENT EDIT" >> "${file}"`);
expect(write.code).not.toBe(0);
expect(write.stderr.toLowerCase()).toContain("permission denied");
const after = await sh(`grep -c "AGENT EDIT" "${file}" || true`);
expect(after.stdout.trim()).toBe("0");
}
});
it("reverts a force-edited file on the next sync", async () => {
await cli(["sync", dir, "--bank", "demo", "--api-url", baseUrl]);
const file = path.join(dir, PREFS);
await sh(`chmod u+w "${file}" && echo "HIJACKED" > "${file}"`);
expect((await sh(`cat "${file}"`)).stdout.trim()).toBe("HIJACKED");
const resync = await cli(["sync", dir]);
expect(resync.stdout).toContain("1 reverted");
expect((await sh(`cat "${file}"`)).stdout).toContain("dark mode and async");
expect((await sh(`ls -l "${file}" | cut -c1-10`)).stdout.trim()).toBe("-r--r--r--");
});
it("prunes a file (and emptied folder) when its page is deleted from the bank", async () => {
await cli(["sync", dir, "--bank", "demo", "--api-url", baseUrl]);
expect(await fileExists(path.join(dir, PREFS))).toBe(true);
// Remove the user-preferences page (and its folder) from the bank.
bankTree = [
{ id: "project-status", kind: "page", name: "Project Status", parent_id: null, children: [] },
];
delete bankContent["user-preferences"];
const resync = await cli(["sync", dir]);
expect(resync.stdout).toContain("1 removed");
expect((await sh(`test -f "${path.join(dir, PREFS)}"; echo $?`)).stdout.trim()).toBe("1");
expect((await sh(`test -d "${path.join(dir, "profile")}"; echo $?`)).stdout.trim()).toBe("1");
expect(
(await sh(`test -f "${path.join(dir, "project-status.md")}"; echo $?`)).stdout.trim()
).toBe("0");
});
it("runs as a background daemon and refreshes files on an interval", async () => {
const start = await cli([
"start",
dir,
"--bank",
"demo",
"--api-url",
baseUrl,
"--interval",
"1",
]);
startedDirs.add(dir);
expect(start.code).toBe(0);
expect(start.stdout).toContain("in background");
const appeared = await waitFor(() => fileExists(path.join(dir, PREFS)));
expect(appeared).toBe(true);
const healthy = await waitFor(async () => (await cli(["status", dir, "--json"])).code === 0);
expect(healthy).toBe(true);
const report = JSON.parse((await cli(["status", dir, "--json"])).stdout);
expect(report.status).toBe("ok");
expect(report.daemon.running).toBe(true);
// Change the bank server-side → the daemon picks it up within an interval.
bankContent["user-preferences"] =
"---\nid: user-preferences\n---\n\nThe user now prefers LIGHT mode.\n";
const picked = await waitFor(
async () =>
(await sh(`grep -c "LIGHT mode" "${path.join(dir, PREFS)}" || true`)).stdout.trim() === "1"
);
expect(picked).toBe(true);
const stop = await cli(["stop", dir]);
startedDirs.delete(dir);
expect(stop.code).toBe(0);
const dead = await cli(["status", dir, "--json"]);
expect(dead.code).toBe(1);
expect(JSON.parse(dead.stdout).status).toBe("dead");
}, 20000);
it("status exit codes: dead / stale / ok drive the exit code", async () => {
const never = await cli(["status", dir, "--bank", "demo", "--api-url", baseUrl, "--json"]);
expect(never.code).toBe(1);
expect(JSON.parse(never.stdout).status).toBe("dead");
await cli(["start", dir, "--bank", "demo", "--api-url", baseUrl, "--interval", "1"]);
startedDirs.add(dir);
await waitFor(() => fileExists(path.join(dir, PREFS)));
const ok = await waitFor(async () => (await cli(["status", dir, "--json"])).code === 0);
expect(ok).toBe(true);
const stale = await cli(["status", dir, "--stale-after", "0", "--json"]);
expect(stale.code).toBe(1);
expect(JSON.parse(stale.stdout).status).toBe("stale");
}, 20000);
it("list prints folders + pages without writing any files", async () => {
const list = await cli(["list", "--bank", "demo", "--api-url", baseUrl, "--dir", dir]);
expect(list.code).toBe(0);
expect(list.stdout).toContain("profile/");
expect(list.stdout).toContain("profile/user-preferences.md");
expect(list.stdout).toContain("project-status.md");
// No files were written to the mount.
const count = await sh(
`find "${dir}" -name '*.md' -not -path '*/.hindsight-fs/*' 2>/dev/null | wc -l | tr -d ' '`
);
expect(count.stdout.trim()).toBe("0");
});
});
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import { stringifyFrontmatter, parseDocument, type Frontmatter } from "../src/frontmatter.js";
import { slug, planMirror } from "../src/format.js";
import type { KnowledgeSnapshot } from "../src/client.js";
describe("frontmatter", () => {
it("round-trips scalars and arrays", () => {
const data: Frontmatter = {
id: "user-preferences",
name: "User Preferences",
tags: ["team", "ui"],
empty: [],
flag: true,
n: 7,
missing: null,
};
const text = `${stringifyFrontmatter(data)}\n\nbody here\n`;
const parsed = parseDocument(text);
expect(parsed.body.trim()).toBe("body here");
expect(parsed.frontmatter.id).toBe("user-preferences");
expect(parsed.frontmatter.name).toBe("User Preferences");
expect(parsed.frontmatter.tags).toEqual(["team", "ui"]);
expect(parsed.frontmatter.empty).toEqual([]);
expect(parsed.frontmatter.flag).toBe(true);
expect(parsed.frontmatter.n).toBe(7);
expect(parsed.frontmatter.missing).toBeNull();
});
it("quotes strings containing YAML-significant characters", () => {
const text = stringifyFrontmatter({ q: "What are the user's needs: now?" });
expect(text).toContain('q: "What are the user');
const parsed = parseDocument(`${text}\n\nx\n`);
expect(parsed.frontmatter.q).toBe("What are the user's needs: now?");
});
it("treats a document with no frontmatter as pure body", () => {
const parsed = parseDocument("# just markdown\n");
expect(parsed.frontmatter).toEqual({});
expect(parsed.body).toBe("# just markdown\n");
});
});
describe("slug", () => {
it("produces safe path segments", () => {
expect(slug("user-preferences")).toBe("user-preferences");
expect(slug("Weird Name!!")).toBe("weird-name");
expect(slug("Billing Policy")).toBe("billing-policy");
expect(slug("")).toBe("untitled");
});
});
describe("planMirror", () => {
it("nests pages under folder dirs and uses the page's OKF content", () => {
const snapshot: KnowledgeSnapshot = {
roots: [
{
id: "f1",
kind: "folder",
name: "Policies",
parent_id: null,
children: [{ id: "p1", kind: "page", name: "Billing", parent_id: "f1", children: [] }],
},
{ id: "p2", kind: "page", name: "Glossary", parent_id: null, children: [] },
],
content: new Map([["p1", "# Billing\n\nNet-30.\n"]]), // p2 intentionally has no content
};
const plan = planMirror(snapshot);
expect(plan.folderCount).toBe(1);
expect(plan.pageCount).toBe(2);
expect(plan.dirs).toEqual(["policies"]);
const billing = plan.files.find((f) => f.relPath === "policies/billing.md");
expect(billing?.content).toContain("Net-30.");
const glossary = plan.files.find((f) => f.relPath === "glossary.md");
expect(glossary?.content).toContain("has not been generated yet"); // placeholder
});
});
@@ -0,0 +1,103 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { promises as fs } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { computeHealth } from "../src/health.js";
import { saveState, emptyState } from "../src/state.js";
import type { MountConfig } from "../src/config.js";
let dir: string;
function config(): MountConfig {
return {
dir,
apiUrl: "http://localhost:8000",
bankId: "acme",
intervalSeconds: 10,
writable: false,
};
}
/** Write a daemon pidfile pointing at a live (or dead) process. */
async function writePid(pid: number, intervalSeconds = 10): Promise<void> {
await fs.mkdir(path.join(dir, ".hindsight-fs"), { recursive: true });
await fs.writeFile(
path.join(dir, ".hindsight-fs", "daemon.pid"),
JSON.stringify({ pid, bankId: "acme", intervalSeconds, startedAt: "2026-06-26T00:00:00Z" }),
"utf8"
);
}
async function writeLastSync(
at: string | null,
ok: boolean,
error: string | null = null
): Promise<void> {
const state = emptyState("acme", "http://localhost:8000");
state.lastSyncAt = at;
state.lastSyncOk = ok;
state.lastError = error;
state.files = { a: { file: "a.md", hash: "x" } };
await saveState(dir, state);
}
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "hsfs-health-"));
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
describe("computeHealth", () => {
it("reports dead + unhealthy when no daemon is running", async () => {
await writeLastSync("2026-06-26T10:00:00Z", true);
const report = await computeHealth(config(), { now: Date.parse("2026-06-26T10:00:05Z") });
expect(report.status).toBe("dead");
expect(report.healthy).toBe(false);
expect(report.daemon.running).toBe(false);
});
it("reports ok when daemon alive and sync is recent", async () => {
await writePid(process.pid);
await writeLastSync("2026-06-26T10:00:00Z", true);
const report = await computeHealth(config(), { now: Date.parse("2026-06-26T10:00:05Z") });
expect(report.status).toBe("ok");
expect(report.healthy).toBe(true);
expect(report.lastSync.ageSeconds).toBe(5);
});
it("reports stale when the last sync is older than the threshold", async () => {
await writePid(process.pid, 10); // staleAfter = max(30, 15) = 30s
await writeLastSync("2026-06-26T10:00:00Z", true);
const report = await computeHealth(config(), { now: Date.parse("2026-06-26T10:01:00Z") }); // 60s
expect(report.status).toBe("stale");
expect(report.healthy).toBe(false);
});
it("reports failed when the daemon is alive but the last sync errored", async () => {
await writePid(process.pid);
await writeLastSync("2026-06-26T10:00:00Z", false, "HTTP 500");
const report = await computeHealth(config(), { now: Date.parse("2026-06-26T10:00:05Z") });
expect(report.status).toBe("failed");
expect(report.healthy).toBe(false);
expect(report.lastSync.error).toBe("HTTP 500");
});
it("reports stale when the daemon is up but has never synced", async () => {
await writePid(process.pid);
await writeLastSync(null, false);
const report = await computeHealth(config(), { now: Date.parse("2026-06-26T10:00:05Z") });
expect(report.status).toBe("stale");
expect(report.lastSync.ageSeconds).toBeNull();
});
it("honors an explicit stale-after override", async () => {
await writePid(process.pid);
await writeLastSync("2026-06-26T10:00:00Z", true);
const report = await computeHealth(config(), {
now: Date.parse("2026-06-26T10:00:20Z"), // 20s old
staleAfterSeconds: 10,
});
expect(report.status).toBe("stale");
});
});
@@ -0,0 +1,187 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { promises as fs } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { runSync } from "../src/sync.js";
import { parseDocument } from "../src/frontmatter.js";
import type { KnowledgeNode } from "../src/client.js";
import type { MountConfig } from "../src/config.js";
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
function jsonResp(obj: unknown) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(obj),
text: () => Promise.resolve(JSON.stringify(obj)),
});
}
/** Route the two knowledge-base fetches (tree + export) for a sync pass. */
function setKb(roots: KnowledgeNode[], content: Record<string, string>) {
mockFetch.mockImplementation((url: string) => {
if (url.includes("/knowledge-base/tree")) return jsonResp({ roots });
if (url.includes("/knowledge-base/export")) {
const files = Object.entries(content).map(([id, c]) => ({ path: `${id}.md`, content: c }));
return jsonResp({ files });
}
return jsonResp({});
});
}
function page(id: string, name: string, parent_id: string | null = null): KnowledgeNode {
return { id, kind: "page", name, parent_id, children: [] };
}
function folder(
id: string,
name: string,
children: KnowledgeNode[],
parent_id: string | null = null
): KnowledgeNode {
return { id, kind: "folder", name, parent_id, mission: `mission ${name}`, children };
}
let dir: string;
function config(overrides: Partial<MountConfig> = {}): MountConfig {
return {
dir,
apiUrl: "http://localhost:8000",
bankId: "acme",
intervalSeconds: 30,
writable: false,
...overrides,
};
}
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "hsfs-test-"));
mockFetch.mockReset();
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
describe("runSync", () => {
it("mirrors the folder/page tree as nested directories + .md files", async () => {
setKb([folder("f1", "Policies", [page("p1", "Billing", "f1")]), page("p2", "Glossary")], {
p1: "---\nid: p1\ntitle: Billing\n---\n\nNet-30.\n",
p2: "---\nid: p2\n---\n\nTerms.\n",
});
const result = await runSync(config());
expect(result.total).toBe(2);
expect(result.folders).toBe(1);
const billing = await fs.readFile(path.join(dir, "policies", "billing.md"), "utf8");
expect(parseDocument(billing).body.trim()).toBe("Net-30.");
expect(await exists(path.join(dir, "glossary.md"))).toBe(true);
const index = await fs.readFile(path.join(dir, ".hindsight-fs", "index.md"), "utf8");
expect(index).toContain("**Policies/**");
expect(index).toContain("policies/billing.md");
});
it("does not rewrite unchanged files but updates changed ones", async () => {
setKb([page("prefs", "Preferences")], { prefs: "v1\n" });
await runSync(config());
const firstStat = await fs.stat(path.join(dir, "preferences.md"));
setKb([page("prefs", "Preferences")], { prefs: "v1\n" });
const second = await runSync(config());
expect(second.unchanged).toBe(1);
expect(second.written).toBe(0);
expect((await fs.stat(path.join(dir, "preferences.md"))).mtimeMs).toBe(firstStat.mtimeMs);
setKb([page("prefs", "Preferences")], { prefs: "v2\n" });
const third = await runSync(config());
expect(third.written).toBe(1);
expect((await fs.readFile(path.join(dir, "preferences.md"), "utf8")).trim()).toBe("v2");
});
it("prunes a removed page within a folder, then prunes the folder when emptied", async () => {
setKb([folder("f", "Stuff", [page("a", "A", "f"), page("b", "B", "f")])], {
a: "a\n",
b: "b\n",
});
await runSync(config());
expect(await exists(path.join(dir, "stuff", "b.md"))).toBe(true);
// Remove b but keep a in the folder → exactly one file pruned; folder stays.
setKb([folder("f", "Stuff", [page("a", "A", "f")])], { a: "a\n" });
const r2 = await runSync(config());
expect(r2.removed).toBe(1);
expect(await exists(path.join(dir, "stuff", "b.md"))).toBe(false);
expect(await exists(path.join(dir, "stuff", "a.md"))).toBe(true);
// Remove the folder entirely (move a to the root) → the folder dir is pruned.
setKb([page("a", "A")], { a: "a\n" });
await runSync(config());
expect(await exists(path.join(dir, "stuff"))).toBe(false);
expect(await exists(path.join(dir, "a.md"))).toBe(true);
});
it("does not wipe the mirror on a fetch error", async () => {
setKb([page("a", "A")], { a: "a\n" });
await runSync(config());
mockFetch.mockImplementation(() =>
Promise.resolve({
ok: false,
status: 500,
json: () => Promise.resolve({}),
text: () => Promise.resolve("boom"),
})
);
await expect(runSync(config())).rejects.toThrow();
expect(await exists(path.join(dir, "a.md"))).toBe(true);
});
it("recreates a file the user deleted even if the page is unchanged", async () => {
setKb([page("a", "A")], { a: "a\n" });
await runSync(config());
await fs.unlink(path.join(dir, "a.md"));
setKb([page("a", "A")], { a: "a\n" });
const result = await runSync(config());
expect(result.written).toBe(1);
expect(await exists(path.join(dir, "a.md"))).toBe(true);
});
it("writes read-only files by default and editable files with writable", async () => {
setKb([page("a", "A")], { a: "a\n" });
await runSync(config());
expect((await fs.stat(path.join(dir, "a.md"))).mode & 0o222).toBe(0);
setKb([page("a", "A")], { a: "a\n" });
await runSync(config({ writable: true }));
expect((await fs.stat(path.join(dir, "a.md"))).mode & 0o200).toBe(0o200);
});
it("reverts a tampered file even when the page is unchanged server-side", async () => {
setKb([page("a", "A")], { a: "original\n" });
await runSync(config());
const file = path.join(dir, "a.md");
await fs.chmod(file, 0o644);
await fs.writeFile(file, "HIJACKED", "utf8");
setKb([page("a", "A")], { a: "original\n" });
const result = await runSync(config());
expect(result.reverted).toBe(1);
expect(result.written).toBe(1);
expect((await fs.readFile(file, "utf8")).trim()).toBe("original");
expect((await fs.stat(file)).mode & 0o222).toBe(0);
});
});
async function exists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"types": ["node"],
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
+33 -1
View File
@@ -10,7 +10,8 @@
"hindsight-control-plane",
"hindsight-docs",
"hindsight-all-npm",
"hindsight-tools/hindsight-agent-sdk"
"hindsight-tools/hindsight-agent-sdk",
"hindsight-tools/hindsight-fs"
]
},
"hindsight-all-npm": {
@@ -817,6 +818,33 @@
"integrity": "sha512-bymmlMWI1z0zOjgY+wRMLudNxzqcW20VHMtyV3QLhwJm63NeQN/nEZ4plWPR0p28DffaUM5nk2VSxzQljN+Mow==",
"license": "MIT"
},
"hindsight-tools/hindsight-fs": {
"name": "@vectorize-io/hindsight-fs",
"version": "0.1.0",
"license": "MIT",
"bin": {
"hindsight-fs": "dist/cli.js",
"hsfs": "dist/cli.js"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.4",
"vitest": "^4.1.2"
},
"engines": {
"node": ">=18"
}
},
"hindsight-tools/hindsight-fs/node_modules/@types/node": {
"version": "22.20.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
"integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"hindsight-tools/self-driving-agents": {
"name": "@vectorize-io/self-driving-agents",
"version": "0.0.6",
@@ -12091,6 +12119,10 @@
"resolved": "hindsight-control-plane",
"link": true
},
"node_modules/@vectorize-io/hindsight-fs": {
"resolved": "hindsight-tools/hindsight-fs",
"link": true
},
"node_modules/@vitest/expect": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
+2 -1
View File
@@ -6,7 +6,8 @@
"hindsight-control-plane",
"hindsight-docs",
"hindsight-all-npm",
"hindsight-tools/hindsight-agent-sdk"
"hindsight-tools/hindsight-agent-sdk",
"hindsight-tools/hindsight-fs"
],
"scripts": {
"prepare": "./scripts/setup-hooks.sh"
File diff suppressed because it is too large Load Diff