Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0022d427d3 | ||
|
|
1f9bad0858 | ||
|
|
138bf02f29 | ||
|
|
df178aae8a | ||
|
|
a43026b8f4 | ||
|
|
5c425e276e |
@@ -56,6 +56,7 @@ BACKUP_TABLES = [
|
||||
"observation_history",
|
||||
"mental_models",
|
||||
"mental_model_history",
|
||||
"knowledge_pages",
|
||||
"directives",
|
||||
"async_operations",
|
||||
"webhooks",
|
||||
|
||||
+52
@@ -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)
|
||||
+110
@@ -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)
|
||||
+71
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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
|
||||
@@ -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(), [])
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
+96
@@ -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
|
||||
|
||||
|
||||
+95
@@ -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">
|
||||
“{selected.description}”
|
||||
</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 },
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
+1533
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,
|
||||
});
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Generated
+33
-1
@@ -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
@@ -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
Reference in New Issue
Block a user