Compare commits

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

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

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

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

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

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

- knowledge_pages table (PG + Oracle): parent_id tree, kind folder/page,
  mission, managed, last_curated_at; partial unique index on (folder, name)
  for concurrency-safe dedup; added to BACKUP_TABLES.
- api/okf.py: OKF serializer (frontmatter + body, index/log, constellation graph).
- engine/knowledge_curator.py: folder curator (LLM op plan + safe apply); reads
  new memories since last curation (delta, not recall); ops create/merge/delete
  page + spawn sub-folder (bounded depth<=3, <=8). Runs as an async curate_folder
  task on folder/mission create and after consolidation. Curator pages use an
  observation-only delta trigger with exclude_mental_models.
- MemoryEngine: folder/page CRUD, tree, curate, async submit + worker handler.
- /v1/default/banks/{bank}/knowledge-base/* endpoints.
- Control plane: knowledge-base tree view + constellation toggle, missions,
  OKF page panel + bundle export; proxies, client, sidebar, i18n.
- Tests: okf unit, knowledge-base HTTP, curator apply + dedup guard, hs_llm_core e2e.
- Regenerated OpenAPI + SDK clients + docs-skill.
2026-07-02 10:30:39 +02:00
171 changed files with 20371 additions and 6562 deletions
-5
View File
@@ -11,11 +11,6 @@
"name": "hindsight-memory",
"description": "Automatic long-term memory for Claude Code via Hindsight",
"source": "./hindsight-integrations/claude-code"
},
{
"name": "hindsight-zcode",
"description": "No-MCP long-term memory for ZCode via Hindsight hooks",
"source": "./hindsight-integrations/zcode"
}
]
}
-41
View File
@@ -41,7 +41,6 @@ jobs:
integrations-github-copilot: ${{ steps.filter.outputs.integrations-github-copilot }}
integrations-continue: ${{ steps.filter.outputs.integrations-continue }}
integrations-cursor-cli: ${{ steps.filter.outputs.integrations-cursor-cli }}
integrations-zcode: ${{ steps.filter.outputs.integrations-zcode }}
integrations-crewai: ${{ steps.filter.outputs.integrations-crewai }}
integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }}
integrations-pydantic-ai: ${{ steps.filter.outputs.integrations-pydantic-ai }}
@@ -181,8 +180,6 @@ jobs:
- 'hindsight-integrations/cursor/**'
integrations-zed:
- 'hindsight-integrations/zed/**'
integrations-zcode:
- 'hindsight-integrations/zcode/**'
integrations-n8n:
- 'hindsight-integrations/n8n/**'
integrations-zapier:
@@ -705,43 +702,6 @@ jobs:
working-directory: ./hindsight-integrations/cursor-cli
run: uv run pytest tests -v
test-zcode-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-zcode == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build zcode integration
working-directory: ./hindsight-integrations/zcode
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/zcode
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/zcode
run: uv run pytest tests -v
build-ai-sdk-integration:
needs: [detect-changes]
if: >-
@@ -4949,7 +4909,6 @@ jobs:
- test-github-copilot-integration
- test-codex-integration
- test-cursor-cli-integration
- test-zcode-integration
- build-ai-sdk-integration
- test-ai-sdk-integration-deno
- test-opencode-integration
@@ -56,6 +56,7 @@ BACKUP_TABLES = [
"observation_history",
"mental_models",
"mental_model_history",
"knowledge_pages",
"directives",
"async_operations",
"webhooks",
@@ -0,0 +1,52 @@
"""Add managed flag to knowledge_pages.
The knowledge base is managed by clients (CRUD over folders/pages). ``managed``
lets a client tag a node as system-owned vs. hand-authored; it carries no
server-side behaviour.
Revision ID: a5b6c7d8e9f0
Revises: a9b8c7d6e5f4
Create Date: 2026-06-26
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a5b6c7d8e9f0"
down_revision: str | Sequence[str] | None = "a9b8c7d6e5f4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}knowledge_pages ADD COLUMN IF NOT EXISTS managed BOOLEAN NOT NULL DEFAULT false")
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}knowledge_pages DROP COLUMN IF EXISTS managed")
def _oracle_upgrade() -> None:
op.execute("ALTER TABLE knowledge_pages ADD (managed NUMBER(1) DEFAULT 0 NOT NULL)")
def _oracle_downgrade() -> None:
op.execute("ALTER TABLE knowledge_pages DROP COLUMN managed")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -0,0 +1,110 @@
"""Add knowledge_pages table (knowledge-base hierarchy).
The knowledge base organizes synthesized mental models into a navigable tree of
**folders** and **pages**. A page references the mental model that holds its
content (``mental_model_id``); a folder is a pure container (``mental_model_id``
NULL). Hierarchy is a single self-referential ``parent_id`` so folders can nest
arbitrarily. Content stays in ``mental_models`` — this table is metadata + tree
structure only.
Revision ID: a9b8c7d6e5f4
Revises: b57a7c9e0d13
Create Date: 2026-06-25
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a9b8c7d6e5f4"
down_revision: str | Sequence[str] | None = "b57a7c9e0d13"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# parent_id self-FK cascades so deleting a folder row removes its whole
# subtree of rows in one shot. The mental_model FK is composite (matches the
# mental_models (id, bank_id) PK) and cascades too, so deleting a page's
# mental model removes the page row — folders skip the FK because a NULL
# column in a composite FK is not enforced (MATCH SIMPLE).
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}knowledge_pages (
id VARCHAR(64) NOT NULL,
bank_id TEXT NOT NULL,
parent_id VARCHAR(64),
kind VARCHAR(16) NOT NULL,
name TEXT NOT NULL,
mental_model_id VARCHAR(64),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT pk_knowledge_pages PRIMARY KEY (id),
CONSTRAINT ck_knowledge_pages_kind CHECK (kind IN ('folder', 'page')),
CONSTRAINT fk_kp_bank FOREIGN KEY (bank_id)
REFERENCES {schema}banks(bank_id) ON DELETE CASCADE,
CONSTRAINT fk_kp_parent FOREIGN KEY (parent_id)
REFERENCES {schema}knowledge_pages(id) ON DELETE CASCADE,
CONSTRAINT fk_kp_mm FOREIGN KEY (mental_model_id, bank_id)
REFERENCES {schema}mental_models(id, bank_id) ON DELETE CASCADE
)
"""
)
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_kp_bank_parent ON {schema}knowledge_pages (bank_id, parent_id, sort_order)"
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_kp_bank_parent")
op.execute(f"DROP TABLE IF EXISTS {schema}knowledge_pages")
def _oracle_upgrade() -> None:
op.execute(
"""
CREATE TABLE IF NOT EXISTS knowledge_pages (
id VARCHAR2(64) NOT NULL,
bank_id VARCHAR2(256) NOT NULL,
parent_id VARCHAR2(64),
kind VARCHAR2(16) NOT NULL,
name CLOB NOT NULL,
mental_model_id VARCHAR2(64),
sort_order NUMBER DEFAULT 0 NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_knowledge_pages PRIMARY KEY (id),
CONSTRAINT ck_knowledge_pages_kind CHECK (kind IN ('folder', 'page')),
CONSTRAINT fk_kp_bank FOREIGN KEY (bank_id)
REFERENCES banks(bank_id) ON DELETE CASCADE,
CONSTRAINT fk_kp_parent FOREIGN KEY (parent_id)
REFERENCES knowledge_pages(id) ON DELETE CASCADE,
CONSTRAINT fk_kp_mm FOREIGN KEY (mental_model_id, bank_id)
REFERENCES mental_models(id, bank_id) ON DELETE CASCADE
)
"""
)
op.execute("CREATE INDEX idx_kp_bank_parent ON knowledge_pages (bank_id, parent_id, sort_order)")
def _oracle_downgrade() -> None:
op.execute("DROP TABLE knowledge_pages CASCADE CONSTRAINTS")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -0,0 +1,71 @@
"""Unique page name per folder in knowledge_pages.
The folder curator can fire concurrently (folder-create trigger + the
post-consolidation sweep), and an in-process lock can't serialize runs that
execute in different threads/loops. A partial unique index on
(bank_id, parent, lower(name)) for pages makes duplicate-named pages in the same
folder impossible at the DB level — the second concurrent insert fails and the
curator treats it as "already exists".
PostgreSQL only: the Oracle ``name`` column is a CLOB and cannot back a
functional unique index; Oracle relies on the in-process serialization instead.
Revision ID: c3d4e5f6a7b8
Revises: a5b6c7d8e9f0
Create Date: 2026-06-26
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c3d4e5f6a7b8"
down_revision: str | Sequence[str] | None = "a5b6c7d8e9f0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# First drop any pre-existing duplicate pages (created by the racy curator
# before this guard existed), keeping the earliest row of each duplicate set,
# so the unique index can be built. Their backing mental models are left in
# place (harmless orphans).
op.execute(
f"""
DELETE FROM {schema}knowledge_pages a
USING {schema}knowledge_pages b
WHERE a.kind = 'page' AND b.kind = 'page'
AND a.bank_id = b.bank_id
AND COALESCE(a.parent_id, '') = COALESCE(b.parent_id, '')
AND lower(a.name) = lower(b.name)
AND a.ctid > b.ctid
"""
)
# COALESCE(parent_id, '') so root-level pages (NULL parent) are also unique by
# name — NULLs would otherwise compare distinct and allow duplicates.
op.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_kp_folder_pagename "
f"ON {schema}knowledge_pages (bank_id, COALESCE(parent_id, ''), lower(name)) "
"WHERE kind = 'page'"
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}uq_kp_folder_pagename")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade) # oracle slot intentionally absent (CLOB name)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,96 +0,0 @@
"""Drop the search_vector column from the curation archive (invalidated_memory_units).
The archive is cold storage, never a recall surface, and carries no text-search
index. Like ``embedding`` (dropped in d4f6a8c2e1b3), ``search_vector`` is a
recall-surface column whose type follows the configured text-search backend, so
it has no business living on the archive. Earlier curation code copied the live
row's ``search_vector`` into ``invalidated_memory_units`` on invalidate; the
engine now leaves it out on invalidate and recomputes it on revert, so the
column is dead weight.
Dropping it removes a latent failure mode (#2503): under a non-native backend
(pgroonga / pg_textsearch / pg_search / vchord) ``ensure_text_search_extension``
reconciles ``memory_units.search_vector`` to ``text`` / ``bm25vector`` but never
touched the archive, which the ``LIKE memory_units`` clone (c9a1b2d3e4f5) created
as ``tsvector``. The type mismatch then broke the curation INSERT … SELECT
round-trip:
column "search_vector" is of type tsvector but expression is of type text
With no column at all, there is nothing to mismatch. Unlike ``embedding`` (whose
creation sites already omit it), the ``LIKE`` clone still adds ``search_vector``,
so this migration does real work on both fresh and existing PostgreSQL databases.
DROP COLUMN is a metadata-only operation on both PostgreSQL and Oracle 23ai (no
table rewrite), so it is cheap even across many tenant schemas. The downgrade
re-adds an empty ``tsvector`` column (its original creation type).
Revision ID: e7c3a9f1b2d5
Revises: b57a7c9e0d13
Create Date: 2026-07-02
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "e7c3a9f1b2d5"
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()
op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS search_vector")
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
# Re-add as the original tsvector creation type; comes back empty regardless.
op.execute(f"ALTER TABLE {schema}invalidated_memory_units ADD COLUMN IF NOT EXISTS search_vector tsvector")
def _oracle_upgrade() -> None:
# Oracle has no `DROP COLUMN IF EXISTS`; swallow ORA-00904 (column does not
# exist) so the migration is idempotent and safe on a schema whose baseline
# may already omit the column.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units DROP COLUMN search_vector';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -904 THEN RAISE; END IF;
END;
"""
)
def _oracle_downgrade() -> None:
# Swallow ORA-01430 (column already exists) for idempotency. Oracle stores
# search_vector as CLOB (see the Oracle baseline), so re-add it as CLOB.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units ADD (search_vector CLOB)';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -1430 THEN RAISE; END IF;
END;
"""
)
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -18,6 +18,7 @@ from typing import Any, Literal, TypeVar
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, Request, UploadFile
from fastapi.middleware.gzip import GZipMiddleware
from hindsight_api.api import okf
from hindsight_api.api.disconnect import ClientDisconnectCancellationMiddleware, get_scope_cancellation_token
from hindsight_api.cancellation import OperationCancelledError
from hindsight_api.engine.audit import (
@@ -2110,6 +2111,150 @@ class MentalModelListResponse(BaseModel):
items: list[MentalModelResponse]
# =========================================================================
# KNOWLEDGE BASE (folders + pages over mental models, projected to OKF)
# =========================================================================
class KnowledgeNode(BaseModel):
"""A node in the knowledge-base tree — a folder or a page.
Pages carry ``description``/``tags`` from their backing mental model. The
knowledge base is client-managed (CRUD); ``managed`` lets a client tag a node
as system-owned vs. hand-authored.
"""
id: str
kind: Literal["folder", "page"]
name: str
parent_id: str | None = None
mental_model_id: str | None = Field(default=None, description="Backing mental model id (pages only).")
managed: bool = Field(default=False, description="Client-set flag: true = system-owned, false = hand-authored.")
description: str | None = Field(default=None, description="Page source query (OKF `description`).")
tags: list[str] = FieldWithDefault(list)
timestamp: str | None = Field(default=None, description="Last refresh (page) or last update (folder).")
children: list["KnowledgeNode"] = FieldWithDefault(list)
class KnowledgeTreeResponse(BaseModel):
"""The knowledge base as a nested folder/page tree."""
roots: list[KnowledgeNode]
class CreateFolderRequest(BaseModel):
"""Create a folder under an optional parent folder."""
name: str
parent_id: str | None = None
class CreatePageRequest(BaseModel):
"""Create a page (a mental model + tree node) under an optional parent folder."""
name: str
source_query: str
parent_id: str | None = None
tags: list[str] | None = None
max_tokens: int | None = None
trigger: MentalModelTrigger | None = None
class UpdateNodeRequest(BaseModel):
"""Rename and/or move a node. Each field applies only when present."""
name: str | None = None
parent_id: str | None = None
class CreateKnowledgePageResponse(BaseModel):
"""Result of creating a page: the node id, its mental model, and the refresh op."""
page_id: str
mental_model_id: str
operation_id: str | None = None
class KnowledgePageResponse(BaseModel):
"""A knowledge page rendered as an OKF document."""
id: str
name: str
type: str = Field(description="OKF document type — from a `type:<x>` tag, else 'knowledge-page'.")
description: str | None = Field(default=None, description="The source query that rebuilds the page.")
tags: list[str] = FieldWithDefault(list)
timestamp: str | None = Field(default=None, description="Last refresh time (falls back to creation).")
body: str | None = Field(default=None, description="The page's synthesized markdown body.")
markdown: str = Field(description="The full OKF document: YAML frontmatter + markdown body.")
class KnowledgePageGraphResponse(BaseModel):
"""Constellation graph of knowledge pages linked by shared tags."""
nodes: list[dict[str, Any]]
edges: list[dict[str, Any]]
total_pages: int
total_edges: int
class KnowledgePageBundleFile(BaseModel):
"""One file in a portable OKF bundle."""
path: str
content: str
class KnowledgePageBundleResponse(BaseModel):
"""A portable OKF bundle — a flat set of markdown files (index + pages + logs)."""
files: list[KnowledgePageBundleFile]
def _knowledge_node_model(node: dict[str, Any]) -> KnowledgeNode:
"""Project an engine node dict into a (childless) KnowledgeNode."""
is_page = node.get("kind") == "page"
return KnowledgeNode(
id=node["id"],
kind=node["kind"],
name=node["name"],
parent_id=node.get("parent_id"),
mental_model_id=node.get("mental_model_id"),
managed=bool(node.get("managed")),
description=node.get("source_query") if is_page else None,
tags=list(node.get("tags") or []) if is_page else [],
timestamp=(node.get("last_refreshed_at") if is_page else node.get("updated_at")),
)
def _build_knowledge_tree(nodes: list[dict[str, Any]]) -> list[KnowledgeNode]:
"""Assemble the flat node list into a nested tree of roots."""
models = {n["id"]: _knowledge_node_model(n) for n in nodes}
roots: list[KnowledgeNode] = []
for node in nodes:
model = models[node["id"]]
parent_id = node.get("parent_id")
if parent_id and parent_id in models:
models[parent_id].children.append(model)
else:
roots.append(model)
return roots
def _knowledge_page_response(node: dict[str, Any]) -> KnowledgePageResponse:
"""Project a page node (with merged mental-model content) into an OKF document."""
page = okf.page_type(node.get("tags"))
return KnowledgePageResponse(
id=node["id"],
name=node["name"],
type=page.type,
description=node.get("source_query"),
tags=page.display_tags,
timestamp=node.get("last_refreshed_at") or node.get("created_at"),
body=node.get("content"),
markdown=okf.render_document(node),
)
class CreateMentalModelRequest(BaseModel):
"""Request model for creating a mental model."""
@@ -4781,6 +4926,333 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/mental-models/{mental_model_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
# =========================================================================
# KNOWLEDGE BASE ENDPOINTS (folders + pages, Open Knowledge Format)
# =========================================================================
# A hierarchy of folders and pages over mental models. Pages project to OKF
# documents (markdown body + YAML frontmatter); see api/okf.py. The static
# sub-paths (/tree, /folders, /pages, /graph, /export) are declared before
# the /pages/{id} and /nodes/{id} path-parameter routes so they win.
@app.get(
"/v1/default/banks/{bank_id}/knowledge-base/tree",
response_model=KnowledgeTreeResponse,
summary="Get the knowledge-base tree",
description="Return the knowledge base as a nested tree of folders and pages.",
operation_id="get_knowledge_base_tree",
tags=["Knowledge Base"],
)
async def api_knowledge_base_tree(
bank_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Return the folder/page tree for a bank."""
try:
nodes = await app.state.memory.list_knowledge_nodes(bank_id=bank_id, request_context=request_context)
return KnowledgeTreeResponse(roots=_build_knowledge_tree(nodes))
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in GET /v1/default/banks/{bank_id}/knowledge-base/tree: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/knowledge-base/folders",
response_model=KnowledgeNode,
status_code=201,
summary="Create a knowledge-base folder",
description="Create a folder, optionally nested under a parent folder.",
operation_id="create_knowledge_folder",
tags=["Knowledge Base"],
)
async def api_create_knowledge_folder(
bank_id: str,
body: CreateFolderRequest,
request_context: RequestContext = Depends(get_request_context),
):
"""Create a folder node."""
try:
node = await app.state.memory.create_knowledge_folder(
bank_id=bank_id,
name=body.name,
parent_id=body.parent_id,
request_context=request_context,
)
return _knowledge_node_model(node)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in POST /v1/default/banks/{bank_id}/knowledge-base/folders: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/knowledge-base/pages",
response_model=CreateKnowledgePageResponse,
status_code=201,
summary="Create a knowledge-base page",
description="Create a page (a mental model + tree node). Content is generated asynchronously; "
"use the returned operation_id to track completion.",
operation_id="create_knowledge_page",
tags=["Knowledge Base"],
)
async def api_create_knowledge_page(
bank_id: str,
body: CreatePageRequest,
request_context: RequestContext = Depends(get_request_context),
):
"""Create a page node (async content generation)."""
try:
node = await app.state.memory.create_knowledge_page(
bank_id=bank_id,
name=body.name,
source_query=body.source_query,
content="Generating content...",
parent_id=body.parent_id,
tags=body.tags if body.tags else None,
max_tokens=body.max_tokens,
trigger=body.trigger.model_dump() if body.trigger else None,
request_context=request_context,
)
if node is None:
raise HTTPException(status_code=409, detail=f"A page named '{body.name}' already exists in this folder")
result = await app.state.memory.submit_async_refresh_mental_model(
bank_id=bank_id,
mental_model_id=node["mental_model_id"],
request_context=request_context,
)
return CreateKnowledgePageResponse(
page_id=node["id"],
mental_model_id=node["mental_model_id"],
operation_id=result["operation_id"],
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in POST /v1/default/banks/{bank_id}/knowledge-base/pages: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/knowledge-base/graph",
response_model=KnowledgePageGraphResponse,
summary="Knowledge-base constellation graph",
description="Return pages as nodes linked by shared tags, for the constellation view.",
operation_id="get_knowledge_base_graph",
tags=["Knowledge Base"],
)
async def api_knowledge_base_graph(
bank_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Return the shared-tag constellation graph for a bank's pages."""
try:
nodes = await app.state.memory.list_knowledge_nodes(bank_id=bank_id, request_context=request_context)
pages = [n for n in nodes if n.get("kind") == "page"]
# Cluster the constellation by parent folder (the knowledge base's own
# structure) rather than by the retired type: tag.
folder_names = {n["id"]: n["name"] for n in nodes if n.get("kind") == "folder"}
graph = okf.knowledge_graph(pages, cluster_for=lambda p: folder_names.get(p.get("parent_id"), "Ungrouped"))
return KnowledgePageGraphResponse(
nodes=graph.nodes,
edges=graph.edges,
total_pages=len(graph.nodes),
total_edges=len(graph.edges),
)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in GET /v1/default/banks/{bank_id}/knowledge-base/graph: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/knowledge-base/export",
response_model=KnowledgePageBundleResponse,
summary="Export the knowledge base as an OKF bundle",
description="Return a portable OKF bundle: a nested index.md, one <id>.md per page, and history logs.",
operation_id="export_knowledge_base",
tags=["Knowledge Base"],
)
async def api_export_knowledge_base(
bank_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Export a bank's knowledge base as a flat OKF markdown bundle."""
try:
nodes = await app.state.memory.list_knowledge_nodes(bank_id=bank_id, request_context=request_context)
files = [KnowledgePageBundleFile(path=okf.INDEX_FILENAME, content=okf.render_index(nodes))]
for node in nodes:
if node.get("kind") != "page":
continue
page = await app.state.memory.get_knowledge_page(
bank_id=bank_id, page_id=node["id"], request_context=request_context
)
if page is None:
continue
files.append(
KnowledgePageBundleFile(path=okf.page_filename(node["id"]), content=okf.render_document(page))
)
if node.get("mental_model_id"):
history = (
await app.state.memory.get_mental_model_history(
bank_id=bank_id,
mental_model_id=node["mental_model_id"],
request_context=request_context,
)
or []
)
if history:
files.append(
KnowledgePageBundleFile(
path=okf.log_filename(node["id"]), content=okf.render_log(page, history)
)
)
return KnowledgePageBundleResponse(files=files)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in GET /v1/default/banks/{bank_id}/knowledge-base/export: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}",
response_model=KnowledgePageResponse,
summary="Get a knowledge-base page",
description="Return a single page as an OKF document (frontmatter + markdown body).",
operation_id="get_knowledge_page",
tags=["Knowledge Base"],
)
async def api_get_knowledge_page(
bank_id: str,
page_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Get a single page as an OKF document."""
try:
node = await app.state.memory.get_knowledge_page(
bank_id=bank_id, page_id=page_id, request_context=request_context
)
if node is None:
raise HTTPException(status_code=404, detail=f"Knowledge page '{page_id}' not found")
return _knowledge_page_response(node)
except (AuthenticationError, HTTPException):
raise
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in GET /v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.patch(
"/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}",
response_model=KnowledgeNode,
summary="Rename or move a knowledge-base node",
description="Rename a node (set `name`) and/or move it under another folder (set `parent_id`, "
"null for the root).",
operation_id="update_knowledge_node",
tags=["Knowledge Base"],
)
async def api_update_knowledge_node(
bank_id: str,
node_id: str,
body: UpdateNodeRequest,
request_context: RequestContext = Depends(get_request_context),
):
"""Rename and/or move a node."""
try:
updated: dict[str, Any] | None = None
did_change = False
if body.name is not None:
did_change = True
updated = await app.state.memory.rename_knowledge_node(
bank_id=bank_id, node_id=node_id, name=body.name, request_context=request_context
)
# parent_id is applied only when present in the body, so passing null
# moves the node to the root (distinct from "not provided").
if "parent_id" in body.model_fields_set:
did_change = True
updated = await app.state.memory.move_knowledge_node(
bank_id=bank_id, node_id=node_id, new_parent_id=body.parent_id, request_context=request_context
)
if not did_change:
raise HTTPException(status_code=400, detail="Provide name and/or parent_id to update")
if updated is None:
raise HTTPException(status_code=404, detail=f"Knowledge node '{node_id}' not found")
return _knowledge_node_model(updated)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except (AuthenticationError, HTTPException):
raise
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.delete(
"/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}",
summary="Delete a knowledge-base node",
description="Delete a folder or page and its whole subtree (pages' mental models are removed too).",
operation_id="delete_knowledge_node",
tags=["Knowledge Base"],
)
async def api_delete_knowledge_node(
bank_id: str,
node_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Delete a node and its subtree."""
try:
deleted = await app.state.memory.delete_knowledge_node(
bank_id=bank_id, node_id=node_id, request_context=request_context
)
if not deleted:
raise HTTPException(status_code=404, detail=f"Knowledge node '{node_id}' not found")
return {"status": "deleted"}
except (AuthenticationError, HTTPException):
raise
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
# =========================================================================
# DIRECTIVES ENDPOINTS
# =========================================================================
+263
View File
@@ -0,0 +1,263 @@
"""Open Knowledge Format (OKF) projection for knowledge pages.
Knowledge pages are a *read-only* OKF view over the existing mental models: each
mental model is projected into an OKF document — a markdown body with YAML
frontmatter (``type`` required; ``title``/``description``/``tags``/``timestamp``
optional) — and pages are linked into a constellation graph via shared tags.
See the Open Knowledge Format spec:
https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf
This module is intentionally pure: every function transforms the mental-model
dicts returned by ``MemoryEngine.list_mental_models`` / ``get_mental_model`` and
never touches the database. That keeps the OKF contract unit-testable without a
DB or LLM and lets the HTTP layer stay a thin wrapper.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
# OKF requires exactly one frontmatter field — ``type``. We default to this when
# a page does not declare one via a ``type:<x>`` tag.
DEFAULT_PAGE_TYPE = "knowledge-page"
# A page declares its OKF ``type`` through a tag of the form ``type:runbook``.
# This keeps the projection schema-free (no new mental_models column): the type
# is lifted from the existing tags array.
TYPE_TAG_PREFIX = "type:"
INDEX_FILENAME = "index.md"
# Deterministic, colour-blind-friendly palette. Type → colour is stable across
# requests so the constellation keeps the same colours between reloads.
_PALETTE = (
"#0074d9", # blue
"#2ecc40", # green
"#b10dc9", # purple
"#ff851b", # orange
"#39cccc", # teal
"#f012be", # magenta
"#3d9970", # olive
"#ff4136", # red
)
_EDGE_COLOR = "#9aa5b1"
@dataclass(frozen=True)
class PageType:
"""A page's OKF ``type`` and the tags that remain after the type tag is split off."""
type: str
display_tags: list[str]
@dataclass(frozen=True)
class KnowledgeGraph:
"""Cytoscape-style node/edge graph of knowledge pages linked by shared tags."""
nodes: list[dict[str, Any]] = field(default_factory=list)
edges: list[dict[str, Any]] = field(default_factory=list)
def _color_for(key: str) -> str:
"""Stable colour for a string key (FNV-ish hash into the fixed palette)."""
h = 0
for ch in key:
h = (h * 31 + ord(ch)) & 0xFFFFFFFF
return _PALETTE[h % len(_PALETTE)]
def _scalar(value: Any) -> str:
"""Emit a YAML-safe double-quoted scalar.
We always double-quote so arbitrary page names / source queries can't be
misread as YAML special forms (``true``, ``2026-01-01``, ``- x``, etc.).
"""
text = str(value)
escaped = text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "")
return f'"{escaped}"'
def page_type(tags: list[str] | None) -> PageType:
"""Split an OKF ``type`` out of the tag list.
The first ``type:<x>`` tag wins; all ``type:`` tags are removed from the
returned ``display_tags`` so they don't pollute the constellation's
shared-tag edges. Falls back to :data:`DEFAULT_PAGE_TYPE`.
"""
resolved = DEFAULT_PAGE_TYPE
display: list[str] = []
for tag in tags or []:
if tag.startswith(TYPE_TAG_PREFIX):
suffix = tag[len(TYPE_TAG_PREFIX) :].strip()
if suffix and resolved == DEFAULT_PAGE_TYPE:
resolved = suffix
continue
display.append(tag)
return PageType(type=resolved, display_tags=display)
def _timestamp(mm: dict[str, Any]) -> str | None:
return mm.get("last_refreshed_at") or mm.get("created_at")
def frontmatter(mm: dict[str, Any]) -> dict[str, Any]:
"""Build the ordered OKF frontmatter mapping for a mental model.
``None``/empty values are dropped by :func:`render_frontmatter`.
"""
pt = page_type(mm.get("tags"))
return {
"id": mm.get("id"),
"type": pt.type,
"title": mm.get("name"),
"description": mm.get("source_query"),
"tags": pt.display_tags,
"timestamp": _timestamp(mm),
}
def render_frontmatter(fm: dict[str, Any]) -> str:
"""Render a frontmatter mapping into a ``---`` fenced YAML block."""
lines = ["---"]
for key, value in fm.items():
if value is None:
continue
if isinstance(value, list):
if not value:
continue
lines.append(f"{key}:")
lines.extend(f" - {_scalar(item)}" for item in value)
else:
lines.append(f"{key}: {_scalar(value)}")
lines.append("---")
return "\n".join(lines)
def render_document(mm: dict[str, Any]) -> str:
"""Render a full OKF document: frontmatter block + markdown body."""
body = (mm.get("content") or "").strip()
return f"{render_frontmatter(frontmatter(mm))}\n\n{body}\n" if body else f"{render_frontmatter(frontmatter(mm))}\n"
def page_filename(page_id: str) -> str:
"""OKF bundle filename for a page id."""
return f"{page_id}.md"
def log_filename(page_id: str) -> str:
"""OKF reserved per-page history filename."""
return f"{page_id}.log.md"
def render_index(nodes: list[dict[str, Any]]) -> str:
"""Render the reserved ``index.md`` — nested OKF navigation over the tree.
``nodes`` is the flat folder/page list (each with ``id``, ``kind``, ``name``,
``parent_id``); folders nest their children, pages link to their ``.md``.
"""
fm = render_frontmatter({"type": "index", "title": "Knowledge base"})
lines = [fm, "", "# Knowledge base", ""]
children: dict[Any, list[dict[str, Any]]] = {}
for node in nodes:
children.setdefault(node.get("parent_id"), []).append(node)
def walk(parent: Any, depth: int) -> None:
ordered = sorted(children.get(parent, []), key=lambda n: (n.get("sort_order", 0), n.get("name") or ""))
for node in ordered:
indent = " " * depth
if node.get("kind") == "folder":
lines.append(f"{indent}- **{node['name']}/**")
walk(node["id"], depth + 1)
else:
description = node.get("source_query") or node.get("description")
link = f"{indent}- [{node['name']}](./{page_filename(node['id'])})"
lines.append(f"{link}{description}" if description else link)
walk(None, 0)
if len(lines) == 4:
lines.append("_No knowledge pages yet._")
return "\n".join(lines) + "\n"
def render_log(mm: dict[str, Any], history: list[dict[str, Any]]) -> str:
"""Render the reserved per-page ``log.md`` from refresh history.
Each history entry is ``{previous_content, previous_reflect_response,
changed_at}`` (newest first), capturing the content *before* a refresh.
"""
name = mm.get("name") or mm.get("id")
fm = render_frontmatter({"type": "log", "title": f"{name} — history"})
lines = [fm, "", f"# {name} — history", ""]
if not history:
lines.append("_No refresh history._")
return "\n".join(lines) + "\n"
for entry in history:
changed_at = entry.get("changed_at") or "unknown"
previous = (entry.get("previous_content") or "").strip()
lines.append(f"## {changed_at}")
lines.append("")
lines.append(previous if previous else "_(empty)_")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def knowledge_graph(
pages: list[dict[str, Any]],
cluster_for: "Callable[[dict[str, Any]], str] | None" = None,
) -> KnowledgeGraph:
"""Derive the constellation graph: pages as nodes, shared tags as edges.
Two pages are linked when they share at least one (non-``type:``) tag; the
edge weight is the number of shared tags. Each node's cluster (``type`` field
+ colour) comes from ``cluster_for(page)`` — the knowledge base groups by
parent folder; the default groups by OKF ``type``.
"""
nodes: list[dict[str, Any]] = []
tag_sets: list[tuple[str, frozenset[str]]] = []
for mm in pages:
page_id = mm["id"]
pt = page_type(mm.get("tags"))
cluster = cluster_for(mm) if cluster_for else pt.type
tag_sets.append((page_id, frozenset(pt.display_tags)))
nodes.append(
{
"data": {
"id": page_id,
"label": mm.get("name") or page_id,
"type": cluster,
"tagCount": len(pt.display_tags),
"color": _color_for(cluster),
}
}
)
edges: list[dict[str, Any]] = []
for i in range(len(tag_sets)):
source_id, source_tags = tag_sets[i]
if not source_tags:
continue
for j in range(i + 1, len(tag_sets)):
target_id, target_tags = tag_sets[j]
shared = source_tags & target_tags
if not shared:
continue
edges.append(
{
"data": {
"id": f"{source_id}--{target_id}",
"source": source_id,
"target": target_id,
"sharedTags": sorted(shared),
"weight": len(shared),
"color": _EDGE_COLOR,
}
}
)
return KnowledgeGraph(nodes=nodes, edges=edges)
@@ -9,33 +9,6 @@ from .ops import DataAccessOps, TagListingParts
from .result import ResultRow
def pg_search_vector_expr(
config,
*,
text_col: str = "text",
context_col: str = "context",
signals_col: str = "text_signals",
) -> str | None:
"""SQL expression that builds ``search_vector`` for the configured PG text-search backend.
Single source of truth shared by the batch insert (over the ``input_data``
CTE columns) and the curation revert recompute (over a ``memory_units`` row),
so the two can never drift. Returns ``None`` for backends that leave
``search_vector`` unpopulated — pgroonga / pg_textsearch / pg_search index the
base text columns directly and keep only a dummy column, so there is nothing
to build.
``text_search_extension_native_language`` is validated as a PG identifier in
``HindsightConfig.validate()``, so embedding it as a SQL literal is safe.
"""
combined = f"COALESCE({text_col}, '') || ' ' || COALESCE({context_col}, '') || ' ' || COALESCE({signals_col}, '')"
if config.text_search_extension == "vchord":
return f"tokenize({combined}, 'llmlingua2')::bm25_catalog.bm25vector"
if config.text_search_extension == "native":
return f"to_tsvector('{config.text_search_extension_native_language}'::regconfig, {combined})"
return None
class PostgreSQLOps(DataAccessOps):
"""PostgreSQL-specific data access operations using unnest and LATERAL."""
@@ -120,39 +93,101 @@ class PostgreSQLOps(DataAccessOps):
config = get_config()
table = self._get_mu_table()
# search_vector is populated inline for backends that store a real vector
# (native tsvector, vchord bm25vector). pgroonga / pg_textsearch / pg_search
# index the base text columns directly and keep only a dummy column, so the
# expression is None and the column is left out of the insert entirely.
# Same expression is reused by curation revert (see pg_search_vector_expr).
sv_expr = pg_search_vector_expr(config)
sv_insert_col = ", search_vector" if sv_expr else ""
sv_select_val = f",\n {sv_expr}" if sv_expr else ""
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals{sv_insert_col})
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals{sv_select_val}
FROM input_data
RETURNING id
"""
if config.text_search_extension == "vchord":
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals, search_vector)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals,
tokenize(
COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, ''),
'llmlingua2'
)::bm25_catalog.bm25vector
FROM input_data
RETURNING id
"""
elif config.text_search_extension == "native":
# search_vector is a regular tsvector column populated here using the
# configured native dictionary. It used to be GENERATED ALWAYS with
# a hardcoded 'english', which prevented per-deployment language
# configuration. text_search_extension_native_language is validated
# in HindsightConfig.validate() as a PG identifier, so embedding it
# as a SQL literal is safe.
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals, search_vector)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals,
to_tsvector(
'{config.text_search_extension_native_language}'::regconfig,
COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, '')
)
FROM input_data
RETURNING id
"""
else:
# pg_textsearch, pgroonga, and pg_search: search_vector is a dummy
# TEXT column; the actual full-text index operates on the base text
# columns directly, so we don't populate search_vector at insert time.
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals
FROM input_data
RETURNING id
"""
results = await conn.fetch(
query,
@@ -6570,18 +6570,13 @@ class MemoryEngine(MemoryEngineInterface):
)
collist = await self._memory_unit_columns(conn)
# The archive is cold storage, never a recall surface and carries no index,
# so the schema gives it neither the `embedding` (dropped in d4f6a8c2e1b3)
# nor the `search_vector` column (dropped in e7c3a9f1b2d5). Both are
# recall-surface columns whose type/shape follows server
# config, so the move in/out is over every memory_units column EXCEPT those
# two; on revert each is recomputed from the unit's text/dates/entities below.
# This makes a model switch (which re-dimensions memory_units) structurally
# unable to trip a vector-dimension mismatch (#2209), and a text-search backend
# switch unable to trip a search_vector type mismatch (#2503), on the
# INSERT … SELECT round-trip.
_archive_omitted = ('"embedding"', '"search_vector"')
arch_cols = ", ".join(c for c in (s.strip() for s in collist.split(",")) if c not in _archive_omitted)
# The archive is cold storage, never a recall surface, so the schema gives it
# no `embedding` column at all (dropped in d4f6a8c2e1b3). The move in/out is
# therefore over every memory_units column EXCEPT embedding; on revert the
# embedding is recomputed from the unit's text/dates/entities below. This makes
# a model switch (which re-dimensions memory_units) structurally unable to trip
# a vector-dimension mismatch on the INSERT … SELECT round-trip (#2209).
arch_cols = ", ".join(c for c in (s.strip() for s in collist.split(",")) if c != '"embedding"')
# --- Edit fields (live rows only): text / context / dates / fact_type / entities ---
doing_edit = any(
@@ -6700,29 +6695,14 @@ class MemoryEngine(MemoryEngineInterface):
arch_row = await conn.fetchrow(
f"SELECT entity_ids FROM {arch} WHERE id = $1 AND bank_id = $2", str(memory_uuid), bank_id
)
# The archive keeps neither embedding nor search_vector (see arch_cols
# above), so both default to NULL on the way back and are recomputed here:
# the embedding below once entities are restored, the search_vector now
# from the row's own text/context/text_signals.
# The archive has no embedding column (see arch_cols above), so the live
# row's embedding defaults to NULL on the way back and is recomputed below
# once entities are restored.
await conn.execute(
f"INSERT INTO {mu} ({arch_cols}) SELECT {arch_cols} FROM {arch} WHERE id = $1 AND bank_id = $2",
str(memory_uuid),
bank_id,
)
# Rebuild search_vector using the *current* text-search backend, so the
# reverted unit is keyword-searchable again (more correct than carrying a
# verbatim copy that could be stale/wrong-type if the backend changed while
# the fact sat archived). None = pgroonga/pg_textsearch/pg_search, which
# index base columns directly and leave search_vector empty (#2503).
from .db.ops_postgresql import pg_search_vector_expr
sv_expr = pg_search_vector_expr(get_config())
if sv_expr is not None:
await conn.execute(
f"UPDATE {mu} SET search_vector = {sv_expr} WHERE id = $1 AND bank_id = $2",
str(memory_uuid),
bank_id,
)
# Re-consolidate from scratch; links are rebuilt by graph maintenance.
await conn.execute(
f"UPDATE {mu} SET consolidated_at = NULL, consolidation_failed_at = NULL, updated_at = now() "
@@ -11139,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,
@@ -12867,3 +13176,4 @@ class MemoryEngine(MemoryEngineInterface):
result_metadata={"mental_model_id": mental_model_id, "name": mental_model["name"]},
dedupe_by_bank=False,
)
@@ -53,53 +53,6 @@ __all__ = [
logger = logging.getLogger(__name__)
# Name of the single forced function tool used to carry structured output when
# strict_schema is on. The Codex backend speaks the OpenAI Responses API, so a
# forced function call gives us constrained decoding straight into the response
# schema — no prompt-injected schema, no raw json.loads on free-form model text,
# no invalid-\escape retry storm (issue #2504, same class as #1002 / #2339).
_STRUCTURED_TOOL_NAME = "structured_response"
# Valid JSON string escape characters (the char that may follow a backslash).
_VALID_JSON_ESCAPE_CHARS = set('"\\/bfnrtu')
def _repair_invalid_json_escapes(text: str) -> str:
"""Best-effort repair of invalid ``\\escape`` sequences in a JSON string.
Escape-heavy content (code, serial/CLI commands, Windows paths, regexes)
makes weaker models emit backslashes that aren't valid JSON escapes (e.g.
``\\d``, ``\\s``, ``C:\\Users``), so ``json.loads`` fails deterministically
and every retry re-fails the same way (issue #2504). This doubles any
backslash that isn't part of a valid escape so the payload parses. It is a
lenient fallback only — the strict_schema forced-tool path is the real fix.
"""
result: list[str] = []
i = 0
n = len(text)
while i < n:
ch = text[i]
if ch == "\\" and i + 1 < n:
nxt = text[i + 1]
if nxt in _VALID_JSON_ESCAPE_CHARS:
# Preserve the valid escape (both chars) verbatim.
result.append(ch)
result.append(nxt)
i += 2
continue
# Invalid escape: escape the lone backslash so JSON parses.
result.append("\\\\")
i += 1
continue
if ch == "\\" and i + 1 == n:
# Trailing lone backslash — escape it.
result.append("\\\\")
i += 1
continue
result.append(ch)
i += 1
return "".join(result)
class CodexLLM(LLMInterface):
"""
@@ -383,18 +336,7 @@ class CodexLLM(LLMInterface):
strict_schema: bool = False,
return_usage: bool = False,
) -> Any:
"""Make API call to Codex backend with SSE streaming.
Args:
strict_schema: Route structured output through a single forced
function tool (constrained decoding) instead of prompt-injecting
the schema and parsing free-form text. The Codex backend speaks
the OpenAI Responses API, so the forced function call emits the
response schema directly as tool arguments — eliminating the
invalid-``\\escape`` retry storm (issue #2504). When False, falls
back to schema-in-prompt + JSON parse, now hardened with a lenient
invalid-escape repair before giving up.
"""
"""Make API call to Codex backend with SSE streaming."""
start_time = time.time()
# Proactively refresh the OAuth access_token if it's near expiry.
@@ -419,22 +361,11 @@ class CodexLLM(LLMInterface):
else:
user_messages.append(msg)
# Structured output: prefer a single forced function tool (constrained
# decoding) over text-injecting the schema and parsing the reply. The
# forced tool guarantees schema-shaped JSON in the tool arguments,
# eliminating the invalid-\escape retry storm (issue #2504). When
# strict_schema is off we keep the schema-in-prompt + json.loads
# fallback (now hardened with a lenient escape repair) for callers that
# can't force tools.
schema = None
use_forced_tool = False
# Add JSON schema instruction if response_format is provided
if response_format is not None and hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
if strict_schema:
use_forced_tool = True
else:
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
system_instruction += schema_msg
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
system_instruction += schema_msg
# gpt-5.2-codex only supports "detailed" reasoning summary
reasoning_summary = "detailed" if "5.2" in self.model else self.reasoning_summary
@@ -461,20 +392,6 @@ class CodexLLM(LLMInterface):
"prompt_cache_key": str(uuid.uuid4()),
}
if use_forced_tool and schema is not None:
# Single function tool whose parameters ARE the response schema;
# force it via tool_choice so the backend does constrained decoding.
payload["tools"] = [
{
"type": "function",
"name": _STRUCTURED_TOOL_NAME,
"description": "Return the structured response.",
"parameters": schema,
}
]
payload["tool_choice"] = {"type": "function", "name": _STRUCTURED_TOOL_NAME}
payload["parallel_tool_calls"] = False
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
@@ -495,15 +412,8 @@ class CodexLLM(LLMInterface):
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
response.raise_for_status()
# Forced-tool path: read structured output from the function-call
# arguments (already a JSON string in a dedicated channel) rather
# than from free-form assistant text.
if use_forced_tool:
text_content, tool_calls = await self._parse_sse_tool_stream(response)
content = text_content or ""
else:
tool_calls = []
content = await self._parse_sse_stream(response)
# Parse SSE stream
content = await self._parse_sse_stream(response)
# Codex SSE carries no usage block; stash the same char/4 estimate
# the success path traces so a later parse/validate failure records
@@ -516,28 +426,7 @@ class CodexLLM(LLMInterface):
)
# Handle structured output
if use_forced_tool:
tool_input = None
for tc in tool_calls:
if tc.name == _STRUCTURED_TOOL_NAME:
tool_input = tc.arguments if isinstance(tc.arguments, dict) else None
break
if tool_input is None:
# Model ignored the forced tool (rare — e.g. a gateway that
# drops tool_choice). Retry so we don't hard-fail.
logger.warning(
f"Codex forced structured tool missing from response "
f"(attempt {attempt + 1}/{max_retries + 1})"
)
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
attempt += 1
continue
raise RuntimeError("Codex did not return the forced structured_response tool call")
content = json.dumps(tool_input)
result = tool_input if skip_validation else response_format.model_validate(tool_input)
elif response_format is not None:
if response_format is not None:
# Models may wrap JSON in markdown
clean_content = content
if "```json" in content:
@@ -548,20 +437,13 @@ class CodexLLM(LLMInterface):
try:
json_data = json.loads(clean_content)
except json.JSONDecodeError as e:
# Escape-heavy content deterministically re-fails every
# retry (issue #2504). Try a lenient invalid-escape repair
# before burning a retry / re-raising.
try:
json_data = json.loads(_repair_invalid_json_escapes(clean_content))
logger.info("Codex JSON parsed after repairing invalid escape sequences")
except json.JSONDecodeError:
logger.warning(f"Codex JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {e}")
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
attempt += 1
continue
raise
logger.warning(f"Codex JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {e}")
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
attempt += 1
continue
raise
if skip_validation:
result = json_data
@@ -990,13 +872,8 @@ class CodexLLM(LLMInterface):
try:
arguments = json.loads(arguments_str)
except json.JSONDecodeError:
# Escape-heavy content can emit invalid \escape
# sequences (issue #2504); repair before giving up.
try:
arguments = json.loads(_repair_invalid_json_escapes(arguments_str))
except json.JSONDecodeError:
logger.warning(f"Failed to parse tool arguments: {arguments_str}")
arguments = {}
logger.warning(f"Failed to parse tool arguments: {arguments_str}")
arguments = {}
tool_calls.append(
LLMToolCall(
@@ -1,185 +0,0 @@
"""
Regression tests for Codex structured output (issue #2504).
Before the fix, ``CodexLLM.call(strict_schema=True)`` was a dead no-op: structured
output always went through prompt-injected schema + raw ``json.loads`` on the
model's free-form text. Escape-heavy content (code, serial/CLI commands, Windows
paths, regexes) makes weaker models emit invalid ``\\escape`` sequences, so every
parse attempt fails and retain/consolidation burn all retries and fail.
The fix:
- ``strict_schema=True`` routes structured output through a single forced function
tool (constrained decoding into the response schema).
- The non-strict fallback now repairs invalid ``\\escape`` sequences before giving up.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import BaseModel
from hindsight_api.engine.providers.codex_llm import (
CodexLLM,
_repair_invalid_json_escapes,
)
from hindsight_api.engine.response_models import LLMToolCall
class _Fact(BaseModel):
fact: str
def build_llm() -> CodexLLM:
with patch.object(CodexLLM, "_load_codex_auth", return_value=("token", "account")):
return CodexLLM(
provider="openai-codex",
api_key="ignored",
base_url="https://chatgpt.com/backend-api",
model="gpt-5.4-mini",
)
# ---------------------------------------------------------------------------
# _repair_invalid_json_escapes — pure unit tests
# ---------------------------------------------------------------------------
def test_repair_fixes_invalid_escape_in_json():
# `\d` and `\s` are not valid JSON escapes; raw json.loads fails.
broken = r'{"fact": "regex \d+\s matches digits"}'
import json
with pytest.raises(json.JSONDecodeError):
json.loads(broken)
repaired = _repair_invalid_json_escapes(broken)
assert json.loads(repaired) == {"fact": r"regex \d+\s matches digits"}
def test_repair_preserves_valid_escapes():
import json
valid = r'{"fact": "line1\nline2\ttab \"quoted\" \\backslash é"}'
# Already valid — repair must not corrupt it.
assert json.loads(_repair_invalid_json_escapes(valid)) == json.loads(valid)
def test_repair_handles_windows_paths():
import json
# Uses path segments whose first char isn't a valid JSON escape letter
# (b/f/n/r/t/u), where the repair is unambiguous.
broken = r'{"path": "C:\Windows\System32\app.exe"}'
assert json.loads(_repair_invalid_json_escapes(broken)) == {"path": r"C:\Windows\System32\app.exe"}
def test_repair_handles_trailing_backslash():
# A lone trailing backslash must be escaped, not dropped.
assert _repair_invalid_json_escapes("abc\\") == "abc\\\\"
# ---------------------------------------------------------------------------
# strict_schema forced-tool path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_strict_schema_uses_forced_function_tool():
llm = build_llm()
response = MagicMock()
response.raise_for_status.return_value = None
tool_call = LLMToolCall(id="call-1", name="structured_response", arguments={"fact": "the sky is blue"})
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = response
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
mock_parse.return_value = (None, [tool_call])
result = await llm.call(
messages=[{"role": "user", "content": "The sky is blue"}],
response_format=_Fact,
strict_schema=True,
max_retries=0,
)
sent_payload = mock_post.call_args.kwargs["json"]
# Forced tool wired into the request payload.
assert sent_payload["tool_choice"] == {"type": "function", "name": "structured_response"}
assert len(sent_payload["tools"]) == 1
assert sent_payload["tools"][0]["name"] == "structured_response"
assert sent_payload["parallel_tool_calls"] is False
# No prompt-injected schema in the instructions.
assert "You must respond with valid JSON" not in sent_payload["instructions"]
assert isinstance(result, _Fact)
assert result.fact == "the sky is blue"
@pytest.mark.asyncio
async def test_strict_schema_skip_validation_returns_dict():
llm = build_llm()
response = MagicMock()
response.raise_for_status.return_value = None
tool_call = LLMToolCall(id="c", name="structured_response", arguments={"fact": "x"})
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = response
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
mock_parse.return_value = (None, [tool_call])
result = await llm.call(
messages=[{"role": "user", "content": "hi"}],
response_format=_Fact,
strict_schema=True,
skip_validation=True,
max_retries=0,
)
assert result == {"fact": "x"}
@pytest.mark.asyncio
async def test_strict_schema_retries_when_forced_tool_missing():
llm = build_llm()
response = MagicMock()
response.raise_for_status.return_value = None
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = response
# Model returns no tool call at all — should raise after retries exhausted.
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
mock_parse.return_value = ("some prose", [])
with pytest.raises(RuntimeError, match="structured_response"):
await llm.call(
messages=[{"role": "user", "content": "hi"}],
response_format=_Fact,
strict_schema=True,
max_retries=0,
)
# ---------------------------------------------------------------------------
# Non-strict fallback: escape repair keeps the retry storm from happening
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_non_strict_repairs_invalid_escapes_without_retrying():
llm = build_llm()
response = MagicMock()
response.raise_for_status.return_value = None
# Escape-heavy content the model would emit as invalid JSON.
escape_heavy = r'{"fact": "run rig-control \d serial \s command"}'
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = response
with patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock) as mock_parse:
mock_parse.return_value = escape_heavy
result = await llm.call(
messages=[{"role": "user", "content": "coding transcript"}],
response_format=_Fact,
strict_schema=False,
max_retries=3,
)
# Parsed on the first attempt (no retry storm): the SSE stream was read once.
assert mock_post.await_count == 1
assert isinstance(result, _Fact)
assert result.fact == r"run rig-control \d serial \s command"
@@ -4,7 +4,8 @@ Unit tests that verify the abstraction interfaces work correctly
without requiring a live database connection.
"""
from unittest.mock import AsyncMock, patch
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -747,85 +748,6 @@ class TestOracleOpsInsertFactsBatch:
assert rows_data[0][13] == []
# ---------------------------------------------------------------------------
# PostgreSQL search_vector handling (insert). Since the curation archive drops
# search_vector (#2503), the insert is the single place it is populated, and
# pg_search_vector_expr is its one source of truth (shared with revert recompute).
# ---------------------------------------------------------------------------
class TestPostgreSQLSearchVector:
@staticmethod
def _cfg(ext: str, lang: str = "english"):
from types import SimpleNamespace
return SimpleNamespace(text_search_extension=ext, text_search_extension_native_language=lang)
@pytest.mark.parametrize(
"ext,needle",
[
("native", "to_tsvector('english'::regconfig,"),
("vchord", "::bm25_catalog.bm25vector"),
],
)
def test_expr_builds_vector_for_vector_backends(self, ext, needle):
from hindsight_api.engine.db.ops_postgresql import pg_search_vector_expr
expr = pg_search_vector_expr(self._cfg(ext))
assert expr is not None and needle in expr
# Always built from the same three carried columns.
assert "COALESCE(text, '')" in expr and "COALESCE(text_signals, '')" in expr
@pytest.mark.parametrize("ext", ["pgroonga", "pg_textsearch", "pg_search"])
def test_expr_none_for_base_column_backends(self, ext):
from hindsight_api.engine.db.ops_postgresql import pg_search_vector_expr
# These index the base text columns directly; search_vector stays empty.
assert pg_search_vector_expr(self._cfg(ext)) is None
def test_expr_accepts_custom_column_refs(self):
from hindsight_api.engine.db.ops_postgresql import pg_search_vector_expr
expr = pg_search_vector_expr(self._cfg("native"), text_col="mu.text", context_col="mu.context")
assert "COALESCE(mu.text, '')" in expr and "COALESCE(mu.context, '')" in expr
async def _insert_query(self, ext: str) -> str:
from hindsight_api.engine.db.ops_postgresql import PostgreSQLOps
conn = AsyncMock(spec=DatabaseConnection)
conn.fetch = AsyncMock(return_value=[{"id": "00000000-0000-0000-0000-000000000001"}])
batch = dict(
bank_id="b",
fact_texts=["t"],
embeddings=["[0.1]"],
event_dates=[None],
occurred_starts=[None],
occurred_ends=[None],
mentioned_ats=[None],
contexts=["c"],
fact_types=["world"],
metadata_jsons=["{}"],
chunk_ids=[None],
document_ids=[None],
tags_list=[""],
observation_scopes_list=[None],
text_signals_list=[None],
)
with patch("hindsight_api.config.get_config", return_value=self._cfg(ext)):
await PostgreSQLOps().insert_facts_batch(conn=conn, **batch)
return conn.fetch.call_args.args[0]
@pytest.mark.asyncio
@pytest.mark.parametrize("ext", ["native", "vchord"])
async def test_insert_includes_search_vector_column(self, ext):
assert "search_vector" in await self._insert_query(ext)
@pytest.mark.asyncio
@pytest.mark.parametrize("ext", ["pgroonga", "pg_textsearch", "pg_search"])
async def test_insert_omits_search_vector_column(self, ext):
assert "search_vector" not in await self._insert_query(ext)
# ---------------------------------------------------------------------------
# normalize_schema tests
# ---------------------------------------------------------------------------
@@ -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
@@ -182,7 +182,7 @@ def test_batch_request_body_strict_follows_config(strict):
from hindsight_api.engine.retain.fact_extraction import _build_request_body
llm_config = SimpleNamespace(model="gpt-4o-mini", provider="openai", _provider_impl=SimpleNamespace())
config = SimpleNamespace(retain_max_completion_tokens=None, llm_strict_schema=strict, llm_temperature_retain=None)
config = SimpleNamespace(retain_max_completion_tokens=None, llm_strict_schema=strict)
# provider != "openai" service-tier branch skipped via _provider_impl without attr
llm_config._provider_impl.openai_service_tier = None
@@ -101,12 +101,11 @@ async def _archive_row(conn, mem_id: uuid.UUID) -> dict | None:
return dict(row) if row else None
async def _archive_has_column(conn, column: str) -> bool:
async def _archive_has_embedding_column(conn) -> bool:
return bool(
await conn.fetchval(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = 'invalidated_memory_units' AND column_name = $1",
column,
"WHERE table_name = 'invalidated_memory_units' AND column_name = 'embedding'"
)
)
@@ -175,12 +174,9 @@ class TestInvalidate:
arch = await _archive_row(conn, m1)
assert arch is not None, "row must be in the archive"
assert arch["invalidation_reason"] == "decommissioned"
assert not await _archive_has_column(conn, "embedding"), (
assert not await _archive_has_embedding_column(conn), (
"archive is cold storage; the schema drops the embedding column (#2209)"
)
assert not await _archive_has_column(conn, "search_vector"), (
"archive is cold storage with no index; the schema drops search_vector (#2503)"
)
assert await _link_count(conn, m1) == 0, "links cascade-pruned on move"
assert str(obs_id) not in await _obs_ids(conn, bank_id), "derived observation removed"
assert await _consolidated_at(conn, m2) is None, "surviving source reset for re-consolidation"
@@ -220,10 +216,6 @@ class TestInvalidate:
assert e1 in await _entity_ids_for(conn, m1), "entity associations restored on revert"
reverted_emb = await conn.fetchval("SELECT embedding FROM memory_units WHERE id = $1", m1)
assert reverted_emb is not None, "embedding recomputed on revert (archive keeps none)"
# Native backend (test default) stores a real tsvector; it must be rebuilt on
# revert so the reverted fact is keyword-searchable again (archive keeps none, #2503).
reverted_sv = await conn.fetchval("SELECT search_vector FROM memory_units WHERE id = $1", m1)
assert reverted_sv is not None, "search_vector recomputed on revert (archive keeps none)"
await memory.delete_bank(bank_id, request_context=request_context)
+166
View File
@@ -0,0 +1,166 @@
"""Pure unit tests for the OKF (Open Knowledge Format) serializer.
These exercise hindsight_api/api/okf.py with plain dicts — no DB, no LLM — so
they pin the OKF contract (frontmatter projection, type-from-tag, shared-tag
graph) deterministically and fast.
"""
from hindsight_api.api import okf
def _mm(**overrides):
base = {
"id": "orders",
"name": "Orders",
"source_query": "What are the order facts?",
"content": "# Orders\n\nOne row per order.",
"tags": ["type:runbook", "sales", "revenue"],
"last_refreshed_at": "2026-01-02T00:00:00Z",
"created_at": "2026-01-01T00:00:00Z",
}
base.update(overrides)
return base
class TestPageType:
def test_lifts_type_from_tag_and_drops_it(self):
pt = okf.page_type(["type:runbook", "sales", "revenue"])
assert pt.type == "runbook"
assert pt.display_tags == ["sales", "revenue"]
def test_defaults_when_no_type_tag(self):
pt = okf.page_type(["sales"])
assert pt.type == okf.DEFAULT_PAGE_TYPE
assert pt.display_tags == ["sales"]
def test_handles_none_and_empty(self):
assert okf.page_type(None).type == okf.DEFAULT_PAGE_TYPE
assert okf.page_type(None).display_tags == []
def test_blank_type_suffix_falls_back(self):
pt = okf.page_type(["type:", "sales"])
assert pt.type == okf.DEFAULT_PAGE_TYPE
# the (blank) type tag is still stripped from display tags
assert pt.display_tags == ["sales"]
def test_first_type_tag_wins(self):
pt = okf.page_type(["type:runbook", "type:guide"])
assert pt.type == "runbook"
assert pt.display_tags == []
class TestFrontmatter:
def test_projects_expected_fields(self):
fm = okf.frontmatter(_mm())
assert fm["id"] == "orders"
assert fm["type"] == "runbook"
assert fm["title"] == "Orders"
assert fm["description"] == "What are the order facts?"
assert fm["tags"] == ["sales", "revenue"]
assert fm["timestamp"] == "2026-01-02T00:00:00Z"
def test_timestamp_falls_back_to_created_at(self):
fm = okf.frontmatter(_mm(last_refreshed_at=None))
assert fm["timestamp"] == "2026-01-01T00:00:00Z"
def test_render_omits_none_and_empty(self):
rendered = okf.render_frontmatter({"type": "x", "title": None, "tags": []})
assert "title" not in rendered
assert "tags" not in rendered
assert 'type: "x"' in rendered
def test_render_quotes_and_escapes(self):
# A name that looks like a YAML bool / contains a quote must stay a string.
rendered = okf.render_frontmatter({"title": 'true "x"'})
assert 'title: "true \\"x\\""' in rendered
class TestRenderDocument:
def test_includes_frontmatter_and_body(self):
doc = okf.render_document(_mm())
assert doc.startswith("---\n")
assert 'type: "runbook"' in doc
assert "One row per order." in doc
def test_empty_body(self):
doc = okf.render_document(_mm(content=""))
assert doc.count("---") == 2
assert doc.rstrip().endswith("---")
class TestKnowledgeGraph:
def test_edge_from_shared_tag(self):
pages = [
_mm(id="orders", tags=["type:runbook", "sales", "revenue"]),
_mm(id="customers", tags=["sales", "crm"]),
_mm(id="lonely", tags=[]),
]
graph = okf.knowledge_graph(pages)
assert len(graph.nodes) == 3
assert len(graph.edges) == 1
edge = graph.edges[0]["data"]
assert {edge["source"], edge["target"]} == {"orders", "customers"}
assert edge["sharedTags"] == ["sales"]
assert edge["weight"] == 1
def test_type_tag_does_not_create_edges(self):
# Two pages sharing only a type: tag must NOT be linked.
pages = [
_mm(id="a", tags=["type:runbook"]),
_mm(id="b", tags=["type:runbook"]),
]
graph = okf.knowledge_graph(pages)
assert graph.edges == []
def test_node_carries_type_and_color(self):
graph = okf.knowledge_graph([_mm(id="orders", tags=["type:runbook", "sales"])])
node = graph.nodes[0]["data"]
assert node["type"] == "runbook"
assert node["label"] == "Orders"
assert node["tagCount"] == 1
assert node["color"].startswith("#")
def test_weight_counts_shared_tags(self):
pages = [
_mm(id="a", tags=["sales", "revenue", "x"]),
_mm(id="b", tags=["sales", "revenue", "y"]),
]
graph = okf.knowledge_graph(pages)
assert graph.edges[0]["data"]["weight"] == 2
assert graph.edges[0]["data"]["sharedTags"] == ["revenue", "sales"]
class TestReservedFiles:
def test_index_links_each_page(self):
index = okf.render_index([_mm(id="orders", name="Orders", source_query="q?")])
assert "[Orders](./orders.md)" in index
assert "q?" in index
assert 'type: "index"' in index
def test_index_empty(self):
assert "No knowledge pages yet" in okf.render_index([])
def test_index_nests_folders(self):
nodes = [
{"id": "f1", "kind": "folder", "name": "Runbooks", "parent_id": None},
{"id": "p1", "kind": "page", "name": "Orders", "parent_id": "f1", "source_query": "q?"},
{"id": "p2", "kind": "page", "name": "Loose", "parent_id": None},
]
idx = okf.render_index(nodes)
assert "**Runbooks/**" in idx
# the page nested in the folder is indented and links to its file
assert " - [Orders](./p1.md) — q?" in idx
assert "- [Loose](./p2.md)" in idx
def test_log_renders_history_newest_first(self):
history = [
{"previous_content": "v2", "changed_at": "2026-01-02T00:00:00Z"},
{"previous_content": "v1", "changed_at": "2026-01-01T00:00:00Z"},
]
log = okf.render_log(_mm(), history)
assert 'type: "log"' in log
assert log.index("2026-01-02") < log.index("2026-01-01")
assert "v2" in log and "v1" in log
def test_log_empty(self):
assert "No refresh history" in okf.render_log(_mm(), [])
+692
View File
@@ -1382,6 +1382,348 @@ paths:
summary: Clear mental model content
tags:
- Mental Models
/v1/default/banks/{bank_id}/knowledge-base/tree:
get:
description: Return the knowledge base as a nested tree of folders and pages.
operationId: get_knowledge_base_tree
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgeTreeResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Get the knowledge-base tree
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/knowledge-base/folders:
post:
description: "Create a folder, optionally nested under a parent folder."
operationId: create_knowledge_folder
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CreateFolderRequest'
required: true
responses:
"201":
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgeNode'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Create a knowledge-base folder
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/knowledge-base/pages:
post:
description: Create a page (a mental model + tree node). Content is generated
asynchronously; use the returned operation_id to track completion.
operationId: create_knowledge_page
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CreatePageRequest'
required: true
responses:
"201":
content:
application/json:
schema:
$ref: '#/components/schemas/CreateKnowledgePageResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Create a knowledge-base page
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/knowledge-base/graph:
get:
description: "Return pages as nodes linked by shared tags, for the constellation\
\ view."
operationId: get_knowledge_base_graph
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgePageGraphResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Knowledge-base constellation graph
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/knowledge-base/export:
get:
description: "Return a portable OKF bundle: a nested index.md, one <id>.md per\
\ page, and history logs."
operationId: export_knowledge_base
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgePageBundleResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Export the knowledge base as an OKF bundle
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}:
get:
description: Return a single page as an OKF document (frontmatter + markdown
body).
operationId: get_knowledge_page
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: page_id
required: true
schema:
title: Page Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgePageResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Get a knowledge-base page
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}:
delete:
description: Delete a folder or page and its whole subtree (pages' mental models
are removed too).
operationId: delete_knowledge_node
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: node_id
required: true
schema:
title: Node Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema: {}
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Delete a knowledge-base node
tags:
- Knowledge Base
patch:
description: "Rename a node (set `name`) and/or move it under another folder\
\ (set `parent_id`, null for the root)."
operationId: update_knowledge_node
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: node_id
required: true
schema:
title: Node Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateNodeRequest'
required: true
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgeNode'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Rename or move a knowledge-base node
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/directives:
get:
description: List hard rules that are injected into prompts.
@@ -5095,6 +5437,42 @@ components:
- content
- name
title: CreateDirectiveRequest
CreateFolderRequest:
description: Create a folder under an optional parent folder.
example:
parent_id: parent_id
name: name
properties:
name:
title: Name
type: string
parent_id:
nullable: true
type: string
required:
- name
title: CreateFolderRequest
CreateKnowledgePageResponse:
description: "Result of creating a page: the node id, its mental model, and\
\ the refresh op."
example:
page_id: page_id
operation_id: operation_id
mental_model_id: mental_model_id
properties:
page_id:
title: Page Id
type: string
mental_model_id:
title: Mental Model Id
type: string
operation_id:
nullable: true
type: string
required:
- mental_model_id
- page_id
title: CreateKnowledgePageResponse
CreateMentalModelRequest:
description: Request model for creating a mental model.
example:
@@ -5153,6 +5531,64 @@ components:
required:
- operation_id
title: CreateMentalModelResponse
CreatePageRequest:
description: Create a page (a mental model + tree node) under an optional parent
folder.
example:
source_query: source_query
max_tokens: 0
parent_id: parent_id
name: name
trigger:
mode: full
refresh_after_consolidation: false
recall_chunks_max_tokens: 1
tag_groups:
- match: any_strict
tags:
- tags
- tags
- match: any_strict
tags:
- tags
- tags
fact_types:
- world
- world
exclude_mental_model_ids:
- exclude_mental_model_ids
- exclude_mental_model_ids
include_chunks: true
tags_match: any
exclude_mental_models: false
recall_max_tokens: 6
tags:
- tags
- tags
properties:
name:
title: Name
type: string
source_query:
title: Source Query
type: string
parent_id:
nullable: true
type: string
tags:
items:
type: string
nullable: true
type: array
max_tokens:
nullable: true
type: integer
trigger:
$ref: '#/components/schemas/MentalModelTrigger-Input'
required:
- name
- source_query
title: CreatePageRequest
CreateWebhookRequest:
description: Request model for registering a webhook.
example:
@@ -5956,6 +6392,226 @@ components:
source_facts:
$ref: '#/components/schemas/SourceFactsIncludeOptions'
title: IncludeOptions
KnowledgeNode:
description: |-
A node in the knowledge-base tree — a folder or a page.
Pages carry ``description``/``tags`` from their backing mental model. The
knowledge base is client-managed (CRUD); ``managed`` lets a client tag a node
as system-owned vs. hand-authored.
example:
children:
- null
- null
kind: folder
parent_id: parent_id
managed: false
name: name
description: description
id: id
mental_model_id: mental_model_id
tags:
- tags
- tags
timestamp: timestamp
properties:
id:
title: Id
type: string
kind:
enum:
- folder
- page
title: Kind
type: string
name:
title: Name
type: string
parent_id:
nullable: true
type: string
mental_model_id:
nullable: true
type: string
managed:
default: false
description: "Client-set flag: true = system-owned, false = hand-authored."
title: Managed
type: boolean
description:
nullable: true
type: string
tags:
default: []
items:
type: string
type: array
timestamp:
nullable: true
type: string
children:
default: []
items:
$ref: '#/components/schemas/KnowledgeNode'
type: array
required:
- id
- kind
- name
title: KnowledgeNode
KnowledgePageBundleFile:
description: One file in a portable OKF bundle.
example:
path: path
content: content
properties:
path:
title: Path
type: string
content:
title: Content
type: string
required:
- content
- path
title: KnowledgePageBundleFile
KnowledgePageBundleResponse:
description: A portable OKF bundle — a flat set of markdown files (index + pages
+ logs).
example:
files:
- path: path
content: content
- path: path
content: content
properties:
files:
items:
$ref: '#/components/schemas/KnowledgePageBundleFile'
type: array
required:
- files
title: KnowledgePageBundleResponse
KnowledgePageGraphResponse:
description: Constellation graph of knowledge pages linked by shared tags.
example:
total_edges: 6
nodes:
- key: ""
- key: ""
edges:
- key: ""
- key: ""
total_pages: 0
properties:
nodes:
items:
additionalProperties: {}
type: array
edges:
items:
additionalProperties: {}
type: array
total_pages:
title: Total Pages
type: integer
total_edges:
title: Total Edges
type: integer
required:
- edges
- nodes
- total_edges
- total_pages
title: KnowledgePageGraphResponse
KnowledgePageResponse:
description: A knowledge page rendered as an OKF document.
example:
name: name
markdown: markdown
description: description
id: id
type: type
body: body
tags:
- tags
- tags
timestamp: timestamp
properties:
id:
title: Id
type: string
name:
title: Name
type: string
type:
description: "OKF document type — from a `type:<x>` tag, else 'knowledge-page'."
title: Type
type: string
description:
nullable: true
type: string
tags:
default: []
items:
type: string
type: array
timestamp:
nullable: true
type: string
body:
nullable: true
type: string
markdown:
description: "The full OKF document: YAML frontmatter + markdown body."
title: Markdown
type: string
required:
- id
- markdown
- name
- type
title: KnowledgePageResponse
KnowledgeTreeResponse:
description: The knowledge base as a nested folder/page tree.
example:
roots:
- children:
- null
- null
kind: folder
parent_id: parent_id
managed: false
name: name
description: description
id: id
mental_model_id: mental_model_id
tags:
- tags
- tags
timestamp: timestamp
- children:
- null
- null
kind: folder
parent_id: parent_id
managed: false
name: name
description: description
id: id
mental_model_id: mental_model_id
tags:
- tags
- tags
timestamp: timestamp
properties:
roots:
items:
$ref: '#/components/schemas/KnowledgeNode'
type: array
required:
- roots
title: KnowledgeTreeResponse
LLMRequestEntry:
description: "A single LLM request trace row, as returned by the read API."
example:
@@ -6741,6 +7397,29 @@ components:
title: MentalModelResponse
MentalModelTrigger-Input:
description: Trigger settings for a mental model.
example:
mode: full
refresh_after_consolidation: false
recall_chunks_max_tokens: 1
tag_groups:
- match: any_strict
tags:
- tags
- tags
- match: any_strict
tags:
- tags
- tags
fact_types:
- world
- world
exclude_mental_model_ids:
- exclude_mental_model_ids
- exclude_mental_model_ids
include_chunks: true
tags_match: any
exclude_mental_models: false
recall_max_tokens: 6
properties:
mode:
default: full
@@ -8114,6 +8793,19 @@ components:
trigger:
$ref: '#/components/schemas/MentalModelTrigger-Input'
title: UpdateMentalModelRequest
UpdateNodeRequest:
description: Rename and/or move a node. Each field applies only when present.
example:
parent_id: parent_id
name: name
properties:
name:
nullable: true
type: string
parent_id:
nullable: true
type: string
title: UpdateNodeRequest
UpdateWebhookRequest:
description: Request model for updating a webhook. Only provided fields are
updated.
File diff suppressed because it is too large Load Diff
+3
View File
@@ -65,6 +65,8 @@ type APIClient struct {
FilesAPI *FilesAPIService
KnowledgeBaseAPI *KnowledgeBaseAPIService
LLMTracesAPI *LLMTracesAPIService
MemoryAPI *MemoryAPIService
@@ -102,6 +104,7 @@ func NewAPIClient(cfg *Configuration) *APIClient {
c.DocumentsAPI = (*DocumentsAPIService)(&c.common)
c.EntitiesAPI = (*EntitiesAPIService)(&c.common)
c.FilesAPI = (*FilesAPIService)(&c.common)
c.KnowledgeBaseAPI = (*KnowledgeBaseAPIService)(&c.common)
c.LLMTracesAPI = (*LLMTracesAPIService)(&c.common)
c.MemoryAPI = (*MemoryAPIService)(&c.common)
c.MentalModelsAPI = (*MentalModelsAPIService)(&c.common)
@@ -0,0 +1,204 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the CreateFolderRequest type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &CreateFolderRequest{}
// CreateFolderRequest Create a folder under an optional parent folder.
type CreateFolderRequest struct {
Name string `json:"name"`
ParentId NullableString `json:"parent_id,omitempty"`
}
type _CreateFolderRequest CreateFolderRequest
// NewCreateFolderRequest instantiates a new CreateFolderRequest object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewCreateFolderRequest(name string) *CreateFolderRequest {
this := CreateFolderRequest{}
this.Name = name
return &this
}
// NewCreateFolderRequestWithDefaults instantiates a new CreateFolderRequest object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewCreateFolderRequestWithDefaults() *CreateFolderRequest {
this := CreateFolderRequest{}
return &this
}
// GetName returns the Name field value
func (o *CreateFolderRequest) GetName() string {
if o == nil {
var ret string
return ret
}
return o.Name
}
// GetNameOk returns a tuple with the Name field value
// and a boolean to check if the value has been set.
func (o *CreateFolderRequest) GetNameOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Name, true
}
// SetName sets field value
func (o *CreateFolderRequest) SetName(v string) {
o.Name = v
}
// GetParentId returns the ParentId field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *CreateFolderRequest) GetParentId() string {
if o == nil || IsNil(o.ParentId.Get()) {
var ret string
return ret
}
return *o.ParentId.Get()
}
// GetParentIdOk returns a tuple with the ParentId field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *CreateFolderRequest) GetParentIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.ParentId.Get(), o.ParentId.IsSet()
}
// HasParentId returns a boolean if a field has been set.
func (o *CreateFolderRequest) HasParentId() bool {
if o != nil && o.ParentId.IsSet() {
return true
}
return false
}
// SetParentId gets a reference to the given NullableString and assigns it to the ParentId field.
func (o *CreateFolderRequest) SetParentId(v string) {
o.ParentId.Set(&v)
}
// SetParentIdNil sets the value for ParentId to be an explicit nil
func (o *CreateFolderRequest) SetParentIdNil() {
o.ParentId.Set(nil)
}
// UnsetParentId ensures that no value is present for ParentId, not even an explicit nil
func (o *CreateFolderRequest) UnsetParentId() {
o.ParentId.Unset()
}
func (o CreateFolderRequest) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o CreateFolderRequest) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["name"] = o.Name
if o.ParentId.IsSet() {
toSerialize["parent_id"] = o.ParentId.Get()
}
return toSerialize, nil
}
func (o *CreateFolderRequest) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"name",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varCreateFolderRequest := _CreateFolderRequest{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varCreateFolderRequest)
if err != nil {
return err
}
*o = CreateFolderRequest(varCreateFolderRequest)
return err
}
type NullableCreateFolderRequest struct {
value *CreateFolderRequest
isSet bool
}
func (v NullableCreateFolderRequest) Get() *CreateFolderRequest {
return v.value
}
func (v *NullableCreateFolderRequest) Set(val *CreateFolderRequest) {
v.value = val
v.isSet = true
}
func (v NullableCreateFolderRequest) IsSet() bool {
return v.isSet
}
func (v *NullableCreateFolderRequest) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableCreateFolderRequest(val *CreateFolderRequest) *NullableCreateFolderRequest {
return &NullableCreateFolderRequest{value: val, isSet: true}
}
func (v NullableCreateFolderRequest) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableCreateFolderRequest) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,232 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the CreateKnowledgePageResponse type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &CreateKnowledgePageResponse{}
// CreateKnowledgePageResponse Result of creating a page: the node id, its mental model, and the refresh op.
type CreateKnowledgePageResponse struct {
PageId string `json:"page_id"`
MentalModelId string `json:"mental_model_id"`
OperationId NullableString `json:"operation_id,omitempty"`
}
type _CreateKnowledgePageResponse CreateKnowledgePageResponse
// NewCreateKnowledgePageResponse instantiates a new CreateKnowledgePageResponse object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewCreateKnowledgePageResponse(pageId string, mentalModelId string) *CreateKnowledgePageResponse {
this := CreateKnowledgePageResponse{}
this.PageId = pageId
this.MentalModelId = mentalModelId
return &this
}
// NewCreateKnowledgePageResponseWithDefaults instantiates a new CreateKnowledgePageResponse object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewCreateKnowledgePageResponseWithDefaults() *CreateKnowledgePageResponse {
this := CreateKnowledgePageResponse{}
return &this
}
// GetPageId returns the PageId field value
func (o *CreateKnowledgePageResponse) GetPageId() string {
if o == nil {
var ret string
return ret
}
return o.PageId
}
// GetPageIdOk returns a tuple with the PageId field value
// and a boolean to check if the value has been set.
func (o *CreateKnowledgePageResponse) GetPageIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.PageId, true
}
// SetPageId sets field value
func (o *CreateKnowledgePageResponse) SetPageId(v string) {
o.PageId = v
}
// GetMentalModelId returns the MentalModelId field value
func (o *CreateKnowledgePageResponse) GetMentalModelId() string {
if o == nil {
var ret string
return ret
}
return o.MentalModelId
}
// GetMentalModelIdOk returns a tuple with the MentalModelId field value
// and a boolean to check if the value has been set.
func (o *CreateKnowledgePageResponse) GetMentalModelIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.MentalModelId, true
}
// SetMentalModelId sets field value
func (o *CreateKnowledgePageResponse) SetMentalModelId(v string) {
o.MentalModelId = v
}
// GetOperationId returns the OperationId field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *CreateKnowledgePageResponse) GetOperationId() string {
if o == nil || IsNil(o.OperationId.Get()) {
var ret string
return ret
}
return *o.OperationId.Get()
}
// GetOperationIdOk returns a tuple with the OperationId field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *CreateKnowledgePageResponse) GetOperationIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.OperationId.Get(), o.OperationId.IsSet()
}
// HasOperationId returns a boolean if a field has been set.
func (o *CreateKnowledgePageResponse) HasOperationId() bool {
if o != nil && o.OperationId.IsSet() {
return true
}
return false
}
// SetOperationId gets a reference to the given NullableString and assigns it to the OperationId field.
func (o *CreateKnowledgePageResponse) SetOperationId(v string) {
o.OperationId.Set(&v)
}
// SetOperationIdNil sets the value for OperationId to be an explicit nil
func (o *CreateKnowledgePageResponse) SetOperationIdNil() {
o.OperationId.Set(nil)
}
// UnsetOperationId ensures that no value is present for OperationId, not even an explicit nil
func (o *CreateKnowledgePageResponse) UnsetOperationId() {
o.OperationId.Unset()
}
func (o CreateKnowledgePageResponse) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o CreateKnowledgePageResponse) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["page_id"] = o.PageId
toSerialize["mental_model_id"] = o.MentalModelId
if o.OperationId.IsSet() {
toSerialize["operation_id"] = o.OperationId.Get()
}
return toSerialize, nil
}
func (o *CreateKnowledgePageResponse) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"page_id",
"mental_model_id",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varCreateKnowledgePageResponse := _CreateKnowledgePageResponse{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varCreateKnowledgePageResponse)
if err != nil {
return err
}
*o = CreateKnowledgePageResponse(varCreateKnowledgePageResponse)
return err
}
type NullableCreateKnowledgePageResponse struct {
value *CreateKnowledgePageResponse
isSet bool
}
func (v NullableCreateKnowledgePageResponse) Get() *CreateKnowledgePageResponse {
return v.value
}
func (v *NullableCreateKnowledgePageResponse) Set(val *CreateKnowledgePageResponse) {
v.value = val
v.isSet = true
}
func (v NullableCreateKnowledgePageResponse) IsSet() bool {
return v.isSet
}
func (v *NullableCreateKnowledgePageResponse) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableCreateKnowledgePageResponse(val *CreateKnowledgePageResponse) *NullableCreateKnowledgePageResponse {
return &NullableCreateKnowledgePageResponse{value: val, isSet: true}
}
func (v NullableCreateKnowledgePageResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableCreateKnowledgePageResponse) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,361 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the CreatePageRequest type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &CreatePageRequest{}
// CreatePageRequest Create a page (a mental model + tree node) under an optional parent folder.
type CreatePageRequest struct {
Name string `json:"name"`
SourceQuery string `json:"source_query"`
ParentId NullableString `json:"parent_id,omitempty"`
Tags []string `json:"tags,omitempty"`
MaxTokens NullableInt32 `json:"max_tokens,omitempty"`
Trigger NullableMentalModelTriggerInput `json:"trigger,omitempty"`
}
type _CreatePageRequest CreatePageRequest
// NewCreatePageRequest instantiates a new CreatePageRequest object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewCreatePageRequest(name string, sourceQuery string) *CreatePageRequest {
this := CreatePageRequest{}
this.Name = name
this.SourceQuery = sourceQuery
return &this
}
// NewCreatePageRequestWithDefaults instantiates a new CreatePageRequest object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewCreatePageRequestWithDefaults() *CreatePageRequest {
this := CreatePageRequest{}
return &this
}
// GetName returns the Name field value
func (o *CreatePageRequest) GetName() string {
if o == nil {
var ret string
return ret
}
return o.Name
}
// GetNameOk returns a tuple with the Name field value
// and a boolean to check if the value has been set.
func (o *CreatePageRequest) GetNameOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Name, true
}
// SetName sets field value
func (o *CreatePageRequest) SetName(v string) {
o.Name = v
}
// GetSourceQuery returns the SourceQuery field value
func (o *CreatePageRequest) GetSourceQuery() string {
if o == nil {
var ret string
return ret
}
return o.SourceQuery
}
// GetSourceQueryOk returns a tuple with the SourceQuery field value
// and a boolean to check if the value has been set.
func (o *CreatePageRequest) GetSourceQueryOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.SourceQuery, true
}
// SetSourceQuery sets field value
func (o *CreatePageRequest) SetSourceQuery(v string) {
o.SourceQuery = v
}
// GetParentId returns the ParentId field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *CreatePageRequest) GetParentId() string {
if o == nil || IsNil(o.ParentId.Get()) {
var ret string
return ret
}
return *o.ParentId.Get()
}
// GetParentIdOk returns a tuple with the ParentId field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *CreatePageRequest) GetParentIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.ParentId.Get(), o.ParentId.IsSet()
}
// HasParentId returns a boolean if a field has been set.
func (o *CreatePageRequest) HasParentId() bool {
if o != nil && o.ParentId.IsSet() {
return true
}
return false
}
// SetParentId gets a reference to the given NullableString and assigns it to the ParentId field.
func (o *CreatePageRequest) SetParentId(v string) {
o.ParentId.Set(&v)
}
// SetParentIdNil sets the value for ParentId to be an explicit nil
func (o *CreatePageRequest) SetParentIdNil() {
o.ParentId.Set(nil)
}
// UnsetParentId ensures that no value is present for ParentId, not even an explicit nil
func (o *CreatePageRequest) UnsetParentId() {
o.ParentId.Unset()
}
// GetTags returns the Tags field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *CreatePageRequest) GetTags() []string {
if o == nil {
var ret []string
return ret
}
return o.Tags
}
// GetTagsOk returns a tuple with the Tags field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *CreatePageRequest) GetTagsOk() ([]string, bool) {
if o == nil || IsNil(o.Tags) {
return nil, false
}
return o.Tags, true
}
// HasTags returns a boolean if a field has been set.
func (o *CreatePageRequest) HasTags() bool {
if o != nil && !IsNil(o.Tags) {
return true
}
return false
}
// SetTags gets a reference to the given []string and assigns it to the Tags field.
func (o *CreatePageRequest) SetTags(v []string) {
o.Tags = v
}
// GetMaxTokens returns the MaxTokens field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *CreatePageRequest) GetMaxTokens() int32 {
if o == nil || IsNil(o.MaxTokens.Get()) {
var ret int32
return ret
}
return *o.MaxTokens.Get()
}
// GetMaxTokensOk returns a tuple with the MaxTokens field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *CreatePageRequest) GetMaxTokensOk() (*int32, bool) {
if o == nil {
return nil, false
}
return o.MaxTokens.Get(), o.MaxTokens.IsSet()
}
// HasMaxTokens returns a boolean if a field has been set.
func (o *CreatePageRequest) HasMaxTokens() bool {
if o != nil && o.MaxTokens.IsSet() {
return true
}
return false
}
// SetMaxTokens gets a reference to the given NullableInt32 and assigns it to the MaxTokens field.
func (o *CreatePageRequest) SetMaxTokens(v int32) {
o.MaxTokens.Set(&v)
}
// SetMaxTokensNil sets the value for MaxTokens to be an explicit nil
func (o *CreatePageRequest) SetMaxTokensNil() {
o.MaxTokens.Set(nil)
}
// UnsetMaxTokens ensures that no value is present for MaxTokens, not even an explicit nil
func (o *CreatePageRequest) UnsetMaxTokens() {
o.MaxTokens.Unset()
}
// GetTrigger returns the Trigger field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *CreatePageRequest) GetTrigger() MentalModelTriggerInput {
if o == nil || IsNil(o.Trigger.Get()) {
var ret MentalModelTriggerInput
return ret
}
return *o.Trigger.Get()
}
// GetTriggerOk returns a tuple with the Trigger field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *CreatePageRequest) GetTriggerOk() (*MentalModelTriggerInput, bool) {
if o == nil {
return nil, false
}
return o.Trigger.Get(), o.Trigger.IsSet()
}
// HasTrigger returns a boolean if a field has been set.
func (o *CreatePageRequest) HasTrigger() bool {
if o != nil && o.Trigger.IsSet() {
return true
}
return false
}
// SetTrigger gets a reference to the given NullableMentalModelTriggerInput and assigns it to the Trigger field.
func (o *CreatePageRequest) SetTrigger(v MentalModelTriggerInput) {
o.Trigger.Set(&v)
}
// SetTriggerNil sets the value for Trigger to be an explicit nil
func (o *CreatePageRequest) SetTriggerNil() {
o.Trigger.Set(nil)
}
// UnsetTrigger ensures that no value is present for Trigger, not even an explicit nil
func (o *CreatePageRequest) UnsetTrigger() {
o.Trigger.Unset()
}
func (o CreatePageRequest) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o CreatePageRequest) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["name"] = o.Name
toSerialize["source_query"] = o.SourceQuery
if o.ParentId.IsSet() {
toSerialize["parent_id"] = o.ParentId.Get()
}
if o.Tags != nil {
toSerialize["tags"] = o.Tags
}
if o.MaxTokens.IsSet() {
toSerialize["max_tokens"] = o.MaxTokens.Get()
}
if o.Trigger.IsSet() {
toSerialize["trigger"] = o.Trigger.Get()
}
return toSerialize, nil
}
func (o *CreatePageRequest) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"name",
"source_query",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varCreatePageRequest := _CreatePageRequest{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varCreatePageRequest)
if err != nil {
return err
}
*o = CreatePageRequest(varCreatePageRequest)
return err
}
type NullableCreatePageRequest struct {
value *CreatePageRequest
isSet bool
}
func (v NullableCreatePageRequest) Get() *CreatePageRequest {
return v.value
}
func (v *NullableCreatePageRequest) Set(val *CreatePageRequest) {
v.value = val
v.isSet = true
}
func (v NullableCreatePageRequest) IsSet() bool {
return v.isSet
}
func (v *NullableCreatePageRequest) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableCreatePageRequest(val *CreatePageRequest) *NullableCreatePageRequest {
return &NullableCreatePageRequest{value: val, isSet: true}
}
func (v NullableCreatePageRequest) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableCreatePageRequest) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,511 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the KnowledgeNode type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &KnowledgeNode{}
// KnowledgeNode A node in the knowledge-base tree — a folder or a page. Pages carry ``description``/``tags`` from their backing mental model. The knowledge base is client-managed (CRUD); ``managed`` lets a client tag a node as system-owned vs. hand-authored.
type KnowledgeNode struct {
Id string `json:"id"`
Kind string `json:"kind"`
Name string `json:"name"`
ParentId NullableString `json:"parent_id,omitempty"`
MentalModelId NullableString `json:"mental_model_id,omitempty"`
// Client-set flag: true = system-owned, false = hand-authored.
Managed *bool `json:"managed,omitempty"`
Description NullableString `json:"description,omitempty"`
Tags []string `json:"tags,omitempty"`
Timestamp NullableString `json:"timestamp,omitempty"`
Children []KnowledgeNode `json:"children,omitempty"`
}
type _KnowledgeNode KnowledgeNode
// NewKnowledgeNode instantiates a new KnowledgeNode object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewKnowledgeNode(id string, kind string, name string) *KnowledgeNode {
this := KnowledgeNode{}
this.Id = id
this.Kind = kind
this.Name = name
var managed bool = false
this.Managed = &managed
return &this
}
// NewKnowledgeNodeWithDefaults instantiates a new KnowledgeNode object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewKnowledgeNodeWithDefaults() *KnowledgeNode {
this := KnowledgeNode{}
var managed bool = false
this.Managed = &managed
return &this
}
// GetId returns the Id field value
func (o *KnowledgeNode) GetId() string {
if o == nil {
var ret string
return ret
}
return o.Id
}
// GetIdOk returns a tuple with the Id field value
// and a boolean to check if the value has been set.
func (o *KnowledgeNode) GetIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Id, true
}
// SetId sets field value
func (o *KnowledgeNode) SetId(v string) {
o.Id = v
}
// GetKind returns the Kind field value
func (o *KnowledgeNode) GetKind() string {
if o == nil {
var ret string
return ret
}
return o.Kind
}
// GetKindOk returns a tuple with the Kind field value
// and a boolean to check if the value has been set.
func (o *KnowledgeNode) GetKindOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Kind, true
}
// SetKind sets field value
func (o *KnowledgeNode) SetKind(v string) {
o.Kind = v
}
// GetName returns the Name field value
func (o *KnowledgeNode) GetName() string {
if o == nil {
var ret string
return ret
}
return o.Name
}
// GetNameOk returns a tuple with the Name field value
// and a boolean to check if the value has been set.
func (o *KnowledgeNode) GetNameOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Name, true
}
// SetName sets field value
func (o *KnowledgeNode) SetName(v string) {
o.Name = v
}
// GetParentId returns the ParentId field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *KnowledgeNode) GetParentId() string {
if o == nil || IsNil(o.ParentId.Get()) {
var ret string
return ret
}
return *o.ParentId.Get()
}
// GetParentIdOk returns a tuple with the ParentId field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *KnowledgeNode) GetParentIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.ParentId.Get(), o.ParentId.IsSet()
}
// HasParentId returns a boolean if a field has been set.
func (o *KnowledgeNode) HasParentId() bool {
if o != nil && o.ParentId.IsSet() {
return true
}
return false
}
// SetParentId gets a reference to the given NullableString and assigns it to the ParentId field.
func (o *KnowledgeNode) SetParentId(v string) {
o.ParentId.Set(&v)
}
// SetParentIdNil sets the value for ParentId to be an explicit nil
func (o *KnowledgeNode) SetParentIdNil() {
o.ParentId.Set(nil)
}
// UnsetParentId ensures that no value is present for ParentId, not even an explicit nil
func (o *KnowledgeNode) UnsetParentId() {
o.ParentId.Unset()
}
// GetMentalModelId returns the MentalModelId field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *KnowledgeNode) GetMentalModelId() string {
if o == nil || IsNil(o.MentalModelId.Get()) {
var ret string
return ret
}
return *o.MentalModelId.Get()
}
// GetMentalModelIdOk returns a tuple with the MentalModelId field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *KnowledgeNode) GetMentalModelIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.MentalModelId.Get(), o.MentalModelId.IsSet()
}
// HasMentalModelId returns a boolean if a field has been set.
func (o *KnowledgeNode) HasMentalModelId() bool {
if o != nil && o.MentalModelId.IsSet() {
return true
}
return false
}
// SetMentalModelId gets a reference to the given NullableString and assigns it to the MentalModelId field.
func (o *KnowledgeNode) SetMentalModelId(v string) {
o.MentalModelId.Set(&v)
}
// SetMentalModelIdNil sets the value for MentalModelId to be an explicit nil
func (o *KnowledgeNode) SetMentalModelIdNil() {
o.MentalModelId.Set(nil)
}
// UnsetMentalModelId ensures that no value is present for MentalModelId, not even an explicit nil
func (o *KnowledgeNode) UnsetMentalModelId() {
o.MentalModelId.Unset()
}
// GetManaged returns the Managed field value if set, zero value otherwise.
func (o *KnowledgeNode) GetManaged() bool {
if o == nil || IsNil(o.Managed) {
var ret bool
return ret
}
return *o.Managed
}
// GetManagedOk returns a tuple with the Managed field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *KnowledgeNode) GetManagedOk() (*bool, bool) {
if o == nil || IsNil(o.Managed) {
return nil, false
}
return o.Managed, true
}
// HasManaged returns a boolean if a field has been set.
func (o *KnowledgeNode) HasManaged() bool {
if o != nil && !IsNil(o.Managed) {
return true
}
return false
}
// SetManaged gets a reference to the given bool and assigns it to the Managed field.
func (o *KnowledgeNode) SetManaged(v bool) {
o.Managed = &v
}
// GetDescription returns the Description field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *KnowledgeNode) GetDescription() string {
if o == nil || IsNil(o.Description.Get()) {
var ret string
return ret
}
return *o.Description.Get()
}
// GetDescriptionOk returns a tuple with the Description field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *KnowledgeNode) GetDescriptionOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.Description.Get(), o.Description.IsSet()
}
// HasDescription returns a boolean if a field has been set.
func (o *KnowledgeNode) HasDescription() bool {
if o != nil && o.Description.IsSet() {
return true
}
return false
}
// SetDescription gets a reference to the given NullableString and assigns it to the Description field.
func (o *KnowledgeNode) SetDescription(v string) {
o.Description.Set(&v)
}
// SetDescriptionNil sets the value for Description to be an explicit nil
func (o *KnowledgeNode) SetDescriptionNil() {
o.Description.Set(nil)
}
// UnsetDescription ensures that no value is present for Description, not even an explicit nil
func (o *KnowledgeNode) UnsetDescription() {
o.Description.Unset()
}
// GetTags returns the Tags field value if set, zero value otherwise.
func (o *KnowledgeNode) GetTags() []string {
if o == nil || IsNil(o.Tags) {
var ret []string
return ret
}
return o.Tags
}
// GetTagsOk returns a tuple with the Tags field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *KnowledgeNode) GetTagsOk() ([]string, bool) {
if o == nil || IsNil(o.Tags) {
return nil, false
}
return o.Tags, true
}
// HasTags returns a boolean if a field has been set.
func (o *KnowledgeNode) HasTags() bool {
if o != nil && !IsNil(o.Tags) {
return true
}
return false
}
// SetTags gets a reference to the given []string and assigns it to the Tags field.
func (o *KnowledgeNode) SetTags(v []string) {
o.Tags = v
}
// GetTimestamp returns the Timestamp field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *KnowledgeNode) GetTimestamp() string {
if o == nil || IsNil(o.Timestamp.Get()) {
var ret string
return ret
}
return *o.Timestamp.Get()
}
// GetTimestampOk returns a tuple with the Timestamp field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *KnowledgeNode) GetTimestampOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.Timestamp.Get(), o.Timestamp.IsSet()
}
// HasTimestamp returns a boolean if a field has been set.
func (o *KnowledgeNode) HasTimestamp() bool {
if o != nil && o.Timestamp.IsSet() {
return true
}
return false
}
// SetTimestamp gets a reference to the given NullableString and assigns it to the Timestamp field.
func (o *KnowledgeNode) SetTimestamp(v string) {
o.Timestamp.Set(&v)
}
// SetTimestampNil sets the value for Timestamp to be an explicit nil
func (o *KnowledgeNode) SetTimestampNil() {
o.Timestamp.Set(nil)
}
// UnsetTimestamp ensures that no value is present for Timestamp, not even an explicit nil
func (o *KnowledgeNode) UnsetTimestamp() {
o.Timestamp.Unset()
}
// GetChildren returns the Children field value if set, zero value otherwise.
func (o *KnowledgeNode) GetChildren() []KnowledgeNode {
if o == nil || IsNil(o.Children) {
var ret []KnowledgeNode
return ret
}
return o.Children
}
// GetChildrenOk returns a tuple with the Children field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *KnowledgeNode) GetChildrenOk() ([]KnowledgeNode, bool) {
if o == nil || IsNil(o.Children) {
return nil, false
}
return o.Children, true
}
// HasChildren returns a boolean if a field has been set.
func (o *KnowledgeNode) HasChildren() bool {
if o != nil && !IsNil(o.Children) {
return true
}
return false
}
// SetChildren gets a reference to the given []KnowledgeNode and assigns it to the Children field.
func (o *KnowledgeNode) SetChildren(v []KnowledgeNode) {
o.Children = v
}
func (o KnowledgeNode) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o KnowledgeNode) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["id"] = o.Id
toSerialize["kind"] = o.Kind
toSerialize["name"] = o.Name
if o.ParentId.IsSet() {
toSerialize["parent_id"] = o.ParentId.Get()
}
if o.MentalModelId.IsSet() {
toSerialize["mental_model_id"] = o.MentalModelId.Get()
}
if !IsNil(o.Managed) {
toSerialize["managed"] = o.Managed
}
if o.Description.IsSet() {
toSerialize["description"] = o.Description.Get()
}
if !IsNil(o.Tags) {
toSerialize["tags"] = o.Tags
}
if o.Timestamp.IsSet() {
toSerialize["timestamp"] = o.Timestamp.Get()
}
if !IsNil(o.Children) {
toSerialize["children"] = o.Children
}
return toSerialize, nil
}
func (o *KnowledgeNode) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"id",
"kind",
"name",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varKnowledgeNode := _KnowledgeNode{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varKnowledgeNode)
if err != nil {
return err
}
*o = KnowledgeNode(varKnowledgeNode)
return err
}
type NullableKnowledgeNode struct {
value *KnowledgeNode
isSet bool
}
func (v NullableKnowledgeNode) Get() *KnowledgeNode {
return v.value
}
func (v *NullableKnowledgeNode) Set(val *KnowledgeNode) {
v.value = val
v.isSet = true
}
func (v NullableKnowledgeNode) IsSet() bool {
return v.isSet
}
func (v *NullableKnowledgeNode) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableKnowledgeNode(val *KnowledgeNode) *NullableKnowledgeNode {
return &NullableKnowledgeNode{value: val, isSet: true}
}
func (v NullableKnowledgeNode) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableKnowledgeNode) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,186 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the KnowledgePageBundleFile type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &KnowledgePageBundleFile{}
// KnowledgePageBundleFile One file in a portable OKF bundle.
type KnowledgePageBundleFile struct {
Path string `json:"path"`
Content string `json:"content"`
}
type _KnowledgePageBundleFile KnowledgePageBundleFile
// NewKnowledgePageBundleFile instantiates a new KnowledgePageBundleFile object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewKnowledgePageBundleFile(path string, content string) *KnowledgePageBundleFile {
this := KnowledgePageBundleFile{}
this.Path = path
this.Content = content
return &this
}
// NewKnowledgePageBundleFileWithDefaults instantiates a new KnowledgePageBundleFile object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewKnowledgePageBundleFileWithDefaults() *KnowledgePageBundleFile {
this := KnowledgePageBundleFile{}
return &this
}
// GetPath returns the Path field value
func (o *KnowledgePageBundleFile) GetPath() string {
if o == nil {
var ret string
return ret
}
return o.Path
}
// GetPathOk returns a tuple with the Path field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageBundleFile) GetPathOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Path, true
}
// SetPath sets field value
func (o *KnowledgePageBundleFile) SetPath(v string) {
o.Path = v
}
// GetContent returns the Content field value
func (o *KnowledgePageBundleFile) GetContent() string {
if o == nil {
var ret string
return ret
}
return o.Content
}
// GetContentOk returns a tuple with the Content field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageBundleFile) GetContentOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Content, true
}
// SetContent sets field value
func (o *KnowledgePageBundleFile) SetContent(v string) {
o.Content = v
}
func (o KnowledgePageBundleFile) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o KnowledgePageBundleFile) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["path"] = o.Path
toSerialize["content"] = o.Content
return toSerialize, nil
}
func (o *KnowledgePageBundleFile) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"path",
"content",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varKnowledgePageBundleFile := _KnowledgePageBundleFile{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varKnowledgePageBundleFile)
if err != nil {
return err
}
*o = KnowledgePageBundleFile(varKnowledgePageBundleFile)
return err
}
type NullableKnowledgePageBundleFile struct {
value *KnowledgePageBundleFile
isSet bool
}
func (v NullableKnowledgePageBundleFile) Get() *KnowledgePageBundleFile {
return v.value
}
func (v *NullableKnowledgePageBundleFile) Set(val *KnowledgePageBundleFile) {
v.value = val
v.isSet = true
}
func (v NullableKnowledgePageBundleFile) IsSet() bool {
return v.isSet
}
func (v *NullableKnowledgePageBundleFile) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableKnowledgePageBundleFile(val *KnowledgePageBundleFile) *NullableKnowledgePageBundleFile {
return &NullableKnowledgePageBundleFile{value: val, isSet: true}
}
func (v NullableKnowledgePageBundleFile) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableKnowledgePageBundleFile) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,158 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the KnowledgePageBundleResponse type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &KnowledgePageBundleResponse{}
// KnowledgePageBundleResponse A portable OKF bundle — a flat set of markdown files (index + pages + logs).
type KnowledgePageBundleResponse struct {
Files []KnowledgePageBundleFile `json:"files"`
}
type _KnowledgePageBundleResponse KnowledgePageBundleResponse
// NewKnowledgePageBundleResponse instantiates a new KnowledgePageBundleResponse object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewKnowledgePageBundleResponse(files []KnowledgePageBundleFile) *KnowledgePageBundleResponse {
this := KnowledgePageBundleResponse{}
this.Files = files
return &this
}
// NewKnowledgePageBundleResponseWithDefaults instantiates a new KnowledgePageBundleResponse object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewKnowledgePageBundleResponseWithDefaults() *KnowledgePageBundleResponse {
this := KnowledgePageBundleResponse{}
return &this
}
// GetFiles returns the Files field value
func (o *KnowledgePageBundleResponse) GetFiles() []KnowledgePageBundleFile {
if o == nil {
var ret []KnowledgePageBundleFile
return ret
}
return o.Files
}
// GetFilesOk returns a tuple with the Files field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageBundleResponse) GetFilesOk() ([]KnowledgePageBundleFile, bool) {
if o == nil {
return nil, false
}
return o.Files, true
}
// SetFiles sets field value
func (o *KnowledgePageBundleResponse) SetFiles(v []KnowledgePageBundleFile) {
o.Files = v
}
func (o KnowledgePageBundleResponse) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o KnowledgePageBundleResponse) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["files"] = o.Files
return toSerialize, nil
}
func (o *KnowledgePageBundleResponse) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"files",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varKnowledgePageBundleResponse := _KnowledgePageBundleResponse{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varKnowledgePageBundleResponse)
if err != nil {
return err
}
*o = KnowledgePageBundleResponse(varKnowledgePageBundleResponse)
return err
}
type NullableKnowledgePageBundleResponse struct {
value *KnowledgePageBundleResponse
isSet bool
}
func (v NullableKnowledgePageBundleResponse) Get() *KnowledgePageBundleResponse {
return v.value
}
func (v *NullableKnowledgePageBundleResponse) Set(val *KnowledgePageBundleResponse) {
v.value = val
v.isSet = true
}
func (v NullableKnowledgePageBundleResponse) IsSet() bool {
return v.isSet
}
func (v *NullableKnowledgePageBundleResponse) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableKnowledgePageBundleResponse(val *KnowledgePageBundleResponse) *NullableKnowledgePageBundleResponse {
return &NullableKnowledgePageBundleResponse{value: val, isSet: true}
}
func (v NullableKnowledgePageBundleResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableKnowledgePageBundleResponse) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,242 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the KnowledgePageGraphResponse type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &KnowledgePageGraphResponse{}
// KnowledgePageGraphResponse Constellation graph of knowledge pages linked by shared tags.
type KnowledgePageGraphResponse struct {
Nodes []map[string]interface{} `json:"nodes"`
Edges []map[string]interface{} `json:"edges"`
TotalPages int32 `json:"total_pages"`
TotalEdges int32 `json:"total_edges"`
}
type _KnowledgePageGraphResponse KnowledgePageGraphResponse
// NewKnowledgePageGraphResponse instantiates a new KnowledgePageGraphResponse object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewKnowledgePageGraphResponse(nodes []map[string]interface{}, edges []map[string]interface{}, totalPages int32, totalEdges int32) *KnowledgePageGraphResponse {
this := KnowledgePageGraphResponse{}
this.Nodes = nodes
this.Edges = edges
this.TotalPages = totalPages
this.TotalEdges = totalEdges
return &this
}
// NewKnowledgePageGraphResponseWithDefaults instantiates a new KnowledgePageGraphResponse object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewKnowledgePageGraphResponseWithDefaults() *KnowledgePageGraphResponse {
this := KnowledgePageGraphResponse{}
return &this
}
// GetNodes returns the Nodes field value
func (o *KnowledgePageGraphResponse) GetNodes() []map[string]interface{} {
if o == nil {
var ret []map[string]interface{}
return ret
}
return o.Nodes
}
// GetNodesOk returns a tuple with the Nodes field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageGraphResponse) GetNodesOk() ([]map[string]interface{}, bool) {
if o == nil {
return nil, false
}
return o.Nodes, true
}
// SetNodes sets field value
func (o *KnowledgePageGraphResponse) SetNodes(v []map[string]interface{}) {
o.Nodes = v
}
// GetEdges returns the Edges field value
func (o *KnowledgePageGraphResponse) GetEdges() []map[string]interface{} {
if o == nil {
var ret []map[string]interface{}
return ret
}
return o.Edges
}
// GetEdgesOk returns a tuple with the Edges field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageGraphResponse) GetEdgesOk() ([]map[string]interface{}, bool) {
if o == nil {
return nil, false
}
return o.Edges, true
}
// SetEdges sets field value
func (o *KnowledgePageGraphResponse) SetEdges(v []map[string]interface{}) {
o.Edges = v
}
// GetTotalPages returns the TotalPages field value
func (o *KnowledgePageGraphResponse) GetTotalPages() int32 {
if o == nil {
var ret int32
return ret
}
return o.TotalPages
}
// GetTotalPagesOk returns a tuple with the TotalPages field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageGraphResponse) GetTotalPagesOk() (*int32, bool) {
if o == nil {
return nil, false
}
return &o.TotalPages, true
}
// SetTotalPages sets field value
func (o *KnowledgePageGraphResponse) SetTotalPages(v int32) {
o.TotalPages = v
}
// GetTotalEdges returns the TotalEdges field value
func (o *KnowledgePageGraphResponse) GetTotalEdges() int32 {
if o == nil {
var ret int32
return ret
}
return o.TotalEdges
}
// GetTotalEdgesOk returns a tuple with the TotalEdges field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageGraphResponse) GetTotalEdgesOk() (*int32, bool) {
if o == nil {
return nil, false
}
return &o.TotalEdges, true
}
// SetTotalEdges sets field value
func (o *KnowledgePageGraphResponse) SetTotalEdges(v int32) {
o.TotalEdges = v
}
func (o KnowledgePageGraphResponse) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o KnowledgePageGraphResponse) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["nodes"] = o.Nodes
toSerialize["edges"] = o.Edges
toSerialize["total_pages"] = o.TotalPages
toSerialize["total_edges"] = o.TotalEdges
return toSerialize, nil
}
func (o *KnowledgePageGraphResponse) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"nodes",
"edges",
"total_pages",
"total_edges",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varKnowledgePageGraphResponse := _KnowledgePageGraphResponse{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varKnowledgePageGraphResponse)
if err != nil {
return err
}
*o = KnowledgePageGraphResponse(varKnowledgePageGraphResponse)
return err
}
type NullableKnowledgePageGraphResponse struct {
value *KnowledgePageGraphResponse
isSet bool
}
func (v NullableKnowledgePageGraphResponse) Get() *KnowledgePageGraphResponse {
return v.value
}
func (v *NullableKnowledgePageGraphResponse) Set(val *KnowledgePageGraphResponse) {
v.value = val
v.isSet = true
}
func (v NullableKnowledgePageGraphResponse) IsSet() bool {
return v.isSet
}
func (v *NullableKnowledgePageGraphResponse) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableKnowledgePageGraphResponse(val *KnowledgePageGraphResponse) *NullableKnowledgePageGraphResponse {
return &NullableKnowledgePageGraphResponse{value: val, isSet: true}
}
func (v NullableKnowledgePageGraphResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableKnowledgePageGraphResponse) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,418 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the KnowledgePageResponse type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &KnowledgePageResponse{}
// KnowledgePageResponse A knowledge page rendered as an OKF document.
type KnowledgePageResponse struct {
Id string `json:"id"`
Name string `json:"name"`
// OKF document type — from a `type:<x>` tag, else 'knowledge-page'.
Type string `json:"type"`
Description NullableString `json:"description,omitempty"`
Tags []string `json:"tags,omitempty"`
Timestamp NullableString `json:"timestamp,omitempty"`
Body NullableString `json:"body,omitempty"`
// The full OKF document: YAML frontmatter + markdown body.
Markdown string `json:"markdown"`
}
type _KnowledgePageResponse KnowledgePageResponse
// NewKnowledgePageResponse instantiates a new KnowledgePageResponse object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewKnowledgePageResponse(id string, name string, type_ string, markdown string) *KnowledgePageResponse {
this := KnowledgePageResponse{}
this.Id = id
this.Name = name
this.Type = type_
this.Markdown = markdown
return &this
}
// NewKnowledgePageResponseWithDefaults instantiates a new KnowledgePageResponse object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewKnowledgePageResponseWithDefaults() *KnowledgePageResponse {
this := KnowledgePageResponse{}
return &this
}
// GetId returns the Id field value
func (o *KnowledgePageResponse) GetId() string {
if o == nil {
var ret string
return ret
}
return o.Id
}
// GetIdOk returns a tuple with the Id field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageResponse) GetIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Id, true
}
// SetId sets field value
func (o *KnowledgePageResponse) SetId(v string) {
o.Id = v
}
// GetName returns the Name field value
func (o *KnowledgePageResponse) GetName() string {
if o == nil {
var ret string
return ret
}
return o.Name
}
// GetNameOk returns a tuple with the Name field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageResponse) GetNameOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Name, true
}
// SetName sets field value
func (o *KnowledgePageResponse) SetName(v string) {
o.Name = v
}
// GetType returns the Type field value
func (o *KnowledgePageResponse) GetType() string {
if o == nil {
var ret string
return ret
}
return o.Type
}
// GetTypeOk returns a tuple with the Type field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageResponse) GetTypeOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Type, true
}
// SetType sets field value
func (o *KnowledgePageResponse) SetType(v string) {
o.Type = v
}
// GetDescription returns the Description field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *KnowledgePageResponse) GetDescription() string {
if o == nil || IsNil(o.Description.Get()) {
var ret string
return ret
}
return *o.Description.Get()
}
// GetDescriptionOk returns a tuple with the Description field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *KnowledgePageResponse) GetDescriptionOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.Description.Get(), o.Description.IsSet()
}
// HasDescription returns a boolean if a field has been set.
func (o *KnowledgePageResponse) HasDescription() bool {
if o != nil && o.Description.IsSet() {
return true
}
return false
}
// SetDescription gets a reference to the given NullableString and assigns it to the Description field.
func (o *KnowledgePageResponse) SetDescription(v string) {
o.Description.Set(&v)
}
// SetDescriptionNil sets the value for Description to be an explicit nil
func (o *KnowledgePageResponse) SetDescriptionNil() {
o.Description.Set(nil)
}
// UnsetDescription ensures that no value is present for Description, not even an explicit nil
func (o *KnowledgePageResponse) UnsetDescription() {
o.Description.Unset()
}
// GetTags returns the Tags field value if set, zero value otherwise.
func (o *KnowledgePageResponse) GetTags() []string {
if o == nil || IsNil(o.Tags) {
var ret []string
return ret
}
return o.Tags
}
// GetTagsOk returns a tuple with the Tags field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *KnowledgePageResponse) GetTagsOk() ([]string, bool) {
if o == nil || IsNil(o.Tags) {
return nil, false
}
return o.Tags, true
}
// HasTags returns a boolean if a field has been set.
func (o *KnowledgePageResponse) HasTags() bool {
if o != nil && !IsNil(o.Tags) {
return true
}
return false
}
// SetTags gets a reference to the given []string and assigns it to the Tags field.
func (o *KnowledgePageResponse) SetTags(v []string) {
o.Tags = v
}
// GetTimestamp returns the Timestamp field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *KnowledgePageResponse) GetTimestamp() string {
if o == nil || IsNil(o.Timestamp.Get()) {
var ret string
return ret
}
return *o.Timestamp.Get()
}
// GetTimestampOk returns a tuple with the Timestamp field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *KnowledgePageResponse) GetTimestampOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.Timestamp.Get(), o.Timestamp.IsSet()
}
// HasTimestamp returns a boolean if a field has been set.
func (o *KnowledgePageResponse) HasTimestamp() bool {
if o != nil && o.Timestamp.IsSet() {
return true
}
return false
}
// SetTimestamp gets a reference to the given NullableString and assigns it to the Timestamp field.
func (o *KnowledgePageResponse) SetTimestamp(v string) {
o.Timestamp.Set(&v)
}
// SetTimestampNil sets the value for Timestamp to be an explicit nil
func (o *KnowledgePageResponse) SetTimestampNil() {
o.Timestamp.Set(nil)
}
// UnsetTimestamp ensures that no value is present for Timestamp, not even an explicit nil
func (o *KnowledgePageResponse) UnsetTimestamp() {
o.Timestamp.Unset()
}
// GetBody returns the Body field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *KnowledgePageResponse) GetBody() string {
if o == nil || IsNil(o.Body.Get()) {
var ret string
return ret
}
return *o.Body.Get()
}
// GetBodyOk returns a tuple with the Body field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *KnowledgePageResponse) GetBodyOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.Body.Get(), o.Body.IsSet()
}
// HasBody returns a boolean if a field has been set.
func (o *KnowledgePageResponse) HasBody() bool {
if o != nil && o.Body.IsSet() {
return true
}
return false
}
// SetBody gets a reference to the given NullableString and assigns it to the Body field.
func (o *KnowledgePageResponse) SetBody(v string) {
o.Body.Set(&v)
}
// SetBodyNil sets the value for Body to be an explicit nil
func (o *KnowledgePageResponse) SetBodyNil() {
o.Body.Set(nil)
}
// UnsetBody ensures that no value is present for Body, not even an explicit nil
func (o *KnowledgePageResponse) UnsetBody() {
o.Body.Unset()
}
// GetMarkdown returns the Markdown field value
func (o *KnowledgePageResponse) GetMarkdown() string {
if o == nil {
var ret string
return ret
}
return o.Markdown
}
// GetMarkdownOk returns a tuple with the Markdown field value
// and a boolean to check if the value has been set.
func (o *KnowledgePageResponse) GetMarkdownOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Markdown, true
}
// SetMarkdown sets field value
func (o *KnowledgePageResponse) SetMarkdown(v string) {
o.Markdown = v
}
func (o KnowledgePageResponse) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o KnowledgePageResponse) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["id"] = o.Id
toSerialize["name"] = o.Name
toSerialize["type"] = o.Type
if o.Description.IsSet() {
toSerialize["description"] = o.Description.Get()
}
if !IsNil(o.Tags) {
toSerialize["tags"] = o.Tags
}
if o.Timestamp.IsSet() {
toSerialize["timestamp"] = o.Timestamp.Get()
}
if o.Body.IsSet() {
toSerialize["body"] = o.Body.Get()
}
toSerialize["markdown"] = o.Markdown
return toSerialize, nil
}
func (o *KnowledgePageResponse) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"id",
"name",
"type",
"markdown",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varKnowledgePageResponse := _KnowledgePageResponse{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varKnowledgePageResponse)
if err != nil {
return err
}
*o = KnowledgePageResponse(varKnowledgePageResponse)
return err
}
type NullableKnowledgePageResponse struct {
value *KnowledgePageResponse
isSet bool
}
func (v NullableKnowledgePageResponse) Get() *KnowledgePageResponse {
return v.value
}
func (v *NullableKnowledgePageResponse) Set(val *KnowledgePageResponse) {
v.value = val
v.isSet = true
}
func (v NullableKnowledgePageResponse) IsSet() bool {
return v.isSet
}
func (v *NullableKnowledgePageResponse) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableKnowledgePageResponse(val *KnowledgePageResponse) *NullableKnowledgePageResponse {
return &NullableKnowledgePageResponse{value: val, isSet: true}
}
func (v NullableKnowledgePageResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableKnowledgePageResponse) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,158 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the KnowledgeTreeResponse type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &KnowledgeTreeResponse{}
// KnowledgeTreeResponse The knowledge base as a nested folder/page tree.
type KnowledgeTreeResponse struct {
Roots []KnowledgeNode `json:"roots"`
}
type _KnowledgeTreeResponse KnowledgeTreeResponse
// NewKnowledgeTreeResponse instantiates a new KnowledgeTreeResponse object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewKnowledgeTreeResponse(roots []KnowledgeNode) *KnowledgeTreeResponse {
this := KnowledgeTreeResponse{}
this.Roots = roots
return &this
}
// NewKnowledgeTreeResponseWithDefaults instantiates a new KnowledgeTreeResponse object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewKnowledgeTreeResponseWithDefaults() *KnowledgeTreeResponse {
this := KnowledgeTreeResponse{}
return &this
}
// GetRoots returns the Roots field value
func (o *KnowledgeTreeResponse) GetRoots() []KnowledgeNode {
if o == nil {
var ret []KnowledgeNode
return ret
}
return o.Roots
}
// GetRootsOk returns a tuple with the Roots field value
// and a boolean to check if the value has been set.
func (o *KnowledgeTreeResponse) GetRootsOk() ([]KnowledgeNode, bool) {
if o == nil {
return nil, false
}
return o.Roots, true
}
// SetRoots sets field value
func (o *KnowledgeTreeResponse) SetRoots(v []KnowledgeNode) {
o.Roots = v
}
func (o KnowledgeTreeResponse) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o KnowledgeTreeResponse) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["roots"] = o.Roots
return toSerialize, nil
}
func (o *KnowledgeTreeResponse) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"roots",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varKnowledgeTreeResponse := _KnowledgeTreeResponse{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varKnowledgeTreeResponse)
if err != nil {
return err
}
*o = KnowledgeTreeResponse(varKnowledgeTreeResponse)
return err
}
type NullableKnowledgeTreeResponse struct {
value *KnowledgeTreeResponse
isSet bool
}
func (v NullableKnowledgeTreeResponse) Get() *KnowledgeTreeResponse {
return v.value
}
func (v *NullableKnowledgeTreeResponse) Set(val *KnowledgeTreeResponse) {
v.value = val
v.isSet = true
}
func (v NullableKnowledgeTreeResponse) IsSet() bool {
return v.isSet
}
func (v *NullableKnowledgeTreeResponse) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableKnowledgeTreeResponse(val *KnowledgeTreeResponse) *NullableKnowledgeTreeResponse {
return &NullableKnowledgeTreeResponse{value: val, isSet: true}
}
func (v NullableKnowledgeTreeResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableKnowledgeTreeResponse) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,182 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
)
// checks if the UpdateNodeRequest type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &UpdateNodeRequest{}
// UpdateNodeRequest Rename and/or move a node. Each field applies only when present.
type UpdateNodeRequest struct {
Name NullableString `json:"name,omitempty"`
ParentId NullableString `json:"parent_id,omitempty"`
}
// NewUpdateNodeRequest instantiates a new UpdateNodeRequest object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewUpdateNodeRequest() *UpdateNodeRequest {
this := UpdateNodeRequest{}
return &this
}
// NewUpdateNodeRequestWithDefaults instantiates a new UpdateNodeRequest object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewUpdateNodeRequestWithDefaults() *UpdateNodeRequest {
this := UpdateNodeRequest{}
return &this
}
// GetName returns the Name field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *UpdateNodeRequest) GetName() string {
if o == nil || IsNil(o.Name.Get()) {
var ret string
return ret
}
return *o.Name.Get()
}
// GetNameOk returns a tuple with the Name field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *UpdateNodeRequest) GetNameOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.Name.Get(), o.Name.IsSet()
}
// HasName returns a boolean if a field has been set.
func (o *UpdateNodeRequest) HasName() bool {
if o != nil && o.Name.IsSet() {
return true
}
return false
}
// SetName gets a reference to the given NullableString and assigns it to the Name field.
func (o *UpdateNodeRequest) SetName(v string) {
o.Name.Set(&v)
}
// SetNameNil sets the value for Name to be an explicit nil
func (o *UpdateNodeRequest) SetNameNil() {
o.Name.Set(nil)
}
// UnsetName ensures that no value is present for Name, not even an explicit nil
func (o *UpdateNodeRequest) UnsetName() {
o.Name.Unset()
}
// GetParentId returns the ParentId field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *UpdateNodeRequest) GetParentId() string {
if o == nil || IsNil(o.ParentId.Get()) {
var ret string
return ret
}
return *o.ParentId.Get()
}
// GetParentIdOk returns a tuple with the ParentId field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *UpdateNodeRequest) GetParentIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.ParentId.Get(), o.ParentId.IsSet()
}
// HasParentId returns a boolean if a field has been set.
func (o *UpdateNodeRequest) HasParentId() bool {
if o != nil && o.ParentId.IsSet() {
return true
}
return false
}
// SetParentId gets a reference to the given NullableString and assigns it to the ParentId field.
func (o *UpdateNodeRequest) SetParentId(v string) {
o.ParentId.Set(&v)
}
// SetParentIdNil sets the value for ParentId to be an explicit nil
func (o *UpdateNodeRequest) SetParentIdNil() {
o.ParentId.Set(nil)
}
// UnsetParentId ensures that no value is present for ParentId, not even an explicit nil
func (o *UpdateNodeRequest) UnsetParentId() {
o.ParentId.Unset()
}
func (o UpdateNodeRequest) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o UpdateNodeRequest) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
if o.Name.IsSet() {
toSerialize["name"] = o.Name.Get()
}
if o.ParentId.IsSet() {
toSerialize["parent_id"] = o.ParentId.Get()
}
return toSerialize, nil
}
type NullableUpdateNodeRequest struct {
value *UpdateNodeRequest
isSet bool
}
func (v NullableUpdateNodeRequest) Get() *UpdateNodeRequest {
return v.value
}
func (v *NullableUpdateNodeRequest) Set(val *UpdateNodeRequest) {
v.value = val
v.isSet = true
}
func (v NullableUpdateNodeRequest) IsSet() bool {
return v.isSet
}
func (v *NullableUpdateNodeRequest) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableUpdateNodeRequest(val *UpdateNodeRequest) *NullableUpdateNodeRequest {
return &NullableUpdateNodeRequest{value: val, isSet: true}
}
func (v NullableUpdateNodeRequest) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableUpdateNodeRequest) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -9,6 +9,7 @@ hindsight_client_api/api/document_transfer_api.py
hindsight_client_api/api/documents_api.py
hindsight_client_api/api/entities_api.py
hindsight_client_api/api/files_api.py
hindsight_client_api/api/knowledge_base_api.py
hindsight_client_api/api/llm_traces_api.py
hindsight_client_api/api/memory_api.py
hindsight_client_api/api/mental_models_api.py
@@ -50,8 +51,11 @@ hindsight_client_api/models/consolidation_request.py
hindsight_client_api/models/consolidation_response.py
hindsight_client_api/models/create_bank_request.py
hindsight_client_api/models/create_directive_request.py
hindsight_client_api/models/create_folder_request.py
hindsight_client_api/models/create_knowledge_page_response.py
hindsight_client_api/models/create_mental_model_request.py
hindsight_client_api/models/create_mental_model_response.py
hindsight_client_api/models/create_page_request.py
hindsight_client_api/models/create_webhook_request.py
hindsight_client_api/models/delete_document_response.py
hindsight_client_api/models/delete_response.py
@@ -76,6 +80,12 @@ hindsight_client_api/models/file_retain_response.py
hindsight_client_api/models/graph_data_response.py
hindsight_client_api/models/http_validation_error.py
hindsight_client_api/models/include_options.py
hindsight_client_api/models/knowledge_node.py
hindsight_client_api/models/knowledge_page_bundle_file.py
hindsight_client_api/models/knowledge_page_bundle_response.py
hindsight_client_api/models/knowledge_page_graph_response.py
hindsight_client_api/models/knowledge_page_response.py
hindsight_client_api/models/knowledge_tree_response.py
hindsight_client_api/models/list_chunks_response.py
hindsight_client_api/models/list_documents_response.py
hindsight_client_api/models/list_memory_units_response.py
@@ -142,6 +152,7 @@ hindsight_client_api/models/update_document_request.py
hindsight_client_api/models/update_document_response.py
hindsight_client_api/models/update_memory_request.py
hindsight_client_api/models/update_mental_model_request.py
hindsight_client_api/models/update_node_request.py
hindsight_client_api/models/update_webhook_request.py
hindsight_client_api/models/validation_error.py
hindsight_client_api/models/validation_error_loc_inner.py
@@ -25,6 +25,7 @@ from hindsight_client_api.api.document_transfer_api import DocumentTransferApi
from hindsight_client_api.api.documents_api import DocumentsApi
from hindsight_client_api.api.entities_api import EntitiesApi
from hindsight_client_api.api.files_api import FilesApi
from hindsight_client_api.api.knowledge_base_api import KnowledgeBaseApi
from hindsight_client_api.api.llm_traces_api import LLMTracesApi
from hindsight_client_api.api.memory_api import MemoryApi
from hindsight_client_api.api.mental_models_api import MentalModelsApi
@@ -74,8 +75,11 @@ from hindsight_client_api.models.consolidation_request import ConsolidationReque
from hindsight_client_api.models.consolidation_response import ConsolidationResponse
from hindsight_client_api.models.create_bank_request import CreateBankRequest
from hindsight_client_api.models.create_directive_request import CreateDirectiveRequest
from hindsight_client_api.models.create_folder_request import CreateFolderRequest
from hindsight_client_api.models.create_knowledge_page_response import CreateKnowledgePageResponse
from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest
from hindsight_client_api.models.create_mental_model_response import CreateMentalModelResponse
from hindsight_client_api.models.create_page_request import CreatePageRequest
from hindsight_client_api.models.create_webhook_request import CreateWebhookRequest
from hindsight_client_api.models.delete_document_response import DeleteDocumentResponse
from hindsight_client_api.models.delete_response import DeleteResponse
@@ -100,6 +104,12 @@ from hindsight_client_api.models.file_retain_response import FileRetainResponse
from hindsight_client_api.models.graph_data_response import GraphDataResponse
from hindsight_client_api.models.http_validation_error import HTTPValidationError
from hindsight_client_api.models.include_options import IncludeOptions
from hindsight_client_api.models.knowledge_node import KnowledgeNode
from hindsight_client_api.models.knowledge_page_bundle_file import KnowledgePageBundleFile
from hindsight_client_api.models.knowledge_page_bundle_response import KnowledgePageBundleResponse
from hindsight_client_api.models.knowledge_page_graph_response import KnowledgePageGraphResponse
from hindsight_client_api.models.knowledge_page_response import KnowledgePageResponse
from hindsight_client_api.models.knowledge_tree_response import KnowledgeTreeResponse
from hindsight_client_api.models.llm_request_entry import LLMRequestEntry
from hindsight_client_api.models.llm_request_list_response import LLMRequestListResponse
from hindsight_client_api.models.llm_request_stats_bucket import LLMRequestStatsBucket
@@ -166,6 +176,7 @@ from hindsight_client_api.models.update_document_request import UpdateDocumentRe
from hindsight_client_api.models.update_document_response import UpdateDocumentResponse
from hindsight_client_api.models.update_memory_request import UpdateMemoryRequest
from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest
from hindsight_client_api.models.update_node_request import UpdateNodeRequest
from hindsight_client_api.models.update_webhook_request import UpdateWebhookRequest
from hindsight_client_api.models.validation_error import ValidationError
from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner
@@ -9,6 +9,7 @@ from hindsight_client_api.api.document_transfer_api import DocumentTransferApi
from hindsight_client_api.api.documents_api import DocumentsApi
from hindsight_client_api.api.entities_api import EntitiesApi
from hindsight_client_api.api.files_api import FilesApi
from hindsight_client_api.api.knowledge_base_api import KnowledgeBaseApi
from hindsight_client_api.api.llm_traces_api import LLMTracesApi
from hindsight_client_api.api.memory_api import MemoryApi
from hindsight_client_api.api.mental_models_api import MentalModelsApi
File diff suppressed because it is too large Load Diff
@@ -44,8 +44,11 @@ from hindsight_client_api.models.consolidation_request import ConsolidationReque
from hindsight_client_api.models.consolidation_response import ConsolidationResponse
from hindsight_client_api.models.create_bank_request import CreateBankRequest
from hindsight_client_api.models.create_directive_request import CreateDirectiveRequest
from hindsight_client_api.models.create_folder_request import CreateFolderRequest
from hindsight_client_api.models.create_knowledge_page_response import CreateKnowledgePageResponse
from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest
from hindsight_client_api.models.create_mental_model_response import CreateMentalModelResponse
from hindsight_client_api.models.create_page_request import CreatePageRequest
from hindsight_client_api.models.create_webhook_request import CreateWebhookRequest
from hindsight_client_api.models.delete_document_response import DeleteDocumentResponse
from hindsight_client_api.models.delete_response import DeleteResponse
@@ -70,6 +73,12 @@ from hindsight_client_api.models.file_retain_response import FileRetainResponse
from hindsight_client_api.models.graph_data_response import GraphDataResponse
from hindsight_client_api.models.http_validation_error import HTTPValidationError
from hindsight_client_api.models.include_options import IncludeOptions
from hindsight_client_api.models.knowledge_node import KnowledgeNode
from hindsight_client_api.models.knowledge_page_bundle_file import KnowledgePageBundleFile
from hindsight_client_api.models.knowledge_page_bundle_response import KnowledgePageBundleResponse
from hindsight_client_api.models.knowledge_page_graph_response import KnowledgePageGraphResponse
from hindsight_client_api.models.knowledge_page_response import KnowledgePageResponse
from hindsight_client_api.models.knowledge_tree_response import KnowledgeTreeResponse
from hindsight_client_api.models.llm_request_entry import LLMRequestEntry
from hindsight_client_api.models.llm_request_list_response import LLMRequestListResponse
from hindsight_client_api.models.llm_request_stats_bucket import LLMRequestStatsBucket
@@ -136,6 +145,7 @@ from hindsight_client_api.models.update_document_request import UpdateDocumentRe
from hindsight_client_api.models.update_document_response import UpdateDocumentResponse
from hindsight_client_api.models.update_memory_request import UpdateMemoryRequest
from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest
from hindsight_client_api.models.update_node_request import UpdateNodeRequest
from hindsight_client_api.models.update_webhook_request import UpdateWebhookRequest
from hindsight_client_api.models.validation_error import ValidationError
from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner
@@ -0,0 +1,94 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class CreateFolderRequest(BaseModel):
"""
Create a folder under an optional parent folder.
""" # noqa: E501
name: StrictStr
parent_id: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["name", "parent_id"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of CreateFolderRequest from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# set to None if parent_id (nullable) is None
# and model_fields_set contains the field
if self.parent_id is None and "parent_id" in self.model_fields_set:
_dict['parent_id'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of CreateFolderRequest from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"name": obj.get("name"),
"parent_id": obj.get("parent_id")
})
return _obj
@@ -0,0 +1,96 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class CreateKnowledgePageResponse(BaseModel):
"""
Result of creating a page: the node id, its mental model, and the refresh op.
""" # noqa: E501
page_id: StrictStr
mental_model_id: StrictStr
operation_id: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["page_id", "mental_model_id", "operation_id"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of CreateKnowledgePageResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# set to None if operation_id (nullable) is None
# and model_fields_set contains the field
if self.operation_id is None and "operation_id" in self.model_fields_set:
_dict['operation_id'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of CreateKnowledgePageResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"page_id": obj.get("page_id"),
"mental_model_id": obj.get("mental_model_id"),
"operation_id": obj.get("operation_id")
})
return _obj
@@ -0,0 +1,121 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictInt, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.mental_model_trigger_input import MentalModelTriggerInput
from typing import Optional, Set
from typing_extensions import Self
class CreatePageRequest(BaseModel):
"""
Create a page (a mental model + tree node) under an optional parent folder.
""" # noqa: E501
name: StrictStr
source_query: StrictStr
parent_id: Optional[StrictStr] = None
tags: Optional[List[StrictStr]] = None
max_tokens: Optional[StrictInt] = None
trigger: Optional[MentalModelTriggerInput] = None
__properties: ClassVar[List[str]] = ["name", "source_query", "parent_id", "tags", "max_tokens", "trigger"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of CreatePageRequest from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of trigger
if self.trigger:
_dict['trigger'] = self.trigger.to_dict()
# set to None if parent_id (nullable) is None
# and model_fields_set contains the field
if self.parent_id is None and "parent_id" in self.model_fields_set:
_dict['parent_id'] = None
# set to None if tags (nullable) is None
# and model_fields_set contains the field
if self.tags is None and "tags" in self.model_fields_set:
_dict['tags'] = None
# set to None if max_tokens (nullable) is None
# and model_fields_set contains the field
if self.max_tokens is None and "max_tokens" in self.model_fields_set:
_dict['max_tokens'] = None
# set to None if trigger (nullable) is None
# and model_fields_set contains the field
if self.trigger is None and "trigger" in self.model_fields_set:
_dict['trigger'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of CreatePageRequest from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"name": obj.get("name"),
"source_query": obj.get("source_query"),
"parent_id": obj.get("parent_id"),
"tags": obj.get("tags"),
"max_tokens": obj.get("max_tokens"),
"trigger": MentalModelTriggerInput.from_dict(obj["trigger"]) if obj.get("trigger") is not None else None
})
return _obj
@@ -0,0 +1,141 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr, field_validator
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class KnowledgeNode(BaseModel):
"""
A node in the knowledge-base tree — a folder or a page. Pages carry ``description``/``tags`` from their backing mental model. The knowledge base is client-managed (CRUD); ``managed`` lets a client tag a node as system-owned vs. hand-authored.
""" # noqa: E501
id: StrictStr
kind: StrictStr
name: StrictStr
parent_id: Optional[StrictStr] = None
mental_model_id: Optional[StrictStr] = None
managed: Optional[StrictBool] = Field(default=False, description="Client-set flag: true = system-owned, false = hand-authored.")
description: Optional[StrictStr] = None
tags: Optional[List[StrictStr]] = None
timestamp: Optional[StrictStr] = None
children: Optional[List[KnowledgeNode]] = None
__properties: ClassVar[List[str]] = ["id", "kind", "name", "parent_id", "mental_model_id", "managed", "description", "tags", "timestamp", "children"]
@field_validator('kind')
def kind_validate_enum(cls, value):
"""Validates the enum"""
if value not in set(['folder', 'page']):
raise ValueError("must be one of enum values ('folder', 'page')")
return value
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of KnowledgeNode from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of each item in children (list)
_items = []
if self.children:
for _item_children in self.children:
if _item_children:
_items.append(_item_children.to_dict())
_dict['children'] = _items
# set to None if parent_id (nullable) is None
# and model_fields_set contains the field
if self.parent_id is None and "parent_id" in self.model_fields_set:
_dict['parent_id'] = None
# set to None if mental_model_id (nullable) is None
# and model_fields_set contains the field
if self.mental_model_id is None and "mental_model_id" in self.model_fields_set:
_dict['mental_model_id'] = None
# set to None if description (nullable) is None
# and model_fields_set contains the field
if self.description is None and "description" in self.model_fields_set:
_dict['description'] = None
# set to None if timestamp (nullable) is None
# and model_fields_set contains the field
if self.timestamp is None and "timestamp" in self.model_fields_set:
_dict['timestamp'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of KnowledgeNode from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"id": obj.get("id"),
"kind": obj.get("kind"),
"name": obj.get("name"),
"parent_id": obj.get("parent_id"),
"mental_model_id": obj.get("mental_model_id"),
"managed": obj.get("managed") if obj.get("managed") is not None else False,
"description": obj.get("description"),
"tags": obj.get("tags"),
"timestamp": obj.get("timestamp"),
"children": [KnowledgeNode.from_dict(_item) for _item in obj["children"]] if obj.get("children") is not None else None
})
return _obj
# TODO: Rewrite to not use raise_errors
KnowledgeNode.model_rebuild(raise_errors=False)
@@ -0,0 +1,89 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictStr
from typing import Any, ClassVar, Dict, List
from typing import Optional, Set
from typing_extensions import Self
class KnowledgePageBundleFile(BaseModel):
"""
One file in a portable OKF bundle.
""" # noqa: E501
path: StrictStr
content: StrictStr
__properties: ClassVar[List[str]] = ["path", "content"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of KnowledgePageBundleFile from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of KnowledgePageBundleFile from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"path": obj.get("path"),
"content": obj.get("content")
})
return _obj
@@ -0,0 +1,95 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict
from typing import Any, ClassVar, Dict, List
from hindsight_client_api.models.knowledge_page_bundle_file import KnowledgePageBundleFile
from typing import Optional, Set
from typing_extensions import Self
class KnowledgePageBundleResponse(BaseModel):
"""
A portable OKF bundle — a flat set of markdown files (index + pages + logs).
""" # noqa: E501
files: List[KnowledgePageBundleFile]
__properties: ClassVar[List[str]] = ["files"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of KnowledgePageBundleResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of each item in files (list)
_items = []
if self.files:
for _item_files in self.files:
if _item_files:
_items.append(_item_files.to_dict())
_dict['files'] = _items
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of KnowledgePageBundleResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"files": [KnowledgePageBundleFile.from_dict(_item) for _item in obj["files"]] if obj.get("files") is not None else None
})
return _obj
@@ -0,0 +1,93 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictInt
from typing import Any, ClassVar, Dict, List
from typing import Optional, Set
from typing_extensions import Self
class KnowledgePageGraphResponse(BaseModel):
"""
Constellation graph of knowledge pages linked by shared tags.
""" # noqa: E501
nodes: List[Dict[str, Any]]
edges: List[Dict[str, Any]]
total_pages: StrictInt
total_edges: StrictInt
__properties: ClassVar[List[str]] = ["nodes", "edges", "total_pages", "total_edges"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of KnowledgePageGraphResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of KnowledgePageGraphResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"nodes": obj.get("nodes"),
"edges": obj.get("edges"),
"total_pages": obj.get("total_pages"),
"total_edges": obj.get("total_edges")
})
return _obj
@@ -0,0 +1,116 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class KnowledgePageResponse(BaseModel):
"""
A knowledge page rendered as an OKF document.
""" # noqa: E501
id: StrictStr
name: StrictStr
type: StrictStr = Field(description="OKF document type — from a `type:<x>` tag, else 'knowledge-page'.")
description: Optional[StrictStr] = None
tags: Optional[List[StrictStr]] = None
timestamp: Optional[StrictStr] = None
body: Optional[StrictStr] = None
markdown: StrictStr = Field(description="The full OKF document: YAML frontmatter + markdown body.")
__properties: ClassVar[List[str]] = ["id", "name", "type", "description", "tags", "timestamp", "body", "markdown"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of KnowledgePageResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# set to None if description (nullable) is None
# and model_fields_set contains the field
if self.description is None and "description" in self.model_fields_set:
_dict['description'] = None
# set to None if timestamp (nullable) is None
# and model_fields_set contains the field
if self.timestamp is None and "timestamp" in self.model_fields_set:
_dict['timestamp'] = None
# set to None if body (nullable) is None
# and model_fields_set contains the field
if self.body is None and "body" in self.model_fields_set:
_dict['body'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of KnowledgePageResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"id": obj.get("id"),
"name": obj.get("name"),
"type": obj.get("type"),
"description": obj.get("description"),
"tags": obj.get("tags"),
"timestamp": obj.get("timestamp"),
"body": obj.get("body"),
"markdown": obj.get("markdown")
})
return _obj
@@ -0,0 +1,95 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict
from typing import Any, ClassVar, Dict, List
from hindsight_client_api.models.knowledge_node import KnowledgeNode
from typing import Optional, Set
from typing_extensions import Self
class KnowledgeTreeResponse(BaseModel):
"""
The knowledge base as a nested folder/page tree.
""" # noqa: E501
roots: List[KnowledgeNode]
__properties: ClassVar[List[str]] = ["roots"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of KnowledgeTreeResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of each item in roots (list)
_items = []
if self.roots:
for _item_roots in self.roots:
if _item_roots:
_items.append(_item_roots.to_dict())
_dict['roots'] = _items
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of KnowledgeTreeResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"roots": [KnowledgeNode.from_dict(_item) for _item in obj["roots"]] if obj.get("roots") is not None else None
})
return _obj
@@ -0,0 +1,99 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.8.3
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class UpdateNodeRequest(BaseModel):
"""
Rename and/or move a node. Each field applies only when present.
""" # noqa: E501
name: Optional[StrictStr] = None
parent_id: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["name", "parent_id"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of UpdateNodeRequest from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# set to None if name (nullable) is None
# and model_fields_set contains the field
if self.name is None and "name" in self.model_fields_set:
_dict['name'] = None
# set to None if parent_id (nullable) is None
# and model_fields_set contains the field
if self.parent_id is None and "parent_id" in self.model_fields_set:
_dict['parent_id'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of UpdateNodeRequest from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"name": obj.get("name"),
"parent_id": obj.get("parent_id")
})
return _obj
@@ -32,6 +32,12 @@ import type {
CreateDirectiveData,
CreateDirectiveErrors,
CreateDirectiveResponses,
CreateKnowledgeFolderData,
CreateKnowledgeFolderErrors,
CreateKnowledgeFolderResponses,
CreateKnowledgePageData,
CreateKnowledgePageErrors,
CreateKnowledgePageResponses,
CreateMentalModelData,
CreateMentalModelErrors,
CreateMentalModelResponses,
@@ -50,6 +56,9 @@ import type {
DeleteDocumentData,
DeleteDocumentErrors,
DeleteDocumentResponses,
DeleteKnowledgeNodeData,
DeleteKnowledgeNodeErrors,
DeleteKnowledgeNodeResponses,
DeleteMentalModelData,
DeleteMentalModelErrors,
DeleteMentalModelResponses,
@@ -65,6 +74,9 @@ import type {
ExportDocumentsData,
ExportDocumentsErrors,
ExportDocumentsResponses,
ExportKnowledgeBaseData,
ExportKnowledgeBaseErrors,
ExportKnowledgeBaseResponses,
FileRetainData,
FileRetainErrors,
FileRetainResponses,
@@ -97,6 +109,15 @@ import type {
GetGraphData,
GetGraphErrors,
GetGraphResponses,
GetKnowledgeBaseGraphData,
GetKnowledgeBaseGraphErrors,
GetKnowledgeBaseGraphResponses,
GetKnowledgeBaseTreeData,
GetKnowledgeBaseTreeErrors,
GetKnowledgeBaseTreeResponses,
GetKnowledgePageData,
GetKnowledgePageErrors,
GetKnowledgePageResponses,
GetMemoriesTimeseriesData,
GetMemoriesTimeseriesErrors,
GetMemoriesTimeseriesResponses,
@@ -220,6 +241,9 @@ import type {
UpdateDocumentData,
UpdateDocumentErrors,
UpdateDocumentResponses,
UpdateKnowledgeNodeData,
UpdateKnowledgeNodeErrors,
UpdateKnowledgeNodeResponses,
UpdateMemoryData,
UpdateMemoryErrors,
UpdateMemoryResponses,
@@ -654,6 +678,138 @@ export const clearMentalModel = <ThrowOnError extends boolean = false>(
...options,
});
/**
* Get the knowledge-base tree
*
* Return the knowledge base as a nested tree of folders and pages.
*/
export const getKnowledgeBaseTree = <ThrowOnError extends boolean = false>(
options: Options<GetKnowledgeBaseTreeData, ThrowOnError>
) =>
(options.client ?? client).get<
GetKnowledgeBaseTreeResponses,
GetKnowledgeBaseTreeErrors,
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/knowledge-base/tree", ...options });
/**
* Create a knowledge-base folder
*
* Create a folder, optionally nested under a parent folder.
*/
export const createKnowledgeFolder = <ThrowOnError extends boolean = false>(
options: Options<CreateKnowledgeFolderData, ThrowOnError>
) =>
(options.client ?? client).post<
CreateKnowledgeFolderResponses,
CreateKnowledgeFolderErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/knowledge-base/folders",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
* Create a knowledge-base page
*
* Create a page (a mental model + tree node). Content is generated asynchronously; use the returned operation_id to track completion.
*/
export const createKnowledgePage = <ThrowOnError extends boolean = false>(
options: Options<CreateKnowledgePageData, ThrowOnError>
) =>
(options.client ?? client).post<
CreateKnowledgePageResponses,
CreateKnowledgePageErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/knowledge-base/pages",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
* Knowledge-base constellation graph
*
* Return pages as nodes linked by shared tags, for the constellation view.
*/
export const getKnowledgeBaseGraph = <ThrowOnError extends boolean = false>(
options: Options<GetKnowledgeBaseGraphData, ThrowOnError>
) =>
(options.client ?? client).get<
GetKnowledgeBaseGraphResponses,
GetKnowledgeBaseGraphErrors,
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/knowledge-base/graph", ...options });
/**
* Export the knowledge base as an OKF bundle
*
* Return a portable OKF bundle: a nested index.md, one <id>.md per page, and history logs.
*/
export const exportKnowledgeBase = <ThrowOnError extends boolean = false>(
options: Options<ExportKnowledgeBaseData, ThrowOnError>
) =>
(options.client ?? client).get<
ExportKnowledgeBaseResponses,
ExportKnowledgeBaseErrors,
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/knowledge-base/export", ...options });
/**
* Get a knowledge-base page
*
* Return a single page as an OKF document (frontmatter + markdown body).
*/
export const getKnowledgePage = <ThrowOnError extends boolean = false>(
options: Options<GetKnowledgePageData, ThrowOnError>
) =>
(options.client ?? client).get<GetKnowledgePageResponses, GetKnowledgePageErrors, ThrowOnError>({
url: "/v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}",
...options,
});
/**
* Delete a knowledge-base node
*
* Delete a folder or page and its whole subtree (pages' mental models are removed too).
*/
export const deleteKnowledgeNode = <ThrowOnError extends boolean = false>(
options: Options<DeleteKnowledgeNodeData, ThrowOnError>
) =>
(options.client ?? client).delete<
DeleteKnowledgeNodeResponses,
DeleteKnowledgeNodeErrors,
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}", ...options });
/**
* Rename or move a knowledge-base node
*
* Rename a node (set `name`) and/or move it under another folder (set `parent_id`, null for the root).
*/
export const updateKnowledgeNode = <ThrowOnError extends boolean = false>(
options: Options<UpdateKnowledgeNodeData, ThrowOnError>
) =>
(options.client ?? client).patch<
UpdateKnowledgeNodeResponses,
UpdateKnowledgeNodeErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
* List directives
*
@@ -1153,6 +1153,42 @@ export type CreateDirectiveRequest = {
tags?: Array<string>;
};
/**
* CreateFolderRequest
*
* Create a folder under an optional parent folder.
*/
export type CreateFolderRequest = {
/**
* Name
*/
name: string;
/**
* Parent Id
*/
parent_id?: string | null;
};
/**
* CreateKnowledgePageResponse
*
* Result of creating a page: the node id, its mental model, and the refresh op.
*/
export type CreateKnowledgePageResponse = {
/**
* Page Id
*/
page_id: string;
/**
* Mental Model Id
*/
mental_model_id: string;
/**
* Operation Id
*/
operation_id?: string | null;
};
/**
* CreateMentalModelRequest
*
@@ -1215,6 +1251,35 @@ export type CreateMentalModelResponse = {
operation_id: string;
};
/**
* CreatePageRequest
*
* Create a page (a mental model + tree node) under an optional parent folder.
*/
export type CreatePageRequest = {
/**
* Name
*/
name: string;
/**
* Source Query
*/
source_query: string;
/**
* Parent Id
*/
parent_id?: string | null;
/**
* Tags
*/
tags?: Array<string> | null;
/**
* Max Tokens
*/
max_tokens?: number | null;
trigger?: MentalModelTriggerInput | null;
};
/**
* CreateWebhookRequest
*
@@ -1956,6 +2021,184 @@ export type IncludeOptions = {
source_facts?: SourceFactsIncludeOptions | null;
};
/**
* KnowledgeNode
*
* A node in the knowledge-base tree — a folder or a page.
*
* Pages carry ``description``/``tags`` from their backing mental model. The
* knowledge base is client-managed (CRUD); ``managed`` lets a client tag a node
* as system-owned vs. hand-authored.
*/
export type KnowledgeNode = {
/**
* Id
*/
id: string;
/**
* Kind
*/
kind: "folder" | "page";
/**
* Name
*/
name: string;
/**
* Parent Id
*/
parent_id?: string | null;
/**
* Mental Model Id
*
* Backing mental model id (pages only).
*/
mental_model_id?: string | null;
/**
* Managed
*
* Client-set flag: true = system-owned, false = hand-authored.
*/
managed?: boolean;
/**
* Description
*
* Page source query (OKF `description`).
*/
description?: string | null;
/**
* Tags
*/
tags?: Array<string>;
/**
* Timestamp
*
* Last refresh (page) or last update (folder).
*/
timestamp?: string | null;
/**
* Children
*/
children?: Array<KnowledgeNode>;
};
/**
* KnowledgePageBundleFile
*
* One file in a portable OKF bundle.
*/
export type KnowledgePageBundleFile = {
/**
* Path
*/
path: string;
/**
* Content
*/
content: string;
};
/**
* KnowledgePageBundleResponse
*
* A portable OKF bundle — a flat set of markdown files (index + pages + logs).
*/
export type KnowledgePageBundleResponse = {
/**
* Files
*/
files: Array<KnowledgePageBundleFile>;
};
/**
* KnowledgePageGraphResponse
*
* Constellation graph of knowledge pages linked by shared tags.
*/
export type KnowledgePageGraphResponse = {
/**
* Nodes
*/
nodes: Array<{
[key: string]: unknown;
}>;
/**
* Edges
*/
edges: Array<{
[key: string]: unknown;
}>;
/**
* Total Pages
*/
total_pages: number;
/**
* Total Edges
*/
total_edges: number;
};
/**
* KnowledgePageResponse
*
* A knowledge page rendered as an OKF document.
*/
export type KnowledgePageResponse = {
/**
* Id
*/
id: string;
/**
* Name
*/
name: string;
/**
* Type
*
* OKF document type — from a `type:<x>` tag, else 'knowledge-page'.
*/
type: string;
/**
* Description
*
* The source query that rebuilds the page.
*/
description?: string | null;
/**
* Tags
*/
tags?: Array<string>;
/**
* Timestamp
*
* Last refresh time (falls back to creation).
*/
timestamp?: string | null;
/**
* Body
*
* The page's synthesized markdown body.
*/
body?: string | null;
/**
* Markdown
*
* The full OKF document: YAML frontmatter + markdown body.
*/
markdown: string;
};
/**
* KnowledgeTreeResponse
*
* The knowledge base as a nested folder/page tree.
*/
export type KnowledgeTreeResponse = {
/**
* Roots
*/
roots: Array<KnowledgeNode>;
};
/**
* LLMRequestEntry
*
@@ -3938,6 +4181,22 @@ export type UpdateMentalModelRequest = {
trigger?: MentalModelTriggerInput | null;
};
/**
* UpdateNodeRequest
*
* Rename and/or move a node. Each field applies only when present.
*/
export type UpdateNodeRequest = {
/**
* Name
*/
name?: string | null;
/**
* Parent Id
*/
parent_id?: string | null;
};
/**
* UpdateWebhookRequest
*
@@ -5271,6 +5530,313 @@ export type ClearMentalModelResponses = {
export type ClearMentalModelResponse = ClearMentalModelResponses[keyof ClearMentalModelResponses];
export type GetKnowledgeBaseTreeData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/knowledge-base/tree";
};
export type GetKnowledgeBaseTreeErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetKnowledgeBaseTreeError =
GetKnowledgeBaseTreeErrors[keyof GetKnowledgeBaseTreeErrors];
export type GetKnowledgeBaseTreeResponses = {
/**
* Successful Response
*/
200: KnowledgeTreeResponse;
};
export type GetKnowledgeBaseTreeResponse =
GetKnowledgeBaseTreeResponses[keyof GetKnowledgeBaseTreeResponses];
export type CreateKnowledgeFolderData = {
body: CreateFolderRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/knowledge-base/folders";
};
export type CreateKnowledgeFolderErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type CreateKnowledgeFolderError =
CreateKnowledgeFolderErrors[keyof CreateKnowledgeFolderErrors];
export type CreateKnowledgeFolderResponses = {
/**
* Successful Response
*/
201: KnowledgeNode;
};
export type CreateKnowledgeFolderResponse =
CreateKnowledgeFolderResponses[keyof CreateKnowledgeFolderResponses];
export type CreateKnowledgePageData = {
body: CreatePageRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/knowledge-base/pages";
};
export type CreateKnowledgePageErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type CreateKnowledgePageError = CreateKnowledgePageErrors[keyof CreateKnowledgePageErrors];
export type CreateKnowledgePageResponses = {
/**
* Successful Response
*/
201: CreateKnowledgePageResponse;
};
export type CreateKnowledgePageResponse2 =
CreateKnowledgePageResponses[keyof CreateKnowledgePageResponses];
export type GetKnowledgeBaseGraphData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/knowledge-base/graph";
};
export type GetKnowledgeBaseGraphErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetKnowledgeBaseGraphError =
GetKnowledgeBaseGraphErrors[keyof GetKnowledgeBaseGraphErrors];
export type GetKnowledgeBaseGraphResponses = {
/**
* Successful Response
*/
200: KnowledgePageGraphResponse;
};
export type GetKnowledgeBaseGraphResponse =
GetKnowledgeBaseGraphResponses[keyof GetKnowledgeBaseGraphResponses];
export type ExportKnowledgeBaseData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/knowledge-base/export";
};
export type ExportKnowledgeBaseErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type ExportKnowledgeBaseError = ExportKnowledgeBaseErrors[keyof ExportKnowledgeBaseErrors];
export type ExportKnowledgeBaseResponses = {
/**
* Successful Response
*/
200: KnowledgePageBundleResponse;
};
export type ExportKnowledgeBaseResponse =
ExportKnowledgeBaseResponses[keyof ExportKnowledgeBaseResponses];
export type GetKnowledgePageData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
/**
* Page Id
*/
page_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}";
};
export type GetKnowledgePageErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetKnowledgePageError = GetKnowledgePageErrors[keyof GetKnowledgePageErrors];
export type GetKnowledgePageResponses = {
/**
* Successful Response
*/
200: KnowledgePageResponse;
};
export type GetKnowledgePageResponse = GetKnowledgePageResponses[keyof GetKnowledgePageResponses];
export type DeleteKnowledgeNodeData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
/**
* Node Id
*/
node_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}";
};
export type DeleteKnowledgeNodeErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type DeleteKnowledgeNodeError = DeleteKnowledgeNodeErrors[keyof DeleteKnowledgeNodeErrors];
export type DeleteKnowledgeNodeResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type UpdateKnowledgeNodeData = {
body: UpdateNodeRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
/**
* Node Id
*/
node_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}";
};
export type UpdateKnowledgeNodeErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UpdateKnowledgeNodeError = UpdateKnowledgeNodeErrors[keyof UpdateKnowledgeNodeErrors];
export type UpdateKnowledgeNodeResponses = {
/**
* Successful Response
*/
200: KnowledgeNode;
};
export type UpdateKnowledgeNodeResponse =
UpdateKnowledgeNodeResponses[keyof UpdateKnowledgeNodeResponses];
export type ListDirectivesData = {
body?: never;
headers?: {
+4
View File
@@ -38,12 +38,14 @@
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-visually-hidden": "^1.2.5",
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/typography": "^0.5.19",
"@types/cytoscape": "^3.21.9",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
@@ -53,6 +55,8 @@
"cmdk": "^1.1.1",
"cron-parser": "^5.6.1",
"cronstrue": "^3.21.0",
"cytoscape": "^3.33.1",
"cytoscape-fcose": "^2.2.0",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.1",
"lucide-react": "^0.553.0",
@@ -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>
@@ -8,8 +8,6 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank
const { searchParams } = new URL(request.url);
const tags = searchParams.getAll("tags");
const tagsMatch = searchParams.get("tags_match");
const limit = searchParams.get("limit");
const offset = searchParams.get("offset");
if (!bankId) {
return NextResponse.json(
@@ -28,12 +26,6 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank
if (tagsMatch) {
queryParams.append("tags_match", tagsMatch);
}
if (limit) {
queryParams.append("limit", limit);
}
if (offset) {
queryParams.append("offset", offset);
}
const url = dataplaneBankUrl(
bankId,
@@ -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 }
);
}
}
@@ -4,7 +4,7 @@ import { useRef, useEffect, useCallback, useMemo, useState } from "react";
import type { CSSProperties } from "react";
import { useTranslations } from "next-intl";
import { prepare, layout, prepareWithSegments, layoutWithLines } from "@chenglou/pretext";
import type { GraphData, GraphNode, GraphLink } from "./graph-data";
import type { GraphData, GraphNode, GraphLink } from "./graph-2d";
// ============================================================================
// Types
@@ -21,12 +21,6 @@ interface PreparedNode {
/** Color derived from link count (heat gradient) */
heatColor: string;
linkCount: number;
/**
* Per-node phase in [0, 2π), derived from the id hash. Desynchronizes the
* ambient drift + pulse so the field breathes organically instead of in
* lockstep. Precomputed here so the animation loop stays trig-only.
*/
phase: number;
}
// ============================================================================
@@ -378,7 +372,6 @@ export function Constellation({
// so the grouping reads at a glance; otherwise it keeps the heat gradient.
heatColor: centroid ? color : heat,
linkCount: lc,
phase: ((Math.abs(seed) % 1000) / 1000) * Math.PI * 2,
};
});
@@ -455,24 +448,15 @@ export function Constellation({
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
// Ambient-motion clock (seconds).
const time = (typeof performance !== "undefined" ? performance.now() : 0) / 1000;
// Drift amplitude in world units — nodes slowly wander around their home
// position so the whole field visibly breathes.
const DRIFT_AMP = 16;
// Screen positions (with a slow per-node ambient drift baked in, so links —
// which read straight from screenX/screenY below — follow for free).
// Screen positions
const screenX = new Float32Array(preparedNodes.length);
const screenY = new Float32Array(preparedNodes.length);
const visible = new Uint8Array(preparedNodes.length);
for (let i = 0; i < preparedNodes.length; i++) {
const n = preparedNodes[i];
const driftX = DRIFT_AMP * Math.sin(time * 0.6 + n.phase);
const driftY = DRIFT_AMP * Math.cos(time * 0.5 + n.phase * 1.3);
const sx = cx + (n.wx + driftX) * zoom;
const sy = cy + (n.wy + driftY) * zoom;
const sx = cx + n.wx * zoom;
const sy = cy + n.wy * zoom;
screenX[i] = sx;
screenY[i] = sy;
visible[i] = sx > -margin && sx < W + margin && sy > -margin && sy < H + margin ? 1 : 0;
@@ -524,39 +508,11 @@ export function Constellation({
ctx.moveTo(ax, ay);
ctx.quadraticCurveTo(midX, midY, bx, by);
ctx.stroke();
// A small bead of light travels the curve from the hovered node outward,
// so connections read as live signal paths rather than static lines.
{
// Phase-offset per link so beads don't march in lockstep. Travel runs
// from the hovered node (u=0) toward its neighbor (u=1).
const fromHovered = link.a === hoverIndex;
const raw = (time * 0.22 + (li % 13) / 13) % 1;
const u = fromHovered ? raw : 1 - raw;
const iu = 1 - u;
// Point on the quadratic Bézier at parameter u.
const px = iu * iu * ax + 2 * iu * u * midX + u * u * bx;
const py = iu * iu * ay + 2 * iu * u * midY + u * u * by;
ctx.globalAlpha = 0.9 * (0.4 + 0.6 * Math.sin(u * Math.PI)); // fade at the ends
ctx.fillStyle = link.color;
ctx.shadowColor = link.color;
ctx.shadowBlur = 6;
ctx.beginPath();
ctx.arc(px, py, 2, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
// Restore the stroke state the loop's next iteration expects.
ctx.globalAlpha = 0.5;
ctx.lineWidth = 1.5;
}
linksDrawn++;
}
ctx.globalAlpha = 1;
} else {
// Faint, slow breathing across the whole web so idle links feel alive
// without flickering (one global sine, not per-link — stays calm).
const shimmer = 1 + 0.18 * Math.sin(time * 0.6);
const baseAlpha = (0.06 + Math.min(zoom * 0.04, 0.1)) * shimmer;
const baseAlpha = 0.06 + Math.min(zoom * 0.04, 0.1);
ctx.lineWidth = 0.4;
for (const link of linksWithIndices) {
@@ -705,18 +661,11 @@ export function Constellation({
// Size varies slightly by link count — subtle range like star magnitudes.
// When nodeSizeFn is provided (e.g. entities view), it overrides linkCount
// sizing so dots can scale by an external weight like co-occurrence count.
const rawR = nodeSizeFn ? nodeSizeFn(n.node) : 2.5 + Math.min(n.linkCount * 0.15, 2.5);
// Gentle pulse — each dot "breathes" in size, out of phase with its
// neighbors, so the field twinkles like a living star map.
const pulse = 1 + 0.13 * Math.sin(time * 1.05 + n.phase);
const baseR = rawR * pulse;
const baseR = nodeSizeFn ? nodeSizeFn(n.node) : 2.5 + Math.min(n.linkCount * 0.15, 2.5);
const r = Math.max(1.5, baseR * Math.min(zoom, 2));
// Opacity varies — fewer links = dimmer, more links = brighter. A brightness
// twinkle (offset from the size pulse) makes even tiny dots read as alive,
// where a radius pulse alone would be imperceptible.
const twinkleAlpha = 0.82 + 0.18 * Math.sin(time * 1.4 + n.phase * 2.1);
const baseAlpha = (0.45 + Math.min(n.linkCount * 0.03, 0.5)) * twinkleAlpha;
// Opacity varies — fewer links = dimmer, more links = brighter
const baseAlpha = 0.45 + Math.min(n.linkCount * 0.03, 0.5);
// Dot — star-like: heat-gradient color, varied size & opacity
ctx.beginPath();
@@ -730,14 +679,12 @@ export function Constellation({
}
ctx.fill();
// Soft glow halo for brighter stars (high link count) — the halo twinkles
// a little (out of phase with the dot's pulse) so hubs feel radiant.
// Soft glow halo for brighter stars (high link count)
if (n.linkCount > 3 && !isHovered && hoverIndex < 0) {
const twinkle = 1 + 0.25 * Math.sin(time * 0.9 + n.phase * 1.7);
ctx.beginPath();
ctx.arc(sx, sy, r * 2, 0, Math.PI * 2);
ctx.fillStyle = n.heatColor;
ctx.globalAlpha = (0.06 + Math.min(n.linkCount * 0.005, 0.08)) * twinkle;
ctx.globalAlpha = 0.06 + Math.min(n.linkCount * 0.005, 0.08);
ctx.fill();
}
@@ -1172,17 +1119,9 @@ export function Constellation({
canvas.addEventListener("mouseup", handleMouseUp);
canvas.addEventListener("mouseleave", handleMouseLeave);
// The canvas also changes size when its container reflows (e.g. the side
// panel opening/closing) with no window "resize" event. Observe the element
// so the backing store is re-measured — otherwise CSS stretches the old
// bitmap and the text/dots look squeezed.
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => resize()) : null;
ro?.observe(canvas);
return () => {
cancelAnimationFrame(animRef.current);
window.removeEventListener("resize", handleResize);
ro?.disconnect();
canvas.removeEventListener("wheel", handleWheel);
canvas.removeEventListener("mousemove", handleMouseMove);
canvas.removeEventListener("mousedown", handleMouseDown);
@@ -16,9 +16,11 @@ import {
ChevronsRight,
Settings2,
Eye,
EyeOff,
RefreshCw,
CheckCircle,
Clock,
Network,
List,
Search,
Layers,
@@ -31,6 +33,8 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import {
Select,
@@ -39,15 +43,16 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { MemoryDetailPanel } from "./memory-detail-panel";
import { MemoryDetailModal } from "./memory-detail-modal";
import { convertHindsightGraphData, GraphNode } from "./graph-data";
import { Graph2D, convertHindsightGraphData, GraphNode } from "./graph-2d";
import { Constellation } from "./constellation";
import { TagFilterInput } from "./tag-filter-input";
import { ObservationScopeFilter, ObservationScope } from "./observation-scope-filter";
import { ScatterChart, Plus, FileText } from "lucide-react";
type FactType = "world" | "experience" | "observation";
type ViewMode = "table" | "timeline" | "constellation";
type ViewMode = "graph" | "table" | "timeline" | "constellation";
// Categorical palette for coloring observation scopes (exact tag sets) when
// "Group by scope" clusters the constellation. Distinct, reasonably separable hues.
@@ -100,6 +105,7 @@ export function DataView({
const [scopes, setScopes] = useState<ObservationScope[]>([]);
const [selectedScope, setSelectedScope] = useState<string[] | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [selectedGraphNode, setSelectedGraphNode] = useState<any>(null);
const [modalMemoryId, setModalMemoryId] = useState<string | null>(null);
// Table view: toggle between live facts (graph-fed) and invalidated facts (archive).
const [showInvalidated, setShowInvalidated] = useState(false);
@@ -126,7 +132,10 @@ export function DataView({
last_consolidated_at: string | null;
} | null>(null);
// Constellation controls state
// Graph controls state
const [showLabels, setShowLabels] = useState(true);
const [maxNodes, setMaxNodes] = useState<number | undefined>(undefined);
const [showControlPanel, setShowControlPanel] = useState(true);
const [visibleLinkTypes, setVisibleLinkTypes] = useState<Set<string>>(
new Set(["semantic", "temporal", "entity", "causal"])
);
@@ -143,6 +152,17 @@ export function DataView({
});
};
// Esc key handler to deselect graph node
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && selectedGraphNode) {
setSelectedGraphNode(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedGraphNode]);
// `silent` skips the loading spinner — used by the background consolidation
// poll so the view refreshes in place without flashing.
const loadData = async (
@@ -210,8 +230,6 @@ export function DataView({
if (showInvalidated) return invalidatedRows;
return data?.table_rows ?? [];
}, [data, showInvalidated, invalidatedRows]);
const hasActiveMemoryFilters =
searchQuery.trim().length > 0 || tagFilters.length > 0 || selectedScope !== null;
// Helper to get normalized link type
const getLinkTypeCategory = (type: string | undefined): string => {
@@ -221,7 +239,7 @@ export function DataView({
return "semantic";
};
// Convert data for the constellation (graph data is already filtered server-side)
// Convert data for Graph2D (graph data is already filtered server-side)
const graph2DData = useMemo(() => {
if (!data) return { nodes: [], links: [] };
const fullData = convertHindsightGraphData(data);
@@ -235,11 +253,44 @@ export function DataView({
return { nodes: fullData.nodes, links };
}, [data, visibleLinkTypes]);
// Calculate link stats for display
const linkStats = useMemo(() => {
let semantic = 0,
temporal = 0,
entity = 0,
causal = 0,
total = 0;
const otherTypes: Record<string, number> = {};
graph2DData.links.forEach((l) => {
total++;
const type = l.type || "unknown";
if (type === "semantic") semantic++;
else if (type === "temporal") temporal++;
else if (type === "entity") entity++;
else if (
type === "causes" ||
type === "caused_by" ||
type === "enables" ||
type === "prevents"
)
causal++;
else {
otherTypes[type] = (otherTypes[type] || 0) + 1;
}
});
return { semantic, temporal, entity, causal, total, otherTypes };
}, [graph2DData]);
// Handle node click in graph - show in panel
const handleGraphNodeClick = useCallback((node: GraphNode) => {
// Open the memory dialog for the clicked node (same dialog the table/timeline use).
setModalMemoryId(node.id);
}, []);
const handleGraphNodeClick = useCallback(
(node: GraphNode) => {
const nodeData = data?.table_rows?.find((row: any) => row.id === node.id);
if (nodeData) {
setSelectedGraphNode(nodeData);
}
},
[data]
);
// Memoized color functions to prevent graph re-initialization
// Uses brand colors: primary blue (#0074d9), teal (#009296), amber for entity, purple for causal
@@ -458,6 +509,19 @@ export function DataView({
return () => clearInterval(id);
}, [isConsolidating, currentBank]);
// Enforce 50 node limit to prevent UI instability, default to 20 or max whichever is smaller
useEffect(() => {
if (data && maxNodes === undefined) {
if (graph2DData.nodes.length > 50) {
// Always set maxNodes to 20 when we have >50 nodes (never leave as undefined)
setMaxNodes(20);
} else if (graph2DData.nodes.length > 20) {
setMaxNodes(20);
}
// If ≤20 nodes, leave maxNodes undefined to show all
}
}, [data, graph2DData.nodes.length, maxNodes]);
return (
<div>
{loading && !data ? (
@@ -465,7 +529,7 @@ export function DataView({
<RefreshCw className="w-8 h-8 mx-auto mb-3 text-muted-foreground animate-spin" />
<p className="text-muted-foreground">{t("loadingMemories")}</p>
</div>
) : data && data.total_units === 0 && !hasActiveMemoryFilters ? (
) : data && data.total_units === 0 ? (
<div className="text-center py-20">
<FileText className="w-10 h-10 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-base font-medium text-foreground mb-1">{t("noMemoriesYet")}</h3>
@@ -580,7 +644,7 @@ export function DataView({
</Button>
)}
<div className="text-sm text-muted-foreground">
{hasActiveMemoryFilters ? (
{searchQuery || tagFilters.length > 0 ? (
t("matchingMemories", { count: filteredTableRows.length })
) : data.table_rows?.length < data.total_units ? (
<span>
@@ -592,8 +656,11 @@ export function DataView({
onClick={() => {
const newLimit = Math.min(data.total_units, fetchLimit + 1000);
setFetchLimit(newLimit);
const { tags, match } = resolveTagQuery();
loadData(newLimit, searchQuery || undefined, tags, match);
loadData(
newLimit,
searchQuery || undefined,
tagFilters.length > 0 ? tagFilters : undefined
);
}}
className="ml-2 text-primary hover:underline"
>
@@ -638,10 +705,11 @@ export function DataView({
{t("pendingCount", { count: consolidationStatus.pending_consolidation })}
<button
onClick={() =>
(() => {
const { tags, match } = resolveTagQuery();
loadData(fetchLimit, searchQuery || undefined, tags, match);
})()
loadData(
fetchLimit,
searchQuery || undefined,
tagFilters.length > 0 ? tagFilters : undefined
)
}
disabled={loading}
className="ml-0.5 opacity-70 hover:opacity-100 disabled:opacity-40 transition-opacity"
@@ -666,6 +734,17 @@ export function DataView({
<ScatterChart className="w-4 h-4" />
{t("constellation")}
</button>
<button
onClick={() => setViewMode("graph")}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
viewMode === "graph"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Network className="w-4 h-4" />
{t("graph")}
</button>
<button
onClick={() => setViewMode("table")}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
@@ -692,76 +771,244 @@ export function DataView({
</div>
)}
{(compactMode || viewMode === "constellation") && (
<div className="space-y-3">
{/* Constellation controls — moved out of the old side panel to sit
inline above the graph, next to the view toggle / filters. */}
{!compactMode && (
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
{factType === "observation" && (
<div className="flex items-center gap-2">
<Layers className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">
{t("groupByScope")}
</span>
<Switch checked={groupByScope} onCheckedChange={setGroupByScope} />
</div>
)}
{!(factType === "observation" && groupByScope) && (
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">
{t("colorBy")}
</span>
<Select
value={recencyBasis}
onValueChange={(v) => setRecencyBasis(v as RecencyBasis)}
>
<SelectTrigger className="h-8 w-44 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mentioned_at">{t("mentioned")}</SelectItem>
<SelectItem value="occurred_start">{t("occurredStart")}</SelectItem>
<SelectItem value="occurred_end">{t("occurredEnd")}</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-muted-foreground">
{t("linkTypes")}
</span>
{Object.entries({
semantic: "#0074d9",
temporal: "#009296",
entity: "#f59e0b",
causal: "#8b5cf6",
}).map(([type, color]) => (
<button
key={type}
type="button"
className="flex items-center gap-1.5"
onClick={() => toggleLinkType(type)}
>
<span
className="w-3 h-3 rounded-full"
style={{
backgroundColor: color,
opacity: visibleLinkTypes.has(type) ? 1 : 0.2,
}}
/>
<span
className={`text-xs capitalize ${visibleLinkTypes.has(type) ? "text-foreground" : "text-muted-foreground line-through"}`}
>
{type}
</span>
</button>
))}
</div>
</div>
)}
{!compactMode && viewMode === "graph" && (
<div className="flex gap-0">
{/* Graph */}
<div className="flex-1 min-w-0">
<Graph2D
data={graph2DData}
height={700}
showLabels={showLabels}
onNodeClick={handleGraphNodeClick}
maxNodes={maxNodes}
nodeColorFn={nodeColorFn}
linkColorFn={linkColorFn}
/>
</div>
<div className="border border-border rounded-lg overflow-hidden">
{/* Right Toggle Button */}
<button
onClick={() => setShowControlPanel(!showControlPanel)}
className="flex-shrink-0 w-5 h-[700px] bg-transparent hover:bg-muted/50 flex items-center justify-center transition-colors"
title={showControlPanel ? t("hidePanel") : t("showPanel")}
>
{showControlPanel ? (
<ChevronRight className="w-3 h-3 text-muted-foreground/60" />
) : (
<ChevronLeft className="w-3 h-3 text-muted-foreground/60" />
)}
</button>
{/* Right Panel - Legend/Controls OR Memory Details */}
<div
className={`${showControlPanel ? "w-80" : "w-0"} transition-all duration-300 overflow-hidden flex-shrink-0`}
>
<div className="w-80 h-[700px] bg-card border-l border-border overflow-y-auto">
{selectedGraphNode ? (
/* Memory Detail View */
<MemoryDetailPanel
memory={selectedGraphNode}
onClose={() => setSelectedGraphNode(null)}
inPanel
bankId={currentBank || undefined}
/>
) : (
/* Legend & Controls View */
<div className="p-4 space-y-5">
{/* Legend & Stats */}
<div>
<h3 className="text-sm font-semibold mb-3 text-foreground">
{t("graphTitle")}
</h3>
<div className="space-y-2">
{/* Nodes */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: "#0074d9" }}
/>
<span className="text-foreground">{t("nodes")}</span>
</div>
<span className="font-mono text-foreground">
{Math.min(
maxNodes ?? graph2DData.nodes.length,
graph2DData.nodes.length
)}
/{graph2DData.nodes.length}
</span>
</div>
<div className="text-xs font-medium text-muted-foreground mt-2 mb-1">
{t("linksWithCount", { count: linkStats.total })}{" "}
<span className="text-muted-foreground/60">{t("clickToFilter")}</span>
</div>
<button
onClick={() => toggleLinkType("semantic")}
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
visibleLinkTypes.has("semantic")
? "hover:bg-muted"
: "opacity-40 hover:opacity-60"
}`}
>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[#0074d9]" />
<span className="text-foreground">{t("semantic")}</span>
</div>
<span
className={`font-mono ${linkStats.semantic === 0 ? "text-destructive" : "text-foreground"}`}
>
{linkStats.semantic}
</span>
</button>
<button
onClick={() => toggleLinkType("temporal")}
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
visibleLinkTypes.has("temporal")
? "hover:bg-muted"
: "opacity-40 hover:opacity-60"
}`}
>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[#009296]" />
<span className="text-foreground">{t("temporal")}</span>
</div>
<span
className={`font-mono ${linkStats.temporal === 0 ? "text-destructive" : "text-foreground"}`}
>
{linkStats.temporal}
</span>
</button>
<button
onClick={() => toggleLinkType("entity")}
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
visibleLinkTypes.has("entity")
? "hover:bg-muted"
: "opacity-40 hover:opacity-60"
}`}
>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[#f59e0b]" />
<span className="text-foreground">{t("entity")}</span>
</div>
<span className="font-mono text-foreground">{linkStats.entity}</span>
</button>
<button
onClick={() => toggleLinkType("causal")}
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
visibleLinkTypes.has("causal")
? "hover:bg-muted"
: "opacity-40 hover:opacity-60"
}`}
>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[#8b5cf6]" />
<span className="text-foreground">{t("causal")}</span>
</div>
<span
className={`font-mono ${linkStats.causal === 0 ? "text-muted-foreground" : "text-foreground"}`}
>
{linkStats.causal}
</span>
</button>
{Object.entries(linkStats.otherTypes || {}).map(([type, count]) => (
<div key={type} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground capitalize ml-6">{type}</span>
<span className="font-mono text-muted-foreground">
{count as number}
</span>
</div>
))}
</div>
</div>
<div className="border-t border-border" />
{/* Controls Section */}
<div>
<h3 className="text-sm font-semibold mb-3 text-foreground">
{t("displayTitle")}
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="show-labels" className="text-sm text-foreground">
{t("showLabels")}
</Label>
<Switch
id="show-labels"
checked={showLabels}
onCheckedChange={setShowLabels}
/>
</div>
</div>
</div>
<div className="border-t border-border" />
{/* Limits Section */}
<div>
<h3 className="text-sm font-semibold mb-3 text-foreground">
{t("performanceTitle")}
</h3>
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-sm text-foreground">{t("maxNodes")}</Label>
<span className="text-xs text-muted-foreground">
{graph2DData.nodes.length > 50
? `${maxNodes ?? 50} / ${graph2DData.nodes.length}`
: `${maxNodes ?? "All"} / ${graph2DData.nodes.length}`}
</span>
</div>
<Slider
value={[
graph2DData.nodes.length > 50
? maxNodes || 20
: maxNodes || Math.min(graph2DData.nodes.length, 20),
]}
min={10}
max={Math.min(Math.max(graph2DData.nodes.length, 10), 50)}
step={10}
onValueChange={([v]) => {
const effectiveMax = Math.min(graph2DData.nodes.length, 50);
// If we have >50 nodes, never allow "All" (undefined), cap at 50
if (graph2DData.nodes.length > 50) {
setMaxNodes(v);
} else {
// Original behavior for ≤50 nodes: allow "All" when slider reaches max
setMaxNodes(v >= effectiveMax ? undefined : v);
}
}}
className="w-full"
/>
</div>
<p className="text-xs text-muted-foreground">
{t("allLinksVisible")}
{graph2DData.nodes.length > 50 && (
<span className="block text-amber-600 dark:text-amber-400 mt-1">
{t("limitedTo50Nodes", { count: graph2DData.nodes.length })}
</span>
)}
</p>
</div>
</div>
<div className="border-t border-border" />
{/* Hint */}
<div className="text-xs text-muted-foreground/60 text-center pt-2">
{t("clickNodeForDetails")}
</div>
</div>
)}
</div>
</div>
</div>
)}
{(compactMode || viewMode === "constellation") && (
<div className="flex gap-0">
<div className="flex-1 min-w-0 border border-border rounded-lg overflow-hidden">
<Constellation
key={compactMode ? "compact" : "full"}
data={graph2DData}
@@ -802,6 +1049,119 @@ export function DataView({
}
/>
</div>
{/* Right Toggle Button + Panel (hidden in compact mode) */}
{!compactMode && (
<>
<button
onClick={() => setShowControlPanel(!showControlPanel)}
className="flex-shrink-0 w-5 h-[700px] bg-transparent hover:bg-muted/50 flex items-center justify-center transition-colors"
title={showControlPanel ? t("hidePanel") : t("showPanel")}
>
{showControlPanel ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronLeft className="w-3 h-3 text-muted-foreground" />
)}
</button>
{/* Right Panel — reuse the same panel as graph view */}
{showControlPanel && (
<div className="w-72 flex-shrink-0 border border-border rounded-lg bg-muted/20 overflow-y-auto h-[700px]">
{selectedGraphNode ? (
<MemoryDetailPanel
memory={selectedGraphNode}
onClose={() => setSelectedGraphNode(null)}
inPanel
bankId={currentBank || undefined}
/>
) : (
<div className="p-4 space-y-4">
<h3 className="text-sm font-semibold text-foreground">
{t("constellationViewTitle")}
</h3>
<p className="text-xs text-muted-foreground">
{t("constellationViewDescription")}
</p>
{factType === "observation" && (
<div className="flex items-center justify-between gap-2 pt-2">
<div className="flex items-center gap-1.5">
<Layers className="w-3.5 h-3.5 text-muted-foreground" />
<h4 className="text-xs font-medium text-muted-foreground">
{t("groupByScope")}
</h4>
</div>
<Switch checked={groupByScope} onCheckedChange={setGroupByScope} />
</div>
)}
{!(factType === "observation" && groupByScope) && (
<div className="space-y-2 pt-2">
<h4 className="text-xs font-medium text-muted-foreground">
{t("colorBy")}
</h4>
<Select
value={recencyBasis}
onValueChange={(v) => setRecencyBasis(v as RecencyBasis)}
>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mentioned_at">{t("mentioned")}</SelectItem>
<SelectItem value="occurred_start">
{t("occurredStart")}
</SelectItem>
<SelectItem value="occurred_end">{t("occurredEnd")}</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2 pt-2">
<h4 className="text-xs font-medium text-muted-foreground">
{t("linkTypes")}
</h4>
{Object.entries({
semantic: "#0074d9",
temporal: "#009296",
entity: "#f59e0b",
causal: "#8b5cf6",
}).map(([type, color]) => (
<div
key={type}
className="flex items-center gap-2 cursor-pointer"
onClick={() => toggleLinkType(type)}
>
<div
className="w-3 h-3 rounded-full"
style={{
backgroundColor: color,
opacity: visibleLinkTypes.has(type) ? 1 : 0.2,
}}
/>
<span
className={`text-xs capitalize ${visibleLinkTypes.has(type) ? "text-foreground" : "text-muted-foreground line-through"}`}
>
{type}
</span>
</div>
))}
</div>
<div className="text-xs text-muted-foreground space-y-1 pt-2">
<div>
{t("nodes")}:{" "}
<span className="text-foreground">{graph2DData.nodes.length}</span>
</div>
<div>
{t("links")}:{" "}
<span className="text-foreground">{graph2DData.links.length}</span>
</div>
</div>
</div>
)}
</div>
)}
</>
)}
</div>
)}
@@ -1036,7 +1396,9 @@ export function DataView({
})()
) : (
<div className="text-center py-12 text-muted-foreground">
{hasActiveMemoryFilters ? t("noMemoriesMatchFilter") : t("noMemoriesFound")}
{data.table_rows?.length > 0
? t("noMemoriesMatchFilter")
: t("noMemoriesFound")}
</div>
)}
</div>
@@ -22,7 +22,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Constellation } from "./constellation";
import { convertHindsightGraphData, GraphNode } from "./graph-data";
import { convertHindsightGraphData, GraphNode } from "./graph-2d";
type EntityGraphResponse = Awaited<ReturnType<typeof client.getEntityGraph>>;
@@ -0,0 +1,726 @@
"use client";
import { useRef, useEffect, useState, useMemo } from "react";
import { useTranslations } from "next-intl";
import cytoscape from "cytoscape";
import fcose from "cytoscape-fcose";
// Register the fcose extension
cytoscape.use(fcose);
// Hook to detect dark mode
function useIsDarkMode() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const checkDark = () => {
setIsDark(document.documentElement.classList.contains("dark"));
};
checkDark();
// Watch for theme changes
const observer = new MutationObserver(checkDark);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
return isDark;
}
// ============================================================================
// Types & Interfaces
// ============================================================================
export interface GraphNode {
id: string;
label?: string;
color?: string;
size?: number;
group?: string;
metadata?: Record<string, any>;
}
export interface GraphLink {
source: string;
target: string;
color?: string;
width?: number;
type?: string;
entity?: string;
weight?: number;
metadata?: Record<string, any>;
}
export interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
export interface Graph2DProps {
data: GraphData;
height?: number;
showLabels?: boolean;
onNodeClick?: (node: GraphNode) => void;
onNodeHover?: (node: GraphNode | null) => void;
nodeColorFn?: (node: GraphNode) => string;
nodeSizeFn?: (node: GraphNode) => number;
linkColorFn?: (link: GraphLink) => string;
linkWidthFn?: (link: GraphLink) => number;
maxNodes?: number;
}
// ============================================================================
// Default Values
// ============================================================================
// Brand colors
const BRAND_PRIMARY = "#0074d9";
const LINK_SEMANTIC = "#0074d9"; // Primary blue for semantic
const DEFAULT_NODE_COLOR = BRAND_PRIMARY;
const DEFAULT_LINK_COLOR = LINK_SEMANTIC;
const DEFAULT_LINK_WIDTH = 1;
// ============================================================================
// Component
// ============================================================================
export function Graph2D({
data,
height = 600,
showLabels = true,
onNodeClick,
onNodeHover,
nodeColorFn,
nodeSizeFn,
linkColorFn,
linkWidthFn,
maxNodes,
}: Graph2DProps) {
const t = useTranslations("graph2d");
const [containerDiv, setContainerDiv] = useState<HTMLDivElement | null>(null);
const cyRef = useRef<any>(null);
const isInitializingRef = useRef(false);
const lastDataSignatureRef = useRef<string>("");
const [_hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
const [hoveredLink, setHoveredLink] = useState<GraphLink | null>(null);
const [linkTooltipPos, setLinkTooltipPos] = useState<{ x: number; y: number } | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isMounted, setIsMounted] = useState(false);
const [isFocusMode, setIsFocusMode] = useState(false);
const isDarkMode = useIsDarkMode();
// Use refs to store callbacks and data to prevent re-renders from resetting the graph
const onNodeClickRef = useRef(onNodeClick);
const onNodeHoverRef = useRef(onNodeHover);
const fullDataRef = useRef(data);
const nodeColorFnRef = useRef(nodeColorFn);
const linkColorFnRef = useRef(linkColorFn);
const isFocusModeRef = useRef(isFocusMode);
onNodeClickRef.current = onNodeClick;
onNodeHoverRef.current = onNodeHover;
fullDataRef.current = data;
nodeColorFnRef.current = nodeColorFn;
linkColorFnRef.current = linkColorFn;
isFocusModeRef.current = isFocusMode;
// Transform and limit data - only limit nodes, show ALL links between visible nodes
const graphData = useMemo(() => {
let nodes = [...data.nodes];
// Limit nodes if needed
if (maxNodes && nodes.length > maxNodes) {
nodes = nodes.slice(0, maxNodes);
}
// Show ALL links between visible nodes (no random link limiting)
const nodeIds = new Set(nodes.map((n) => n.id));
const links = data.links.filter((l) => nodeIds.has(l.source) && nodeIds.has(l.target));
return { nodes, links };
}, [data, maxNodes]);
// Track mounting state
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);
// Convert to Cytoscape format
const cyElements = useMemo(() => {
// Calculate node importance based on connections
const nodeConnections = new Map<string, number>();
graphData.links.forEach((link) => {
nodeConnections.set(link.source, (nodeConnections.get(link.source) || 0) + 1);
nodeConnections.set(link.target, (nodeConnections.get(link.target) || 0) + 1);
});
const nodes = graphData.nodes.map((node) => {
const connections = nodeConnections.get(node.id) || 0;
const dynamicSize = nodeSizeFn
? nodeSizeFn(node)
: Math.max(16, Math.min(40, 16 + connections * 4)); // Smaller, more subtle sizing
return {
data: {
id: node.id,
label: node.label || node.id.substring(0, 8),
color: nodeColorFn ? nodeColorFn(node) : node.color || DEFAULT_NODE_COLOR,
size: node.size || dynamicSize,
originalNode: node,
connections: connections,
},
};
});
const edges = graphData.links.map((link, idx) => ({
data: {
id: `edge-${idx}`,
source: link.source,
target: link.target,
color: linkColorFn ? linkColorFn(link) : link.color || DEFAULT_LINK_COLOR,
width: linkWidthFn ? linkWidthFn(link) : link.width || DEFAULT_LINK_WIDTH,
type: link.type,
entity: link.entity,
weight: link.weight,
originalLink: link,
},
}));
return [...nodes, ...edges];
}, [graphData, nodeColorFn, nodeSizeFn, linkColorFn, linkWidthFn]);
// Create data signature to prevent double initialization
const dataSignature = useMemo(() => {
return JSON.stringify({
nodeCount: graphData.nodes.length,
linkCount: graphData.links.length,
nodeIds: graphData.nodes
.map((n) => n.id)
.sort()
.join(","),
showLabels,
isDarkMode,
maxNodes,
});
}, [graphData.nodes, graphData.links, showLabels, isDarkMode, maxNodes]);
// Initialize Cytoscape
useEffect(() => {
let isCancelled = false;
// Small delay to ensure container is mounted
const timeout = setTimeout(() => {
if (isCancelled || !isMounted || !containerDiv || isInitializingRef.current) return;
// Check if data has actually changed to prevent double initialization
if (lastDataSignatureRef.current === dataSignature) {
console.log("Data signature unchanged, skipping graph initialization");
return;
}
// Additional validation - check if element has dimensions
const rect = containerDiv.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
console.warn("Container has no dimensions, skipping cytoscape initialization");
setIsLoading(false);
return;
}
// Handle empty data case
if (cyElements.length === 0) {
setIsLoading(false);
return;
}
// Check if we already have a graph with the same data
if (cyRef.current && !cyRef.current.destroyed()) {
const currentNodes = cyRef.current.nodes().length;
const currentEdges = cyRef.current.edges().length;
const newNodes = cyElements.filter((el) => !(el.data as any).source).length;
const newEdges = cyElements.filter((el) => (el.data as any).source).length;
// If the element counts are the same, just update styles and skip reinitialization
if (currentNodes === newNodes && currentEdges === newEdges) {
console.log("Graph already initialized with same data, skipping reinitialization");
setIsLoading(false);
return;
}
// Clean up existing graph before creating new one
console.log("Data changed, destroying existing graph");
cyRef.current.destroy();
cyRef.current = null;
}
setIsLoading(true);
isInitializingRef.current = true;
// Theme-aware colors
const textColor = isDarkMode ? "#ffffff" : "#1f2937";
const textBgColor = isDarkMode ? "rgba(0,0,0,0.8)" : "rgba(255,255,255,0.9)";
try {
console.log("Initializing cytoscape with container:", containerDiv);
console.log("Elements count:", cyElements.length);
console.log("Sample elements:", cyElements.slice(0, 2));
// Try minimal initialization first
const cy = cytoscape({
container: containerDiv,
elements: [],
// Disable edge selection to prevent gray border on click
selectionType: "single",
userZoomingEnabled: true,
userPanningEnabled: true,
boxSelectionEnabled: false,
// Disable automatic layout on initialization
layout: { name: "preset" },
style: [
{
selector: "node",
style: {
"background-color": "data(color)",
width: "data(size)",
height: "data(size)",
label: showLabels ? "data(label)" : "",
color: textColor,
"text-valign": "bottom",
"text-halign": "center",
"font-size": "8px",
"font-weight": 500,
"text-margin-y": 3,
"text-wrap": "wrap",
"text-max-width": "80px",
"text-background-color": textBgColor,
"text-background-opacity": 0.9,
"text-background-padding": "2px",
"text-background-shape": "roundrectangle",
"border-width": 1,
"border-color": isDarkMode ? "#ffffff20" : "#00000020",
"border-opacity": 0.3,
},
},
{
selector: "node:selected",
style: {
"border-width": 3,
"border-color": "#0074d9",
"border-opacity": 1,
},
},
{
selector: "edge",
style: {
width: "data(width)",
"line-color": "data(color)",
"target-arrow-color": "data(color)",
"target-arrow-shape": "triangle",
"target-arrow-size": 6,
"curve-style": "bezier",
opacity: isDarkMode ? 0.6 : 0.7,
},
},
// Focus mode styles
{
selector: ".dimmed",
style: {
opacity: 0.2,
},
},
{
selector: ".focused",
style: {
"border-width": 4,
"border-color": "#ff6b35",
"border-opacity": 1,
"z-index": 999,
},
},
{
selector: ".connected",
style: {
"border-width": 2,
"border-color": "#0074d9",
"border-opacity": 0.8,
opacity: 1,
},
},
{
selector: "edge.connection",
style: {
width: 2,
opacity: 1,
"z-index": 100,
},
},
{
selector: "edge.connection:hover",
style: {
width: 3,
opacity: 1,
"z-index": 200,
},
},
// Disable edge selection styling
{
selector: "edge:selected",
style: {
"overlay-opacity": 0,
"overlay-color": "transparent",
"overlay-padding": 0,
},
},
],
});
cyRef.current = cy;
console.log("Cytoscape initialized successfully");
// Add elements after initialization
if (cyElements.length > 0) {
console.log("Adding elements to cytoscape");
cy.add(cyElements);
cy.layout({
name: "fcose",
quality: "default",
randomize: false,
animate: true,
animationDuration: 1500,
// Separation settings - increase to spread nodes more
nodeSeparation: 200,
idealEdgeLength: () => 250,
edgeElasticity: () => 0.05,
nestingFactor: 0.05,
gravity: 0.05, // Reduced gravity spreads nodes more
numIter: 2500,
// Overlap prevention
nodeOverlap: 30,
avoidOverlap: true,
nodeDimensionsIncludeLabels: true,
// Layout bounds - reduce padding to use more space
padding: 20,
boundingBox: undefined,
// Tiling - increase spacing between disconnected components
tile: true,
tilingPaddingVertical: 30,
tilingPaddingHorizontal: 30,
// Force more spread
uniformNodeDimensions: false,
packComponents: false, // Don't pack components tightly
}).run();
// Fit to viewport
cy.fit();
}
// Add basic interactions
cy.on("tap", "node", (evt: any) => {
const node = evt.target as cytoscape.NodeSingular;
const originalNode = node.data("originalNode") as GraphNode;
if (onNodeClickRef.current && originalNode) {
onNodeClickRef.current(originalNode);
}
});
cy.on("mouseover", "node", (evt: any) => {
const node = evt.target as cytoscape.NodeSingular;
const originalNode = node.data("originalNode") as GraphNode;
setHoveredNode(originalNode);
if (onNodeHoverRef.current && originalNode) {
onNodeHoverRef.current(originalNode);
}
if (containerDiv) containerDiv.style.cursor = "pointer";
});
cy.on("mouseout", "node", () => {
setHoveredNode(null);
if (onNodeHoverRef.current) {
onNodeHoverRef.current(null);
}
if (containerDiv) containerDiv.style.cursor = "default";
});
// Edge hover handlers - only work in focus mode and on highlighted edges
cy.on("mouseover", "edge", (evt: any) => {
const edge = evt.target;
// Only allow interaction if we're in focus mode and edge is highlighted
if (!isFocusModeRef.current || !edge.hasClass("connection")) {
return;
}
const originalLink = edge.data("originalLink") as GraphLink;
if (originalLink) {
setHoveredLink(originalLink);
// Get position for tooltip
const renderedPos = edge.renderedMidpoint();
setLinkTooltipPos({ x: renderedPos.x, y: renderedPos.y });
}
});
cy.on("mouseout", "edge", (evt: any) => {
const edge = evt.target;
// Only clear hover state if we were actually hovering a highlighted edge
if (!isFocusModeRef.current || !edge.hasClass("connection")) {
return;
}
setHoveredLink(null);
setLinkTooltipPos(null);
});
// Prevent edge selection to avoid gray border on click
cy.on("select", "edge", (evt: any) => {
evt.target.unselect();
});
// Double-click to focus on node and its connections
cy.on("dblclick", "node", (evt: any) => {
const focusedNode = evt.target as cytoscape.NodeSingular;
const focusedNodeId = focusedNode.id();
console.log("Double-clicked node:", focusedNodeId);
// Enter focus mode
setIsFocusMode(true);
// Clear any existing focus classes
cy.elements().removeClass("dimmed focused connected connection");
// Get all connected nodes and edges
const connectedElements = focusedNode.neighborhood();
const connectedNodes = connectedElements.nodes();
const connectedEdges = connectedElements.edges();
// Apply styling classes
cy.elements().addClass("dimmed"); // Dim everything first
focusedNode.removeClass("dimmed").addClass("focused"); // Highlight the focused node
connectedNodes.removeClass("dimmed").addClass("connected"); // Highlight connected nodes
connectedEdges.removeClass("dimmed").addClass("connection"); // Highlight connecting edges
// Create a collection of all relevant elements for positioning
const relevantElements = focusedNode.union(connectedElements);
// Reorient the graph to focus on this subgraph
cy.animate(
{
fit: {
eles: relevantElements,
padding: 100,
},
center: {
eles: focusedNode,
},
},
{
duration: 800,
easing: "ease-out-cubic",
}
);
});
// Click on background to reset focus
cy.on("tap", (evt: any) => {
if (evt.target === cy) {
console.log("Clicked background - resetting focus");
// Exit focus mode
setIsFocusMode(false);
// Remove all focus classes
cy.elements().removeClass("dimmed focused connected connection");
// Zoom out to show all elements
cy.animate(
{
fit: {
eles: cy.elements(),
padding: 50,
},
},
{
duration: 600,
easing: "ease-out",
}
);
}
});
setIsLoading(false);
isInitializingRef.current = false;
lastDataSignatureRef.current = dataSignature;
} catch (error) {
console.error("Error initializing cytoscape:", error);
setIsLoading(false);
isInitializingRef.current = false;
}
}, 100); // 100ms delay
return () => {
isCancelled = true;
clearTimeout(timeout);
isInitializingRef.current = false;
if (cyRef.current) {
cyRef.current.destroy();
cyRef.current = null;
}
};
}, [dataSignature, isMounted, containerDiv]);
// Handle resize
useEffect(() => {
const handleResize = () => {
if (cyRef.current) {
cyRef.current.resize();
cyRef.current.fit(undefined, 80);
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return (
<div
className="relative w-full rounded-lg overflow-hidden border border-border"
style={{ height }}
>
{/* Loading state */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-background z-10">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
<p className="text-sm text-muted-foreground">{t("loading")}</p>
</div>
</div>
)}
{/* Cytoscape container */}
{isMounted && (
<div
ref={setContainerDiv}
className="w-full h-full"
style={{
backgroundImage: isDarkMode
? "radial-gradient(circle at 1px 1px, rgba(255,255,255,0.08) 1px, transparent 0)"
: "radial-gradient(circle at 1px 1px, rgba(0,0,0,0.06) 1px, transparent 0)",
backgroundSize: "20px 20px",
backgroundColor: isDarkMode ? "#0f1419" : "#f8fafc",
}}
/>
)}
{/* Empty state */}
{!isLoading && graphData.nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground">{t("emptyState")}</p>
</div>
</div>
)}
{/* Link hover tooltip */}
{hoveredLink && linkTooltipPos && (
<div
className="absolute z-30 pointer-events-none"
style={{
left: linkTooltipPos.x,
top: linkTooltipPos.y,
transform: "translate(-50%, -100%) translateY(-8px)",
}}
>
<div
className={`px-3 py-2 rounded-lg shadow-lg text-sm ${
isDarkMode
? "bg-gray-800 text-white"
: "bg-white text-gray-900 border border-gray-200"
}`}
>
<div className="font-medium capitalize mb-1">
{(() => {
const type = hoveredLink.type || "semantic";
if (["causes", "caused_by", "enables", "prevents"].includes(type)) {
return t("linkTypeCausal", { type: type.replace("_", " ") });
}
return t("linkTypeGeneric", { type });
})()}
</div>
{hoveredLink.entity && (
<div className="text-xs opacity-80">
{t("linkTooltipEntity")} <span className="font-medium">{hoveredLink.entity}</span>
</div>
)}
{hoveredLink.weight !== undefined && (
<div className="text-xs opacity-80">
{t("linkTooltipWeight")}{" "}
<span className="font-medium">{hoveredLink.weight.toFixed(3)}</span>
</div>
)}
</div>
</div>
)}
{/* Controls hint */}
<div className="absolute bottom-4 right-4 text-xs text-muted-foreground/60 z-20">
{t("controlsHint")}
</div>
</div>
);
}
// ============================================================================
// Utility Functions
// ============================================================================
export function convertHindsightGraphData(hindsightData: {
nodes?: Array<{ data: { id: string; label?: string; color?: string } }>;
edges?: Array<{
data: {
source: string;
target: string;
color?: string;
lineStyle?: string;
linkType?: string;
entityName?: string;
weight?: number;
similarity?: number;
};
}>;
table_rows?: Array<{ id: string; text: string; entities?: string; context?: string }>;
}): GraphData {
const nodes: GraphNode[] = (hindsightData.nodes || []).map((n) => {
const tableRow = hindsightData.table_rows?.find((r) => r.id === n.data.id);
// Use memory text as label, truncated to ~40 chars
let label = n.data.label;
if (!label && tableRow?.text) {
label = tableRow.text.length > 40 ? tableRow.text.substring(0, 40) + "..." : tableRow.text;
}
if (!label) {
label = n.data.id.substring(0, 8);
}
return {
id: n.data.id,
label,
color: n.data.color,
metadata: tableRow,
};
});
const links: GraphLink[] = (hindsightData.edges || []).map((e) => ({
source: e.data.source,
target: e.data.target,
color: e.data.color,
// Use linkType directly from API, fallback to lineStyle check, default to semantic
type: e.data.linkType || (e.data.lineStyle === "dashed" ? "temporal" : "semantic"),
entity: e.data.entityName, // API returns entityName
weight: e.data.weight ?? e.data.similarity,
}));
return { nodes, links };
}
@@ -1,84 +0,0 @@
// Shared graph data model + conversion used by the memory visualizations
// (Constellation, entities view). The Cytoscape-based "Graph" view that used to
// live here was removed; only the framework-agnostic types and the API-response
// converter remain, since the constellation and entity views build on them.
// ============================================================================
// Types & Interfaces
// ============================================================================
export interface GraphNode {
id: string;
label?: string;
color?: string;
size?: number;
group?: string;
metadata?: Record<string, any>;
}
export interface GraphLink {
source: string;
target: string;
color?: string;
width?: number;
type?: string;
entity?: string;
weight?: number;
metadata?: Record<string, any>;
}
export interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
// ============================================================================
// Utility Functions
// ============================================================================
export function convertHindsightGraphData(hindsightData: {
nodes?: Array<{ data: { id: string; label?: string; color?: string } }>;
edges?: Array<{
data: {
source: string;
target: string;
color?: string;
lineStyle?: string;
linkType?: string;
entityName?: string;
weight?: number;
similarity?: number;
};
}>;
table_rows?: Array<{ id: string; text: string; entities?: string; context?: string }>;
}): GraphData {
const nodes: GraphNode[] = (hindsightData.nodes || []).map((n) => {
const tableRow = hindsightData.table_rows?.find((r) => r.id === n.data.id);
// Use memory text as label, truncated to ~40 chars
let label = n.data.label;
if (!label && tableRow?.text) {
label = tableRow.text.length > 40 ? tableRow.text.substring(0, 40) + "..." : tableRow.text;
}
if (!label) {
label = n.data.id.substring(0, 8);
}
return {
id: n.data.id,
label,
color: n.data.color,
metadata: tableRow,
};
});
const links: GraphLink[] = (hindsightData.edges || []).map((e) => ({
source: e.data.source,
target: e.data.target,
color: e.data.color,
// Use linkType directly from API, fallback to lineStyle check, default to semantic
type: e.data.linkType || (e.data.lineStyle === "dashed" ? "temporal" : "semantic"),
entity: e.data.entityName, // API returns entityName
weight: e.data.weight ?? e.data.similarity,
}));
return { nodes, links };
}
@@ -0,0 +1,684 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { useTranslations } from "next-intl";
import { client, type KnowledgeNode } from "@/lib/api";
import { useBank } from "@/lib/bank-context";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
ChevronDown,
ChevronRight,
Download,
FilePlus,
FileText,
Folder,
FolderOpen,
FolderPlus,
Loader2,
Network,
Trash2,
X,
} from "lucide-react";
import { formatAbsoluteDateTime, formatRelativeTime } from "@/lib/relative-time";
import { CompactMarkdown } from "./compact-markdown";
import { Constellation } from "./constellation";
import type { GraphData, GraphLink, GraphNode } from "./graph-2d";
type ViewMode = "tree" | "graph";
type GraphResponse = Awaited<ReturnType<typeof client.getKnowledgeBaseGraph>>;
type PageDetail = Awaited<ReturnType<typeof client.getKnowledgePage>>;
const FALLBACK_COLOR = "#0074d9";
function flatten(nodes: KnowledgeNode[], out: KnowledgeNode[] = []): KnowledgeNode[] {
for (const n of nodes) {
out.push(n);
if (n.children?.length) flatten(n.children, out);
}
return out;
}
export function KnowledgeBaseView() {
const t = useTranslations("knowledgeBase");
const { currentBank } = useBank();
const [roots, setRoots] = useState<KnowledgeNode[]>([]);
const [loading, setLoading] = useState(false);
const [view, setView] = useState<ViewMode>("tree");
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<PageDetail | null>(null);
const [loadingDetail, setLoadingDetail] = useState(false);
const [graph, setGraph] = useState<GraphResponse | null>(null);
const [graphLoading, setGraphLoading] = useState(false);
const [exporting, setExporting] = useState(false);
const [createKind, setCreateKind] = useState<"folder" | "page" | null>(null);
const [form, setForm] = useState({ name: "", sourceQuery: "", parentId: "" });
const [creating, setCreating] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<KnowledgeNode | null>(null);
const [deleting, setDeleting] = useState(false);
const loadTree = useCallback(async () => {
if (!currentBank) return;
setLoading(true);
try {
const result = await client.getKnowledgeTree(currentBank);
setRoots(result.roots || []);
} catch {
// toast handled by interceptor
} finally {
setLoading(false);
}
}, [currentBank]);
const loadGraph = useCallback(async () => {
if (!currentBank) return;
setGraphLoading(true);
try {
setGraph(await client.getKnowledgeBaseGraph(currentBank));
} catch {
// toast handled by interceptor
} finally {
setGraphLoading(false);
}
}, [currentBank]);
useEffect(() => {
if (currentBank) {
setSelected(null);
setGraph(null);
loadTree();
}
}, [currentBank, loadTree]);
useEffect(() => {
if (view === "graph" && currentBank && !graph && !graphLoading) loadGraph();
}, [view, currentBank, graph, graphLoading, loadGraph]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setSelected(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const allNodes = useMemo(() => flatten(roots), [roots]);
const folders = useMemo(() => allNodes.filter((n) => n.kind === "folder"), [allNodes]);
const folderCount = folders.length;
const pageCount = allNodes.length - folderCount;
const openPage = useCallback(
async (pageId: string) => {
if (!currentBank) return;
setLoadingDetail(true);
try {
setSelected(await client.getKnowledgePage(currentBank, pageId));
} catch {
// toast handled by interceptor
} finally {
setLoadingDetail(false);
}
},
[currentBank]
);
const toggleFolder = useCallback((id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const openCreate = (kind: "folder" | "page", parentId = "") => {
setForm({ name: "", sourceQuery: "", parentId });
setCreateKind(kind);
};
const handleCreate = async () => {
if (!currentBank || !createKind || !form.name.trim()) return;
if (createKind === "page" && !form.sourceQuery.trim()) return;
setCreating(true);
try {
const parent_id = form.parentId || null;
if (createKind === "folder") {
await client.createKnowledgeFolder(currentBank, {
name: form.name.trim(),
parent_id,
});
} else {
await client.createKnowledgePage(currentBank, {
name: form.name.trim(),
source_query: form.sourceQuery.trim(),
parent_id,
});
}
if (parent_id) setExpanded((prev) => new Set(prev).add(parent_id));
setCreateKind(null);
await loadTree();
setGraph(null);
} catch {
// toast handled by interceptor
} finally {
setCreating(false);
}
};
const handleDelete = async () => {
if (!currentBank || !deleteTarget) return;
setDeleting(true);
try {
await client.deleteKnowledgeNode(currentBank, deleteTarget.id);
if (selected?.id === deleteTarget.id) setSelected(null);
setDeleteTarget(null);
await loadTree();
setGraph(null);
} catch {
// toast handled by interceptor
} finally {
setDeleting(false);
}
};
const handleExport = async () => {
if (!currentBank) return;
setExporting(true);
try {
const bundle = await client.exportKnowledgeBase(currentBank);
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${currentBank}-okf.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch {
// toast handled by interceptor
} finally {
setExporting(false);
}
};
// ── Constellation (graph view) — clustered by parent folder ───────────────
const typeColors = useMemo(() => {
const colors = new Map<string, string>();
for (const n of graph?.nodes ?? []) colors.set(n.data.type, n.data.color);
return colors;
}, [graph]);
const constellationData = useMemo<GraphData>(() => {
if (!graph) return { nodes: [], links: [] };
const nodes: GraphNode[] = graph.nodes.map((n) => ({
id: n.data.id,
label: n.data.label,
color: n.data.color,
group: n.data.type,
}));
const links: GraphLink[] = graph.edges.map((e) => ({
source: e.data.source,
target: e.data.target,
color: e.data.color,
weight: e.data.weight,
}));
return { nodes, links };
}, [graph]);
const nodeWeights = useMemo(() => {
const weights = new Map<string, number>();
for (const link of constellationData.links) {
const w = typeof link.weight === "number" && link.weight > 0 ? link.weight : 1;
weights.set(link.source, (weights.get(link.source) || 0) + w);
weights.set(link.target, (weights.get(link.target) || 0) + w);
}
return weights;
}, [constellationData]);
const maxNodeWeight = useMemo(() => {
let max = 1;
for (const w of nodeWeights.values()) if (w > max) max = w;
return max;
}, [nodeWeights]);
const nodeSizeFn = useCallback(
(node: GraphNode) => 4 + Math.sqrt((nodeWeights.get(node.id) || 0) / maxNodeWeight) * 10,
[nodeWeights, maxNodeWeight]
);
return (
<div>
<div className="flex items-start justify-between gap-4 mb-2">
<div>
<h1 className="text-3xl font-bold text-foreground">{t("title")}</h1>
<p className="text-muted-foreground mt-1">{t("description")}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => openCreate("folder")}>
<FolderPlus className="w-4 h-4 mr-2" />
{t("newFolder")}
</Button>
<Button variant="outline" size="sm" onClick={() => openCreate("page")}>
<FilePlus className="w-4 h-4 mr-2" />
{t("newPage")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={exporting || !pageCount}
>
{exporting ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Download className="w-4 h-4 mr-2" />
)}
{t("exportButton")}
</Button>
</div>
</div>
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-muted-foreground">
{view === "graph"
? t("graphCount", { pages: graph?.total_pages ?? 0, links: graph?.total_edges ?? 0 })
: t("count", { folders: folderCount, pages: pageCount })}
</div>
<div className="flex items-center gap-2 bg-muted rounded-lg p-1">
<button
onClick={() => setView("tree")}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
view === "tree"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Folder className="w-4 h-4" />
{t("viewTree")}
</button>
<button
onClick={() => setView("graph")}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
view === "graph"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Network className="w-4 h-4" />
{t("viewGraph")}
</button>
</div>
</div>
{view === "tree" ? (
<div className="border border-border rounded-lg overflow-hidden min-h-[480px]">
{loading ? (
<div className="flex items-center justify-center py-24">
<Loader2 className="w-7 h-7 text-muted-foreground animate-spin" />
</div>
) : roots.length > 0 ? (
<ul className="py-2">
{roots.map((node) => (
<TreeRow
key={node.id}
node={node}
depth={0}
expanded={expanded}
selectedId={selected?.id ?? null}
onToggle={toggleFolder}
onOpenPage={openPage}
onAddChild={openCreate}
onDelete={setDeleteTarget}
t={t}
/>
))}
</ul>
) : (
<div className="flex items-center justify-center py-24">
<div className="text-center max-w-md px-6">
<FolderOpen className="w-8 h-8 mx-auto mb-3 text-muted-foreground opacity-60" />
<div className="text-sm text-muted-foreground">{t("empty")}</div>
<div className="text-xs text-muted-foreground mt-1">{t("emptyHint")}</div>
</div>
</div>
)}
</div>
) : (
<div className="border border-border rounded-lg overflow-hidden">
{graphLoading ? (
<div className="flex items-center justify-center py-24">
<Loader2 className="w-7 h-7 text-muted-foreground animate-spin" />
</div>
) : constellationData.nodes.length > 0 ? (
<Constellation
data={constellationData}
height={620}
onNodeClick={(node) => openPage(node.id)}
nodeSizeFn={nodeSizeFn}
clusterKeyFn={(node) => node.group ?? null}
clusterColorFn={(key) => typeColors.get(key) || FALLBACK_COLOR}
clusterLabelFn={(key) => key}
sizeLegendLabel={t("sizeLegendLabel")}
compactLabels
/>
) : (
<div className="flex items-center justify-center py-24">
<div className="text-sm text-muted-foreground">{t("empty")}</div>
</div>
)}
</div>
)}
{/* Page detail panel */}
{(selected || loadingDetail) && (
<div className="fixed right-0 top-0 h-screen w-[460px] bg-card border-l-2 border-primary shadow-2xl z-50 overflow-y-auto animate-in slide-in-from-right duration-300 ease-out">
<div className="p-5">
<div className="flex justify-between items-start mb-4 pb-4 border-b border-border">
<div className="min-w-0 flex-1">
<h3 className="text-xl font-bold text-card-foreground truncate">
{selected?.name ?? t("loadingPage")}
</h3>
{selected?.description && (
<p className="text-sm text-muted-foreground mt-1 italic">
&ldquo;{selected.description}&rdquo;
</p>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setSelected(null)}
className="h-8 w-8 p-0 flex-shrink-0"
aria-label={t("close")}
>
<X className="w-4 h-4" />
</Button>
</div>
{loadingDetail && !selected ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 text-muted-foreground animate-spin" />
</div>
) : selected ? (
<div className="space-y-4">
{selected.tags.length > 0 && (
<div className="flex items-center gap-2 flex-wrap text-xs">
{selected.tags.map((tag) => (
<span
key={tag}
className="px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400"
>
{tag}
</span>
))}
</div>
)}
{selected.timestamp && (
<div
className="text-xs text-muted-foreground"
title={formatAbsoluteDateTime(selected.timestamp)}
>
{t("updatedLabel")} {formatRelativeTime(selected.timestamp)}
</div>
)}
{selected.body ? (
<div className="prose prose-sm dark:prose-invert max-w-none border-t border-border pt-4">
<CompactMarkdown>{selected.body}</CompactMarkdown>
</div>
) : (
<p className="text-sm text-muted-foreground italic border-t border-border pt-4">
{t("noBody")}
</p>
)}
</div>
) : null}
</div>
</div>
)}
{/* Create folder/page dialog */}
<Dialog open={createKind !== null} onOpenChange={(o) => !o && setCreateKind(null)}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{createKind === "folder" ? t("createFolderTitle") : t("createPageTitle")}
</DialogTitle>
<DialogDescription>{t("description")}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">{t("fieldName")}</label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
autoFocus
/>
</div>
{createKind === "page" && (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{t("fieldSourceQuery")}
</label>
<Textarea
value={form.sourceQuery}
onChange={(e) => setForm({ ...form, sourceQuery: e.target.value })}
className="min-h-[100px]"
/>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">{t("fieldParent")}</label>
<Select
value={form.parentId || "__root__"}
onValueChange={(v) => setForm({ ...form, parentId: v === "__root__" ? "" : v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__root__">{t("rootFolder")}</SelectItem>
{folders.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateKind(null)} disabled={creating}>
{t("cancel")}
</Button>
<Button
onClick={handleCreate}
disabled={
creating || !form.name.trim() || (createKind === "page" && !form.sourceQuery.trim())
}
>
{creating ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t("create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete confirmation */}
<AlertDialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteButton")}</AlertDialogTitle>
<AlertDialogDescription>
{t("deleteConfirm", { name: deleteTarget?.name ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-row justify-end space-x-2">
<AlertDialogCancel className="mt-0">{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t("deleteButton")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
function TreeRow({
node,
depth,
expanded,
selectedId,
onToggle,
onOpenPage,
onAddChild,
onDelete,
t,
}: {
node: KnowledgeNode;
depth: number;
expanded: Set<string>;
selectedId: string | null;
onToggle: (id: string) => void;
onOpenPage: (id: string) => void;
onAddChild: (kind: "folder" | "page", parentId: string) => void;
onDelete: (node: KnowledgeNode) => void;
t: ReturnType<typeof useTranslations>;
}) {
const isFolder = node.kind === "folder";
const isOpen = expanded.has(node.id);
const isActive = selectedId === node.id;
return (
<li>
<div
className={`group flex items-center gap-1.5 pr-2 py-1.5 cursor-pointer border-l-2 transition-colors ${
isActive
? "bg-primary/10 border-primary text-foreground"
: "border-transparent hover:bg-muted text-foreground"
}`}
style={{ paddingLeft: `${depth * 18 + 10}px` }}
onClick={() => (isFolder ? onToggle(node.id) : onOpenPage(node.id))}
>
{isFolder ? (
<>
{isOpen ? (
<ChevronDown className="w-3.5 h-3.5 flex-shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="w-3.5 h-3.5 flex-shrink-0 text-muted-foreground" />
)}
{isOpen ? (
<FolderOpen className="w-4 h-4 flex-shrink-0 text-amber-500" />
) : (
<Folder className="w-4 h-4 flex-shrink-0 text-amber-500" />
)}
</>
) : (
<>
<span className="w-3.5 flex-shrink-0" />
<FileText className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
</>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm truncate">{node.name}</span>
{!isFolder && node.managed && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-violet-500/10 text-violet-600 dark:text-violet-400 flex-shrink-0">
{t("autoBadge")}
</span>
)}
</div>
</div>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{isFolder && (
<>
<button
className="p-1 rounded hover:bg-background text-muted-foreground hover:text-foreground"
title={t("newFolder")}
onClick={(e) => {
e.stopPropagation();
onAddChild("folder", node.id);
}}
>
<FolderPlus className="w-3.5 h-3.5" />
</button>
<button
className="p-1 rounded hover:bg-background text-muted-foreground hover:text-foreground"
title={t("newPage")}
onClick={(e) => {
e.stopPropagation();
onAddChild("page", node.id);
}}
>
<FilePlus className="w-3.5 h-3.5" />
</button>
</>
)}
<button
className="p-1 rounded hover:bg-background text-muted-foreground hover:text-red-600"
title={t("deleteButton")}
onClick={(e) => {
e.stopPropagation();
onDelete(node);
}}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{isFolder && isOpen && node.children?.length > 0 && (
<ul>
{node.children.map((child) => (
<TreeRow
key={child.id}
node={child}
depth={depth + 1}
expanded={expanded}
selectedId={selectedId}
onToggle={onToggle}
onOpenPage={onOpenPage}
onAddChild={onAddChild}
onDelete={onDelete}
t={t}
/>
))}
</ul>
)}
</li>
);
}
@@ -154,23 +154,12 @@ export function MentalModelsView() {
setLoading(true);
try {
// The API caps each response at PAGE_SIZE, so page through until a short
// page is returned to load every mental model for this bank.
const PAGE_SIZE = 100;
const all: MentalModel[] = [];
for (let offset = 0; ; offset += PAGE_SIZE) {
const page = await client.listMentalModels(
currentBank,
selectedTags.length > 0 ? selectedTags : undefined,
selectedTags.length > 0 ? tagsMatch : undefined,
PAGE_SIZE,
offset
);
const items = page.items || [];
all.push(...items);
if (items.length < PAGE_SIZE) break;
}
setMentalModels(all);
const mentalModelsData = await client.listMentalModels(
currentBank,
selectedTags.length > 0 ? selectedTags : undefined,
selectedTags.length > 0 ? tagsMatch : undefined
);
setMentalModels(mentalModelsData.items || []);
} catch (error) {
console.error("Error loading mental models:", error);
} finally {
@@ -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 },
@@ -14,19 +14,7 @@ interface JsonViewerProps {
className?: string;
}
function parseJsonString(value: string): unknown {
const trimmed = value.trim();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value;
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
function toDisplayText(value: unknown): string {
value = typeof value === "string" ? parseJsonString(value) : value;
if (typeof value === "string") return value;
// Unescape newlines inside string values so multi-line content (e.g. prompts)
// renders as real line breaks under `whitespace-pre-wrap` instead of literal "\n".
@@ -0,0 +1,25 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary/50 border border-border">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };
+123 -13
View File
@@ -38,6 +38,19 @@ export interface WebhookHttpConfig {
params: Record<string, string>;
}
export interface KnowledgeNode {
id: string;
kind: "folder" | "page";
name: string;
parent_id: string | null;
mental_model_id: string | null;
managed: boolean;
description: string | null;
tags: string[];
timestamp: string | null;
children: KnowledgeNode[];
}
export interface Webhook {
id: string;
bank_id: string | null;
@@ -593,6 +606,115 @@ export class ControlPlaneClient {
}>(`/api/entities/graph?${queryParams}`);
}
/**
* Get the knowledge base as a nested folder/page tree.
*/
async getKnowledgeTree(bankId: string) {
return this.fetchApi<{ roots: KnowledgeNode[] }>(
`/api/knowledge-base/tree?bank_id=${encodeURIComponent(bankId)}`
);
}
/**
* Get the knowledge-base constellation graph (pages linked by shared tags).
*/
async getKnowledgeBaseGraph(bankId: string) {
return this.fetchApi<{
nodes: Array<{
data: { id: string; label: string; type: string; tagCount: number; color: string };
}>;
edges: Array<{
data: {
id: string;
source: string;
target: string;
sharedTags: string[];
weight: number;
color: string;
};
}>;
total_pages: number;
total_edges: number;
}>(`/api/knowledge-base/graph?bank_id=${encodeURIComponent(bankId)}`);
}
/**
* Get a single knowledge page rendered as an OKF document.
*/
async getKnowledgePage(bankId: string, pageId: string) {
return this.fetchApi<{
id: string;
name: string;
type: string;
description: string | null;
tags: string[];
timestamp: string | null;
body: string | null;
markdown: string;
}>(
`/api/knowledge-base/pages/${encodeURIComponent(pageId)}?bank_id=${encodeURIComponent(bankId)}`
);
}
/**
* Create a folder, optionally under a parent folder.
*/
async createKnowledgeFolder(
bankId: string,
body: { name: string; parent_id?: string | null }
) {
return this.fetchApi<KnowledgeNode>(
`/api/knowledge-base/folders?bank_id=${encodeURIComponent(bankId)}`,
{ method: "POST", body: JSON.stringify(body) }
);
}
/**
* Create a page (mental model + tree node). Content is generated asynchronously.
*/
async createKnowledgePage(
bankId: string,
body: { name: string; source_query: string; parent_id?: string | null; tags?: string[] }
) {
return this.fetchApi<{ page_id: string; mental_model_id: string; operation_id: string | null }>(
`/api/knowledge-base/pages?bank_id=${encodeURIComponent(bankId)}`,
{ method: "POST", body: JSON.stringify(body) }
);
}
/**
* Rename and/or move a node. Pass `parent_id: null` to move to the root.
*/
async updateKnowledgeNode(
bankId: string,
nodeId: string,
body: { name?: string; parent_id?: string | null }
) {
return this.fetchApi<KnowledgeNode>(
`/api/knowledge-base/nodes/${encodeURIComponent(nodeId)}?bank_id=${encodeURIComponent(bankId)}`,
{ method: "PATCH", body: JSON.stringify(body) }
);
}
/**
* Delete a node and its whole subtree.
*/
async deleteKnowledgeNode(bankId: string, nodeId: string) {
return this.fetchApi<{ status: string }>(
`/api/knowledge-base/nodes/${encodeURIComponent(nodeId)}?bank_id=${encodeURIComponent(bankId)}`,
{ method: "DELETE" }
);
}
/**
* Export the knowledge base as a portable OKF bundle (markdown files).
*/
async exportKnowledgeBase(bankId: string) {
return this.fetchApi<{ files: Array<{ path: string; content: string }> }>(
`/api/knowledge-base/export?bank_id=${encodeURIComponent(bankId)}`
);
}
/**
* Get entity details
*/
@@ -1205,13 +1327,7 @@ export class ControlPlaneClient {
/**
* List mental models for a bank
*/
async listMentalModels(
bankId: string,
tags?: string[],
tagsMatch?: string,
limit?: number,
offset?: number
) {
async listMentalModels(bankId: string, tags?: string[], tagsMatch?: string) {
const params = new URLSearchParams();
if (tags && tags.length > 0) {
tags.forEach((t) => params.append("tags", t));
@@ -1219,12 +1335,6 @@ export class ControlPlaneClient {
if (tagsMatch) {
params.append("tags_match", tagsMatch);
}
if (limit !== undefined) {
params.append("limit", String(limit));
}
if (offset !== undefined) {
params.append("offset", String(offset));
}
const query = params.toString();
return this.fetchApi<{
items: Array<{
+74 -1
View File
@@ -147,7 +147,8 @@
"memoryBank": "Speicherbank",
"expandSidebar": "Seitenleiste ausklappen",
"collapseSidebar": "Seitenleiste einklappen",
"collapse": "Einklappen"
"collapse": "Einklappen",
"knowledge": "Knowledge base"
},
"overviewAndOperations": "Übersichtsstatistik und Hintergrundoperationen für diesen Speicherbank.",
"operations": "Operationen",
@@ -534,6 +535,7 @@
"allConsolidatedWithDate": "Alle Erinnerungen konsolidiert (zuletzt: {date})",
"pendingConsolidation": "{count} Erinnerungen stehen zur Konsolidierung aus",
"constellation": "Konstellation",
"graph": "Graph",
"table": "Tabelle",
"timeline": "Zeitleiste",
"hidePanel": "Bereich ausblenden",
@@ -547,6 +549,20 @@
"linkTypes": "Verknüpfungstypen",
"nodes": "Knoten",
"links": "Verknüpfungen",
"graphTitle": "Graph",
"linksWithCount": "Verknüpfungen ({count})",
"clickToFilter": "· klicken zum Filtern",
"semantic": "Semantisch",
"temporal": "Zeitlich",
"entity": "Entität",
"causal": "Kausal",
"displayTitle": "Anzeige",
"showLabels": "Beschriftungen anzeigen",
"performanceTitle": "Leistung",
"maxNodes": "Maximale Knoten",
"allLinksVisible": "Alle Verknüpfungen zwischen sichtbaren Knoten werden angezeigt.",
"limitedTo50Nodes": "⚠️ Aus Leistungsgründen auf 50 Knoten begrenzt. Gesamt: {count}",
"clickNodeForDetails": "Auf einen Knoten klicken, um Details anzuzeigen",
"columnObservation": "Beobachtung",
"columnMemory": "Erinnerung",
"columnSources": "Quellen",
@@ -1438,6 +1454,15 @@
"factTypeObservation": "Beobachtung",
"actionClearContent": "Inhalt löschen"
},
"graph2d": {
"loading": "Diagramm wird geladen...",
"emptyState": "Keine Erinnerungen zum Anzeigen",
"linkTypeCausal": "Kausal ({type})",
"linkTypeGeneric": "{type}-Verknüpfung",
"linkTooltipEntity": "Entität:",
"linkTooltipWeight": "Gewicht:",
"controlsHint": "Ziehen zum Verschieben • Scrollen zum Zoomen • Doppelklick auf Knoten zum Fokussieren • Klick auf den Hintergrund zum Zurücksetzen"
},
"constellation": {
"instructions": "Scrollen zum Zoomen · Ziehen zum Verschieben · Hover zum Erkunden · Klicken zum Auswählen",
"hudStats": "{memories} Erinnerungen · {visible} sichtbar · {labels} Beschriftungen · {links} Verknüpfungen · Zoom {zoom}x",
@@ -1688,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"
}
}
+74 -1
View File
@@ -148,7 +148,8 @@
"memoryBank": "Memory Bank",
"expandSidebar": "Expand sidebar",
"collapseSidebar": "Collapse sidebar",
"collapse": "Collapse"
"collapse": "Collapse",
"knowledge": "Knowledge base"
},
"overviewAndOperations": "Overview statistics and background operations for this memory bank.",
"operations": "Operations",
@@ -534,6 +535,7 @@
"allConsolidatedWithDate": "All memories consolidated (last: {date})",
"pendingConsolidation": "{count} memories pending consolidation",
"constellation": "Constellation",
"graph": "Graph",
"table": "Table",
"timeline": "Timeline",
"hidePanel": "Hide panel",
@@ -547,6 +549,20 @@
"linkTypes": "Link types",
"nodes": "Nodes",
"links": "Links",
"graphTitle": "Graph",
"linksWithCount": "Links ({count})",
"clickToFilter": "· click to filter",
"semantic": "Semantic",
"temporal": "Temporal",
"entity": "Entity",
"causal": "Causal",
"displayTitle": "Display",
"showLabels": "Show labels",
"performanceTitle": "Performance",
"maxNodes": "Max nodes",
"allLinksVisible": "All links between visible nodes are shown.",
"limitedTo50Nodes": "⚠️ Limited to 50 nodes for performance. Total: {count}",
"clickNodeForDetails": "Click a node to see details",
"columnObservation": "Observation",
"columnMemory": "Memory",
"columnSources": "Sources",
@@ -1438,6 +1454,15 @@
"factTypeObservation": "observation",
"actionClearContent": "Clear Content"
},
"graph2d": {
"loading": "Loading graph...",
"emptyState": "No memories to display",
"linkTypeCausal": "Causal ({type})",
"linkTypeGeneric": "{type} link",
"linkTooltipEntity": "Entity:",
"linkTooltipWeight": "Weight:",
"controlsHint": "Drag to pan • Scroll to zoom • Double-click node to focus • Click background to reset"
},
"constellation": {
"instructions": "Scroll to zoom · Drag to pan · Hover to explore · Click to select",
"hudStats": "{memories} memories · {visible} visible · {labels} labels · {links} links · zoom {zoom}x",
@@ -1688,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"
}
}
+74 -1
View File
@@ -147,7 +147,8 @@
"memoryBank": "Banco de memoria",
"expandSidebar": "Expandir barra lateral",
"collapseSidebar": "Contraer barra lateral",
"collapse": "Contraer"
"collapse": "Contraer",
"knowledge": "Knowledge base"
},
"overviewAndOperations": "Estadísticas generales y operaciones en segundo plano de este banco de memoria.",
"operations": "Operaciones",
@@ -534,6 +535,7 @@
"allConsolidatedWithDate": "Todas las memorias consolidadas (última: {date})",
"pendingConsolidation": "{count} memorias pendientes de consolidación",
"constellation": "Constelación",
"graph": "Grafo",
"table": "Tabla",
"timeline": "Línea de tiempo",
"hidePanel": "Ocultar panel",
@@ -547,6 +549,20 @@
"linkTypes": "Tipos de vínculo",
"nodes": "Nodos",
"links": "Vínculos",
"graphTitle": "Grafo",
"linksWithCount": "Vínculos ({count})",
"clickToFilter": "· clic para filtrar",
"semantic": "Semántico",
"temporal": "Temporal",
"entity": "Entidad",
"causal": "Causal",
"displayTitle": "Visualización",
"showLabels": "Mostrar etiquetas",
"performanceTitle": "Rendimiento",
"maxNodes": "Nodos máximos",
"allLinksVisible": "Se muestran todos los vínculos entre nodos visibles.",
"limitedTo50Nodes": "⚠️ Limitado a 50 nodos por rendimiento. Total: {count}",
"clickNodeForDetails": "Haz clic en un nodo para ver detalles",
"columnObservation": "Observación",
"columnMemory": "Memoria",
"columnSources": "Fuentes",
@@ -1438,6 +1454,15 @@
"factTypeObservation": "observación",
"actionClearContent": "Borrar contenido"
},
"graph2d": {
"loading": "Cargando gráfico...",
"emptyState": "No hay memorias que mostrar",
"linkTypeCausal": "Causal ({type})",
"linkTypeGeneric": "Enlace {type}",
"linkTooltipEntity": "Entidad:",
"linkTooltipWeight": "Peso:",
"controlsHint": "Arrastra para mover • Desplaza para hacer zoom • Doble clic en un nodo para enfocar • Clic en el fondo para restablecer"
},
"constellation": {
"instructions": "Desplaza para hacer zoom · Arrastra para mover · Pasa el cursor para explorar · Haz clic para seleccionar",
"hudStats": "{memories} memorias · {visible} visibles · {labels} etiquetas · {links} enlaces · zoom {zoom}x",
@@ -1688,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"
}
}
+74 -1
View File
@@ -147,7 +147,8 @@
"memoryBank": "Banque de mémoire",
"expandSidebar": "Développer la barre latérale",
"collapseSidebar": "Réduire la barre latérale",
"collapse": "Réduire"
"collapse": "Réduire",
"knowledge": "Knowledge base"
},
"overviewAndOperations": "Statistiques générales et opérations en arrière-plan pour cette banque mémoire.",
"operations": "Opérations",
@@ -534,6 +535,7 @@
"allConsolidatedWithDate": "Tous les souvenirs consolidés (dernier : {date})",
"pendingConsolidation": "{count} souvenirs en attente de consolidation",
"constellation": "Constellation",
"graph": "Graphe",
"table": "Tableau",
"timeline": "Chronologie",
"hidePanel": "Masquer le panneau",
@@ -547,6 +549,20 @@
"linkTypes": "Types de liens",
"nodes": "Nœuds",
"links": "Liens",
"graphTitle": "Graphe",
"linksWithCount": "Liens ({count})",
"clickToFilter": "· cliquer pour filtrer",
"semantic": "Sémantique",
"temporal": "Temporel",
"entity": "Entité",
"causal": "Causal",
"displayTitle": "Affichage",
"showLabels": "Afficher les étiquettes",
"performanceTitle": "Performance",
"maxNodes": "Nœuds max",
"allLinksVisible": "Tous les liens entre les nœuds visibles sont affichés.",
"limitedTo50Nodes": "⚠️ Limité à 50 nœuds pour les performances. Total : {count}",
"clickNodeForDetails": "Cliquez sur un nœud pour voir les détails",
"columnObservation": "Observation",
"columnMemory": "Souvenir",
"columnSources": "Sources",
@@ -1438,6 +1454,15 @@
"factTypeObservation": "observation",
"actionClearContent": "Effacer le contenu"
},
"graph2d": {
"loading": "Chargement du graphe...",
"emptyState": "Aucun souvenir à afficher",
"linkTypeCausal": "Causal ({type})",
"linkTypeGeneric": "Lien {type}",
"linkTooltipEntity": "Entité :",
"linkTooltipWeight": "Poids :",
"controlsHint": "Glisser pour déplacer • Défiler pour zoomer • Double-clic sur un nœud pour zoomer • Clic sur le fond pour réinitialiser"
},
"constellation": {
"instructions": "Défiler pour zoomer · Glisser pour déplacer · Survoler pour explorer · Cliquer pour sélectionner",
"hudStats": "{memories} souvenirs · {visible} visibles · {labels} étiquettes · {links} liens · zoom {zoom}x",
@@ -1688,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"
}
}
+74 -1
View File
@@ -147,7 +147,8 @@
"memoryBank": "メモリバンク",
"expandSidebar": "サイドバーを展開",
"collapseSidebar": "サイドバーを折りたたむ",
"collapse": "折りたたむ"
"collapse": "折りたたむ",
"knowledge": "Knowledge base"
},
"overviewAndOperations": "このメモリバンクの概要統計とバックグラウンド操作。",
"operations": "操作",
@@ -534,6 +535,7 @@
"allConsolidatedWithDate": "すべてのメモリが統合済み(最終:{date})",
"pendingConsolidation": "{count}件のメモリが統合待ち",
"constellation": "コンステレーション",
"graph": "グラフ",
"table": "テーブル",
"timeline": "タイムライン",
"hidePanel": "パネルを非表示",
@@ -547,6 +549,20 @@
"linkTypes": "リンクの種類",
"nodes": "ノード",
"links": "リンク",
"graphTitle": "グラフ",
"linksWithCount": "リンク({count}件)",
"clickToFilter": "・クリックでフィルター",
"semantic": "セマンティック",
"temporal": "時系列",
"entity": "エンティティ",
"causal": "因果",
"displayTitle": "表示",
"showLabels": "ラベルを表示",
"performanceTitle": "パフォーマンス",
"maxNodes": "最大ノード数",
"allLinksVisible": "表示中のノード間のすべてのリンクが表示されています。",
"limitedTo50Nodes": "⚠️ パフォーマンスのため50ノードに制限されています。合計:{count}",
"clickNodeForDetails": "ノードをクリックして詳細を確認",
"columnObservation": "観察",
"columnMemory": "メモリ",
"columnSources": "ソース",
@@ -1438,6 +1454,15 @@
"factTypeObservation": "オブザベーション",
"actionClearContent": "内容をクリア"
},
"graph2d": {
"loading": "グラフを読み込み中...",
"emptyState": "表示するメモリがありません",
"linkTypeCausal": "因果 ({type})",
"linkTypeGeneric": "{type} リンク",
"linkTooltipEntity": "エンティティ:",
"linkTooltipWeight": "ウェイト:",
"controlsHint": "ドラッグで移動 • スクロールでズーム • ノードをダブルクリックでフォーカス • 背景をクリックでリセット"
},
"constellation": {
"instructions": "スクロールでズーム · ドラッグで移動 · ホバーで探索 · クリックで選択",
"hudStats": "{memories} 件のメモリ · {visible} 件表示 · {labels} 件のラベル · {links} 件のリンク · ズーム {zoom}x",
@@ -1688,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"
}
}
+74 -1
View File
@@ -147,7 +147,8 @@
"memoryBank": "메모리 뱅크",
"expandSidebar": "사이드바 펼치기",
"collapseSidebar": "사이드바 접기",
"collapse": "접기"
"collapse": "접기",
"knowledge": "Knowledge base"
},
"overviewAndOperations": "이 메모리 뱅크의 개요 통계 및 백그라운드 작업.",
"operations": "작업",
@@ -534,6 +535,7 @@
"allConsolidatedWithDate": "모든 메모리 통합됨 (마지막: {date})",
"pendingConsolidation": "{count}개 메모리 통합 대기 중",
"constellation": "별자리",
"graph": "그래프",
"table": "표",
"timeline": "타임라인",
"hidePanel": "패널 숨기기",
@@ -547,6 +549,20 @@
"linkTypes": "링크 유형",
"nodes": "노드",
"links": "링크",
"graphTitle": "그래프",
"linksWithCount": "링크 ({count})",
"clickToFilter": "· 클릭하여 필터링",
"semantic": "의미적",
"temporal": "시간적",
"entity": "엔티티",
"causal": "인과적",
"displayTitle": "표시",
"showLabels": "레이블 표시",
"performanceTitle": "성능",
"maxNodes": "최대 노드",
"allLinksVisible": "표시된 노드 간의 모든 링크가 표시됩니다.",
"limitedTo50Nodes": "⚠️ 성능을 위해 50개 노드로 제한됩니다. 전체: {count}",
"clickNodeForDetails": "노드를 클릭하면 세부 정보를 볼 수 있습니다",
"columnObservation": "관찰",
"columnMemory": "메모리",
"columnSources": "소스",
@@ -1438,6 +1454,15 @@
"factTypeObservation": "관찰",
"actionClearContent": "콘텐츠 지우기"
},
"graph2d": {
"loading": "그래프 로딩 중...",
"emptyState": "표시할 메모리가 없습니다",
"linkTypeCausal": "인과 ({type})",
"linkTypeGeneric": "{type} 링크",
"linkTooltipEntity": "엔티티:",
"linkTooltipWeight": "가중치:",
"controlsHint": "드래그하여 이동 • 스크롤하여 확대/축소 • 노드 더블클릭으로 포커스 • 배경 클릭으로 초기화"
},
"constellation": {
"instructions": "스크롤하여 확대/축소 · 드래그하여 이동 · 호버하여 탐색 · 클릭하여 선택",
"hudStats": "{memories}개 메모리 · {visible}개 표시 · {labels}개 레이블 · {links}개 링크 · 줌 {zoom}x",
@@ -1688,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"
}
}
+74 -1
View File
@@ -147,7 +147,8 @@
"memoryBank": "Banco de memória",
"expandSidebar": "Expandir barra lateral",
"collapseSidebar": "Recolher barra lateral",
"collapse": "Recolher"
"collapse": "Recolher",
"knowledge": "Knowledge base"
},
"overviewAndOperations": "Estatísticas gerais e operações em segundo plano para este banco de memória.",
"operations": "Operações",
@@ -534,6 +535,7 @@
"allConsolidatedWithDate": "Todas as memórias consolidadas (última: {date})",
"pendingConsolidation": "{count} memórias pendentes de consolidação",
"constellation": "Constelação",
"graph": "Grafo",
"table": "Tabela",
"timeline": "Linha do Tempo",
"hidePanel": "Ocultar painel",
@@ -547,6 +549,20 @@
"linkTypes": "Tipos de vínculo",
"nodes": "Nós",
"links": "Vínculos",
"graphTitle": "Grafo",
"linksWithCount": "Vínculos ({count})",
"clickToFilter": "· clique para filtrar",
"semantic": "Semântico",
"temporal": "Temporal",
"entity": "Entidade",
"causal": "Causal",
"displayTitle": "Exibição",
"showLabels": "Mostrar rótulos",
"performanceTitle": "Desempenho",
"maxNodes": "Máximo de nós",
"allLinksVisible": "Todos os vínculos entre os nós visíveis estão sendo exibidos.",
"limitedTo50Nodes": "⚠️ Limitado a 50 nós por desempenho. Total: {count}",
"clickNodeForDetails": "Clique em um nó para ver detalhes",
"columnObservation": "Observação",
"columnMemory": "Memória",
"columnSources": "Fontes",
@@ -1438,6 +1454,15 @@
"factTypeObservation": "observação",
"actionClearContent": "Limpar conteúdo"
},
"graph2d": {
"loading": "Carregando grafo...",
"emptyState": "Nenhuma memória para exibir",
"linkTypeCausal": "Causal ({type})",
"linkTypeGeneric": "Link {type}",
"linkTooltipEntity": "Entidade:",
"linkTooltipWeight": "Peso:",
"controlsHint": "Arraste para mover • Scroll para zoom • Duplo clique no nó para focar • Clique no fundo para redefinir"
},
"constellation": {
"instructions": "Scroll para zoom · Arraste para mover · Passe o mouse para explorar · Clique para selecionar",
"hudStats": "{memories} memórias · {visible} visíveis · {labels} rótulos · {links} links · zoom {zoom}x",
@@ -1688,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": "操作",
@@ -534,6 +535,7 @@
"allConsolidatedWithDate": "所有記憶已整合(最近:{date}",
"pendingConsolidation": "{count} 條記憶待整合",
"constellation": "星座圖",
"graph": "圖譜",
"table": "表格",
"timeline": "時間軸",
"hidePanel": "隱藏面板",
@@ -547,6 +549,20 @@
"linkTypes": "連結類型",
"nodes": "節點",
"links": "連結",
"graphTitle": "圖譜",
"linksWithCount": "連結({count}",
"clickToFilter": "· 選取篩選",
"semantic": "語義",
"temporal": "時間",
"entity": "實體",
"causal": "因果",
"displayTitle": "顯示",
"showLabels": "顯示標籤",
"performanceTitle": "效能",
"maxNodes": "最大節點數",
"allLinksVisible": "所有可見節點之間的連結均已顯示。",
"limitedTo50Nodes": "⚠️ 出於效能限制,最多顯示 50 個節點。總計:{count}",
"clickNodeForDetails": "選取節點檢視詳情",
"columnObservation": "觀察",
"columnMemory": "記憶",
"columnSources": "來源",
@@ -1438,6 +1454,15 @@
"factTypeObservation": "觀察",
"actionClearContent": "清除內容"
},
"graph2d": {
"loading": "載入中圖譜...",
"emptyState": "目前沒有記憶可顯示",
"linkTypeCausal": "因果 ({type})",
"linkTypeGeneric": "{type} 連結",
"linkTooltipEntity": "實體:",
"linkTooltipWeight": "權重:",
"controlsHint": "拖曳平移 • 滾動縮放 • 雙擊節點聚焦 • 選取背景重設"
},
"constellation": {
"instructions": "滾動縮放 · 拖曳平移 · 懸停探索 · 選取項目",
"hudStats": "{memories} 條記憶 · {visible} 條可見 · {labels} 個標籤 · {links} 條連結 · 縮放 {zoom}x",
@@ -1688,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": "操作",
@@ -534,6 +535,7 @@
"allConsolidatedWithDate": "所有记忆已整合(最近:{date}",
"pendingConsolidation": "{count} 条记忆待整合",
"constellation": "星座图",
"graph": "图谱",
"table": "表格",
"timeline": "时间线",
"hidePanel": "隐藏面板",
@@ -547,6 +549,20 @@
"linkTypes": "链接类型",
"nodes": "节点",
"links": "链接",
"graphTitle": "图谱",
"linksWithCount": "链接({count}",
"clickToFilter": "· 点击筛选",
"semantic": "语义",
"temporal": "时间",
"entity": "实体",
"causal": "因果",
"displayTitle": "显示",
"showLabels": "显示标签",
"performanceTitle": "性能",
"maxNodes": "最大节点数",
"allLinksVisible": "所有可见节点之间的链接均已显示。",
"limitedTo50Nodes": "⚠️ 出于性能限制,最多显示 50 个节点。总计:{count}",
"clickNodeForDetails": "点击节点查看详情",
"columnObservation": "观察",
"columnMemory": "记忆",
"columnSources": "来源",
@@ -1438,6 +1454,15 @@
"factTypeObservation": "观察",
"actionClearContent": "清除内容"
},
"graph2d": {
"loading": "正在加载图谱...",
"emptyState": "暂无记忆可显示",
"linkTypeCausal": "因果 ({type})",
"linkTypeGeneric": "{type} 链接",
"linkTooltipEntity": "实体:",
"linkTooltipWeight": "权重:",
"controlsHint": "拖拽平移 • 滚动缩放 • 双击节点聚焦 • 点击背景重置"
},
"constellation": {
"instructions": "滚动缩放 · 拖拽平移 · 悬停探索 · 点击选择",
"hudStats": "{memories} 条记忆 · {visible} 条可见 · {labels} 个标签 · {links} 条链接 · 缩放 {zoom}x",
@@ -1688,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": "操作",
@@ -534,6 +535,7 @@
"allConsolidatedWithDate": "所有記憶已整合(最近:{date}",
"pendingConsolidation": "{count} 條記憶待整合",
"constellation": "星座圖",
"graph": "圖譜",
"table": "表格",
"timeline": "時間軸",
"hidePanel": "隱藏面板",
@@ -547,6 +549,20 @@
"linkTypes": "連結型別",
"nodes": "節點",
"links": "連結",
"graphTitle": "圖譜",
"linksWithCount": "連結({count}",
"clickToFilter": "· 點選篩選",
"semantic": "語義",
"temporal": "時間",
"entity": "實體",
"causal": "因果",
"displayTitle": "顯示",
"showLabels": "顯示標籤",
"performanceTitle": "效能",
"maxNodes": "最大節點數",
"allLinksVisible": "所有可見節點之間的連結均已顯示。",
"limitedTo50Nodes": "⚠️ 出於效能限制,最多顯示 50 個節點。總計:{count}",
"clickNodeForDetails": "點選節點檢視詳情",
"columnObservation": "觀察",
"columnMemory": "記憶",
"columnSources": "來源",
@@ -1438,6 +1454,15 @@
"factTypeObservation": "觀察",
"actionClearContent": "清除內容"
},
"graph2d": {
"loading": "載入中圖譜...",
"emptyState": "尚無記憶可顯示",
"linkTypeCausal": "因果 ({type})",
"linkTypeGeneric": "{type} 連結",
"linkTooltipEntity": "實體:",
"linkTooltipWeight": "權重:",
"controlsHint": "拖曳平移 • 滾動縮放 • 雙擊節點聚焦 • 按一下背景重設"
},
"constellation": {
"instructions": "滾動縮放 · 拖曳平移 · 懸停探索 · 按一下選取",
"hudStats": "{memories} 條記憶 · {visible} 條可見 · {labels} 個標籤 · {links} 條連結 · 縮放 {zoom}x",
@@ -1688,7 +1713,55 @@
"update": "更新 Webhook 失敗",
"delete": "刪除 Webhook 失敗",
"deliveries": "取得 Webhook 投遞失敗"
},
"knowledgeBase": {
"tree": "Failed to fetch knowledge base tree",
"graph": "Failed to fetch knowledge base graph",
"export": "Failed to export knowledge base",
"createFolder": "Failed to create folder",
"createPage": "Failed to create page",
"fetchPage": "Failed to fetch knowledge page",
"updateNode": "Failed to update node",
"deleteNode": "Failed to delete node"
}
}
},
"knowledgeBase": {
"title": "Knowledge base",
"description": "Organize your knowledge into folders and pages, linked by shared tags.",
"loading": "Loading knowledge base…",
"loadingPage": "Loading page…",
"empty": "No folders or pages yet.",
"emptyHint": "Create a folder or a page to get started.",
"viewTree": "Tree",
"viewGraph": "Graph",
"newFolder": "New folder",
"newPage": "New page",
"exportButton": "Export OKF bundle",
"count": "{folders} folders · {pages} pages",
"graphCount": "{pages} pages · {links} shared-tag links",
"sizeLegendLabel": "shared-tag links",
"updatedLabel": "Updated",
"close": "Close",
"noBody": "This page has no content yet.",
"deleteConfirm": "Delete \"{name}\" and everything inside it?",
"deleteButton": "Delete",
"cancel": "Cancel",
"create": "Create",
"creating": "Creating…",
"fieldName": "Name",
"fieldSourceQuery": "Source query (the question that rebuilds this page)",
"fieldParent": "Parent folder",
"rootFolder": "Root",
"selectPagePrompt": "Select a page to read it.",
"createFolderTitle": "New folder",
"createPageTitle": "New page",
"fieldMission": "Mission (what this folder should collect)",
"missionPlaceholder": "e.g. Track all incidents for the payments service",
"editMissionTitle": "Edit folder mission",
"editMission": "Edit mission",
"saveMission": "Save mission",
"autoBadge": "auto",
"missionNone": "No mission set"
}
}
@@ -50,7 +50,6 @@ INTEGRATIONS: dict[str, IntegrationMeta] = {
"nemoclaw": IntegrationMeta("@vectorize-io/hindsight-nemoclaw", "NemoClaw"),
"strands": IntegrationMeta("hindsight-strands", "Strands"),
"claude-code": IntegrationMeta("hindsight-memory", "Claude Code"),
"zcode": IntegrationMeta("hindsight-zcode", "ZCode"),
"claude-agent-sdk": IntegrationMeta("hindsight-claude-agent-sdk", "Claude Agent SDK"),
"llamaindex": IntegrationMeta("hindsight-llamaindex", "LlamaIndex"),
"codex": IntegrationMeta("hindsight-codex", "Codex"),
@@ -1,130 +0,0 @@
---
title: "Devin Desktop Persistent Memory (Formerly Windsurf)"
authors: [benfrank241]
slug: "2026/07/02/devin-desktop-persistent-memory"
date: 2026-07-02T13:00
tags: [hindsight, devin-desktop, devin, windsurf, codeium, memory, persistent-memory, mcp, tutorial]
description: "Add persistent memory to Devin Desktop (formerly Windsurf): a remote MCP server plus one always-on rule that recalls at task start and retains as you work."
image: /img/blog/devin-desktop-persistent-memory.png
hide_table_of_contents: true
---
![Devin Desktop Persistent Memory with Hindsight](/img/blog/devin-desktop-persistent-memory.png)
[Devin Desktop](https://devin.ai) is the editor Cognition rebranded from Windsurf (formerly Codeium) in June 2026. The name changed; the gap didn't. Devin reads your codebase and holds a plan within a session, but it carries nothing across sessions. Close the editor, reopen it tomorrow, and the agent is a fresh model again, with no memory of the decision you talked through last week or the convention you set on Tuesday.
The `hindsight-devin-desktop` integration adds persistent long-term memory to Devin. It's worth understanding *how* it gets there, because Devin Desktop doesn't expose lifecycle hooks to third parties. There's no place to bolt a `sessionStart` recall or a `stop` retain. Instead the integration uses two things the editor *does* support: **remote [Model Context Protocol](https://modelcontextprotocol.io) (MCP) servers** and **always-on workspace rules**.
<!-- truncate -->
## TL;DR
- Devin Desktop (formerly Windsurf) has no third-party lifecycle hooks, so memory is wired through MCP plus a rule, not hook scripts.
- `hindsight-devin-desktop init` connects the Hindsight **remote MCP server** (Devin gets `recall` / `retain` / `reflect` tools) and writes one **always-on rule** to `.devin/rules/hindsight.md`.
- The rule tells Devin to `recall` at the start of each task and `retain` durable facts as it works.
- No local daemon, no plugin scripts, no per-turn hooks. The MCP endpoint connects straight to [Hindsight Cloud](https://hindsight.vectorize.io) or your self-hosted server.
- This is **model-driven memory**: the rule rides in every request, but the actual recall/retain calls are Devin's decision. That's the main tradeoff versus deterministic hook-based integrations.
## Why Devin Desktop Needs Persistent Memory
A new Devin session starts with whatever it can see: your open files, the workspace, and any rules you've written in `.devin/rules/`. What it can't see is the past. The bug you traced through three files yesterday, the library you chose and why, the naming convention you've been holding the line on. None of that survives the session boundary unless you wrote it down somewhere Devin reads.
You can pin context by hand with rules files, and for stable facts that works. It doesn't help with the things you didn't know to record in advance. Persistent memory closes that gap: durable facts get retained as you work, and the relevant ones come back on their own next time.
That matters more for an editor you live in all day. A coding agent that reintroduces itself every morning isn't really an assistant. Memory is what turns a fresh-every-session model into one that builds on yesterday.
## How Devin Desktop Persistent Memory Works
Devin Desktop gives third parties two integration points, and `hindsight-devin-desktop` uses both.
**Remote MCP server.** Devin Desktop reads MCP servers from a single global config and supports *remote* servers via `serverUrl` with custom headers, so the integration points Devin straight at the Hindsight MCP endpoint with no local process to manage:
```json
{
"mcpServers": {
"hindsight": {
"serverUrl": "https://api.hindsight.vectorize.io/mcp/my-project/",
"headers": { "Authorization": "Bearer hsk_..." }
}
}
}
```
That gives Devin three tools: `recall` (search memory), `retain` (store a durable fact), and `reflect` (a synthesized, memory-grounded answer). The memory bank is encoded in the endpoint path, so one config line scopes the whole connection to a bank.
**Always-on rule.** Devin Desktop applies any rule file under `.devin/rules/` whose frontmatter says `trigger: always_on` to every request in the workspace. The integration writes one dedicated file, `.devin/rules/hindsight.md`, telling Devin how and when to use those tools:
```markdown
---
trigger: always_on
---
<!-- Managed by hindsight-devin-desktop -->
You have persistent long-term memory through the Hindsight MCP server
(`recall`, `retain`, and `reflect` tools).
- At the start of each task, call `recall` with the user's request to load
relevant decisions, preferences, and project context before you act.
Use what's relevant and ignore the rest.
- When you learn a durable fact, such as an architectural decision, a user
preference, a convention, or anything worth remembering across sessions,
call `retain` to store it.
- Do not mention these memory operations unless the user asks about them.
```
The file carries a sentinel comment (`<!-- Managed by hindsight-devin-desktop -->`) so the integration owns it end to end and can update or remove it idempotently without touching any other rule you've authored. Put together: the MCP server makes memory *available* as tools, and the always-on rule makes Devin *use* them.
## Install
```bash
pip install hindsight-devin-desktop
cd your-project
hindsight-devin-desktop init --api-token YOUR_HINDSIGHT_API_KEY --bank-id my-project
```
`init` merges the `mcpServers` entry into Devin Desktop's global MCP config and writes the rule into `./.devin/rules/hindsight.md`. Reload Devin Desktop (or refresh MCP servers) and the `hindsight` tools are live.
Three commands cover the lifecycle: `hindsight-devin-desktop init` adds the MCP server and the recall/retain rule, `status` shows whether both are configured, and `uninstall` removes them. If your MCP config isn't plain JSON (comments, or some other tool owns it), `init` won't clobber it. It prints the snippet to paste instead, which you can also get anytime with `hindsight-devin-desktop init --print-only`.
## Cloud or Self-Hosted
By default the integration points at Hindsight Cloud (`https://api.hindsight.vectorize.io`), which needs an API key from your dashboard. To run against your own server, pass `--api-url`. If it's an open local server, you can skip the token entirely:
```bash
hindsight-devin-desktop init --api-url http://localhost:8888 --bank-id my-project
```
Settings can also come from the environment: `HINDSIGHT_API_URL` (the API endpoint, defaulting to Cloud), `HINDSIGHT_API_TOKEN` (the bearer token, required for Cloud), and `HINDSIGHT_DEVIN_DESKTOP_BANK_ID` (the bank to scope memory to, defaulting to `devin-desktop`). Point two projects at the same bank to share memory, or give each its own bank for isolation.
## A Rebrand Detail Worth Knowing
Because Devin Desktop is a rebrand of Windsurf, a couple of on-disk paths still carry the old name, and the integration handles that so you don't have to. The global MCP config still lives under `~/.codeium/windsurf/` (that's Devin Desktop's data directory, unchanged by the rename), while the workspace rule now lives under `.devin/rules/`, with `.windsurf/rules/` kept as a legacy fallback. If you used the integration back when it was the Windsurf package, your existing rule keeps working and the new path takes precedence going forward.
## The Tradeoff: Model-Driven, Not Hook-Driven
This is worth being direct about, because it's the real difference between this integration and the hook-based ones for Claude Code or the Cursor CLI.
Hook-based integrations are **deterministic**. A `sessionStart` hook recalls before the agent ever sees the prompt; a `stop` hook retains after every task, whether or not the model thought to. The recall and retain happen because the harness fires an event, not because the agent decided to.
Devin Desktop doesn't offer that surface to third parties, so `hindsight-devin-desktop` is **model-driven**. The always-on rule is injected into every request, so the instruction to use memory is always present, but the actual `recall` and `retain` calls are Devin's decision. In practice modern models follow a short, concrete always-on rule reliably. But it's an instruction, not a guarantee: Devin can skip a `retain` on a task it didn't judge memorable, or answer from context without calling `recall` first. If you want memory pulled for a specific task, you can just ask ("check memory for how we handled auth"), and `reflect` is there to consolidate on demand. The honest framing: Devin Desktop trades the guarantees of hooks for the simplicity of a remote MCP server and one rule file, with no local daemon and nothing to keep running.
## Frequently Asked Questions
**Is Devin Desktop the same as Windsurf?**
Yes. Cognition rebranded the Windsurf editor (formerly Codeium) to Devin Desktop in June 2026. The `hindsight-devin-desktop` package is the maintained integration; it writes its rule to `.devin/rules/` and still reads the MCP config under `~/.codeium/windsurf/`, which is unchanged by the rebrand.
**Does Devin Desktop have built-in memory across sessions?**
No. A new session starts fresh. Persistent memory comes from an integration like `hindsight-devin-desktop` that gives Devin recall and retain over a memory layer.
**Will memory recall slow Devin down?**
Recall is the agent's call, not a per-prompt hook, so there's no fixed overhead on every turn. When Devin does recall, a Hindsight Cloud query is typically well under a second.
**Does it work with self-hosted Hindsight?**
Yes. Pass `--api-url` (or set `HINDSIGHT_API_URL`) to point at your server. For an open local server with no auth, omit the token.
## Further reading
- [What is agent memory?](https://vectorize.io/what-is-agent-memory): the foundational concepts behind recall, retention, and memory banks.
- [Best AI agent memory systems](https://vectorize.io/articles/best-ai-agent-memory-systems): how the major agent memory frameworks compare.
- [Cursor persistent memory](/blog/2026/06/12/cursor-persistent-memory): the hook-based sibling integration for the Cursor editor and CLI.
- [One memory for every AI tool](/blog/2026/04/07/one-memory-for-every-ai-tool): point Devin and your other agents at the same bank.
@@ -1,110 +0,0 @@
---
title: "From Documents to Decisions: architxt and Hindsight"
authors: [garethjcooper]
slug: "2026/07/03/architxt-hindsight-temporal-mosaic"
date: 2026-07-03T12:00
tags: [hindsight, architxt, integration, memory, temporal, enterprise-architecture, community, tutorial]
description: "How architxt uses Hindsight as a time-aware agent memory layer to turn fragmented enterprise architecture documents into a queryable, current-state Temporal Mosaic."
image: /img/blog/architxt-hindsight.png
hide_table_of_contents: true
---
![From documents to decisions: architxt and Hindsight](/img/blog/architxt-hindsight.png)
Every enterprise system is described somewhere. The problem is *where*.
Architecture decisions live in impact assessments buried in SharePoint. Integration details hide in Confluence pages written three years ago. Current-state diagrams sit in slide decks from a programme that was "phase 2'd" into oblivion. When someone asks, "How does billing actually work now?", the answer is never in one place. It is spread across dozens of documents, each written at a different time, for a different audience, with different assumptions about what was "current."
This is the problem architxt was built to solve. It integrates with [Hindsight](https://github.com/vectorize-io/hindsight), an agent memory system used as a durable, time-aware memory layer for enterprise semantic search and cross-document reasoning.
<!-- truncate -->
## architxt: turn documents into structured knowledge
architxt is a document processing pipeline and research framework, accessed via a web UI. You upload documents (PDFs, Word files, PowerPoint decks) and it extracts clean, structured content using Docling, LLM-based denoising, vision analysis for diagrams, and entity detection. The output is combined with key metadata to prepare it for Hindsight. The main components of this are:
- **Documents** with metadata, tags, and extracted blocks.
- **Entities** (systems, services, capabilities) detected and normalised across documents.
- **Mental models**: reusable LLM prompts that analyse an entity from a specific angle (capabilities, interfaces, summaries). These allow for quick retrieval of common dimensions per entity.
- **A temporal mosaic**: research and query extraction of the best-known state of each entity, regardless of when the source document was written.
![architxt detects and normalises entities across a document, mapping aliases back to a single entity.](/img/blog/architxt-entity-detection.png)
The key insight is that **knowledge freshness is not document freshness**. A 2020 component design is still valid if that component has not changed, even if the rest of the system was rewritten twice since. architxt tracks which document touched which entity, when, and lets you reason across the whole corpus without pretending there is a single "as-is" document.
## The architxt process and minimum viable input
The tool works best when documents arrive with a small amount of consistent data. architxt does not need everything to be perfect; it needs enough structure to know what each document is, when it was produced, and what it is about.
### Document hygiene
Each document should carry:
- **Document ID**: a stable identifier. The same document re-uploaded keeps the same ID.
- **Document date**: the publish date, in ISO 8601. This is the anchor for temporal reasoning.
- **Context**: a curated value describing the layer or purpose of the document, such as impact assessment, component design, or business capability definition.
- **Tags**: consistent filters such as project, domain, system, or data area.
- **Source metadata**: where the document came from (Confluence, SharePoint, a file path) and who produced it.
architxt includes tagging alignment tools that help assign context, tags, source metadata, and entity identifiers. The time saving comes from the bulk change and consistency checking architxt enables across the whole corpus. It aligns information across dozens or hundreds of documents without requiring each one to be manually updated directly through the UI. A document is only useful in the mosaic if you can locate it again, filter by it, and trust its date; architxt uses that externally curated structure to build a reliable, consistent current-state view.
### Entities are the anchor
Entities are the central unit. A component, a service, a capability: each becomes a stable reference point that observations from different documents can attach to. Without entities, a document is just a bag of text. With entities, a sentence in a 2020 design and a paragraph in a 2024 migration document can both refer to the same thing, and architxt can keep the latest view of that thing intact.
In practice, the same entity is rarely called the same thing in every document. One document might say "Billing Engine", another "Billing Service", another "BE". Aliases let architxt map these varied names back to a single entity. The more aliases are known, the more complete the entity timeline becomes.
To make this work across time, entities are embedded into documents with a stable ID, using a lightweight tagging convention such as `Billing Engine (SYS-001)`. The human name can change ("Billing Engine" might become "Billing Platform" in a later design) but the stable ID survives. architxt then resolves the current name against the stable ID, so references from older documents remain usable even after naming conventions shift.
![The entity namespace in architxt: stable IDs, aliases, and the documents each entity appears in.](/img/blog/architxt-entities.png)
This is why the minimum viable input matters. Good metadata and tags make recall precise. A stable, aliased entity namespace makes composition across documents and across years possible. The rest, mental models, reflections, and the temporal mosaic, builds on top of that foundation.
## What Hindsight adds
So, we have the data and have a basic set of metadata. We know there's a goldmine of information, that probably cost thousands or millions of dollars to get written. How do we process and store it in a way that accommodates the variance in source text?
Hindsight stores the extracted knowledge as a durable, time-aware memory bank. Rather than replacing documents with a single summary, it keeps observations as discrete entries that include when they were captured and where they came from. This matters because the temporal mosaic is only possible when you can ask "what do we most recently know about X?" instead of "what does the latest document say?"
Two Hindsight primitives make this work:
- **Retain / Observation**: when architxt extracts facts from a document, they are retained as timestamped, source-referenced facts, and then consolidated into observations. A new document about the same entity does not overwrite the old one; it adds newer facts, and the observation is re-consolidated. The mosaic can then prefer the latest observation per facet while still keeping older ones visible where nothing newer exists.
- **Reflect / Mental models**: mental models are reusable prompts that run over the current set of memories. They can be refreshed as new documentation is added, so summaries, capability lists, and interface descriptions stay current; auto-refresh after consolidation is opt-in. The output is tied to the observations it was based on, so the generated view remains grounded and traceable.
Together, retain and reflect mean the mosaic updates incrementally. New documents are ingested, facts are retained, and mental models are re-run. The "current state" is not rebuilt from scratch; it is the latest layer of a continuously updated knowledge stack.
![Comparing architxt's mental models against a remote Hindsight bank: only on architxt, on both, or only on the bank.](/img/blog/architxt-hindsight-compare.png)
## The Temporal Mosaic: current state without a single source of truth
This combination matters because most architecture tools force one of two models:
1. **Static models**: draw a diagram once, watch it rot.
2. **Designed-vs-delivered reconciliation**: try to maintain two parallel realities and merge them.
architxt takes a third path, using Hindsight as its durable memory layer. The "current state" is a mosaic: the latest reliable knowledge for each entity, sourced from whichever document last touched it. A component from a 2020 design sits next to a service from a 2024 migration document. Seams (contradictions, outdated interfaces, orphaned dependencies) surface only when a query crosses them.
This is the **Temporal Mosaic**. It accepts that organisations do not produce one consistent architecture document. They produce a stream of partial, dated, overlapping documents. Rather than flattening them into a single model, architxt uses Hindsight to make them queryable as a composite, with answers grounded in source documents and tagged with entities so they carry provenance rather than relying on model hallucination.
## What this looks like in practice
Once the documents are tagged and ingested, the question changes. Instead of "which document might have the answer?", you can ask direct questions against the corpus.
For example:
- "What are the integration points between the billing service and the customer platform?"
- "Which capabilities does the order processing system support, and which documents describe them?"
- "Has the data model for customer records changed since the 2022 platform migration?"
![A grounded capability summary for one entity, synthesised from the source documents and organised into facets such as capabilities and interfaces.](/img/blog/architxt-grounded-answer.png)
Hindsight returns grounded answers. Each claim is tied back to the document and observation it came from, so you can verify it rather than trust a generated summary. If two documents disagree, that disagreement is surfaced rather than smoothed over. This turns document search from a guessing game into a structured query.
## In short
The real cost of fragmented architecture knowledge is not the documents themselves. It is the time people spend trying to reconstruct what those documents mean when taken together.
architxt reduces that cost by turning documents into structured, entity-tagged observations. Hindsight keeps those observations alive over time. The Temporal Mosaic is the result: a current-state view that does not pretend the organisation ever produced a single authoritative description, but still makes the combined knowledge searchable, verifiable, and current.
That is the shift. Not more documents, or better diagrams, but a way to extract value from the documents already in place and provide a pathway for keeping that state current as new documents get written. The format and scope of future design documents is unknown, but they will contain information worth leveraging. architxt, coupled with Hindsight, is built to make that possible.
-6
View File
@@ -2,12 +2,6 @@ hindsight:
name: Hindsight Team
url: https://github.com/vectorize-io/hindsight
garethjcooper:
name: Gareth Cooper
title: Community Contributor
url: https://github.com/garethjcooper
image_url: https://github.com/garethjcooper.png
nicoloboschi:
name: Nicolò Boschi
title: Hindsight Team
+37 -41
View File
@@ -1,12 +1,12 @@
---
sidebar_position: 38
title: "Eve Agent Memory with Hindsight | Integration"
description: "Add automatic long-term memory to Vercel Eve agents with Hindsight. Memory is injected before each turn and retained after — no model tool-calling."
description: "Add long-term memory to Vercel Eve agents with Hindsight. A one-line MCP connection gives your agent retain, recall, and reflect across sessions."
---
# Eve
Automatic long-term memory for [Vercel Eve](https://github.com/vercel/eve) agents using [Hindsight](https://vectorize.io/hindsight). Eve is filesystem-first — an agent gains a capability by dropping a file under `agent/`. The `@vectorize-io/hindsight-eve` package wires two files that call Hindsight's REST API directly, so your agent gets memory that **just works**relevant memory is injected before every turn and each exchange is retained after — **without the model ever choosing to call a tool.**
Long-term memory for [Vercel Eve](https://github.com/vercel/eve) agents using [Hindsight](https://vectorize.io/hindsight). Eve is filesystem-first — an agent gains a capability by dropping a file under `agent/connections/`. The `@vectorize-io/hindsight-eve` package wraps Eve's `defineMcpClientConnection`, so one file gives your agent `retain`, `recall`, and `reflect` over Hindsight's MCP server and it remembers across sessions and deployments.
## Install
@@ -18,71 +18,67 @@ npm install @vectorize-io/hindsight-eve
## Quick Start
Create two files:
Create `agent/connections/hindsight.ts`:
```ts
// agent/instructions/hindsight.ts — recall: inject memory before each turn
import { hindsightMemory } from "@vectorize-io/hindsight-eve";
import { defineHindsightConnection } from "@vectorize-io/hindsight-eve";
export default hindsightMemory();
export default defineHindsightConnection();
```
```ts
// agent/hooks/hindsight.ts — retain: save each exchange after the turn
import { hindsightRetainHook } from "@vectorize-io/hindsight-eve";
The connection reads its defaults from the environment:
export default hindsightRetainHook();
```
| Env var | Purpose |
| ----------------------- | ---------------------------------------------------------------- |
| `HINDSIGHT_API_KEY` | Bearer token sent as `Authorization: Bearer <key>` |
| `HINDSIGHT_MCP_URL` | MCP endpoint (defaults to Hindsight Cloud) |
| `HINDSIGHT_MCP_BANK_ID` | Optional bank to scope memory to, sent as the `X-Bank-Id` header |
Both read their config from the environment:
| Env var | Purpose |
| ------------------- | -------------------------------------------------------------- |
| `HINDSIGHT_API_KEY` | Bearer token sent as `Authorization: Bearer <key>` |
| `HINDSIGHT_API_URL` | Hindsight REST base (defaults to Hindsight Cloud) |
| `HINDSIGHT_BANK_ID` | Bank to scope memory to (defaults to `default`; auto-created) |
The model discovers the tools via Eve's `connection__search` and calls them as `connection__hindsight__recall`, `connection__hindsight__retain`, and `connection__hindsight__reflect`. The connection's URL and token never reach the model.
### Hindsight Cloud
Set `HINDSIGHT_API_KEY` from your [Hindsight Cloud](https://hindsight.vectorize.io) dashboard. `HINDSIGHT_API_URL` defaults to `https://api.hindsight.vectorize.io`, so no URL is needed.
Set `HINDSIGHT_API_KEY` from your [Hindsight Cloud](https://hindsight.vectorize.io) dashboard. The connection defaults to `https://api.hindsight.vectorize.io/mcp`, so no URL is needed.
### Self-hosted
```ts
import { hindsightMemory } from "@vectorize-io/hindsight-eve";
Point at your own server, optionally scoping to a bank. Use `apiKey: null` for a no-auth local server:
// A local server with no auth:
export default hindsightMemory({ apiUrl: "http://localhost:8000", apiKey: null });
```ts
import { defineHindsightConnection } from "@vectorize-io/hindsight-eve";
export default defineHindsightConnection({
url: "http://localhost:8000/mcp",
apiKey: null,
});
```
## Options
Both factories accept the same options (each falls back to its env var):
```ts
hindsightMemory({
apiUrl, // REST base; defaults to HINDSIGHT_API_URL, then Cloud
defineHindsightConnection({
url, // MCP endpoint; defaults to HINDSIGHT_MCP_URL, then Cloud
apiKey, // bearer token; null = no auth (local dev)
bankId, // bank to scope memory to
recallQuery, // the broad query used for recall (see below)
budget, // "low" | "mid" | "high" — recall result budget (default "mid")
maxTokens, // recall token budget (default 1024)
context, // `context` tag written on retained items (default "eve")
includeAssistantReply, // also retain the assistant's reply (default false — user message only)
timeoutMs, // HTTP timeout (default 15000)
onError, // (err, phase) => void — failures degrade silently (default console.warn)
bankId, // scope memory to a bank (X-Bank-Id header)
description, // override the model-facing description
tools, // { allow } | { block } — narrow which Hindsight tools the model sees
approval, // human-in-the-loop policy, e.g. once() from "eve/tools/approval"
});
```
## Recall is profile-based, not per-message
Restrict the agent to read-only recall and require approval the first time:
Eve's instruction resolver runs at the start of a turn and **cannot see the live user message**, so recall uses a fixed broad query (default: `"user preferences, identity, and working context"`) to surface the user's ambient profile/context each turn. This is ideal for "the agent knows you" — preferences, identity, ongoing context — and is fully deterministic. Tune it with `recallQuery`. Per-message, query-specific retrieval inherently needs a tool the model calls and is out of scope here.
```ts
import { defineHindsightConnection } from "@vectorize-io/hindsight-eve";
import { once } from "eve/tools/approval";
## Verify
Run your agent. Tell it a durable preference in one chat ("whenever you write me code, use Python with full type hints and no comments"). Start a **fresh** chat and ask for something — the agent applies the remembered preference, because the memory was injected before the model ran, with no tool call.
export default defineHindsightConnection({
tools: { allow: ["recall", "reflect"] },
approval: once(),
});
```
## Links
- [Hindsight docs](https://hindsight.vectorize.io)
- [Eve hooks](https://github.com/vercel/eve/blob/main/docs/guides/hooks.md) · [Eve dynamic capabilities](https://github.com/vercel/eve/blob/main/docs/guides/dynamic-capabilities.md)
- [Eve connections](https://github.com/vercel/eve/blob/main/docs/connections.mdx)
-169
View File
@@ -1,169 +0,0 @@
---
sidebar_position: 40
title: "ZCode Persistent Memory with Hindsight | Integration Guide"
description: "Add persistent long-term memory to ZCode (Z.ai's GLM desktop coding agent) with Hindsight. Python hooks automatically recall context before each prompt and retain conversations — no MCP, no workflow changes."
---
# ZCode
Persistent memory for [ZCode](https://zcode.z.ai) — Z.ai's GLM desktop coding agent — using [Hindsight](https://vectorize.io/hindsight). ZCode embeds the Claude Code agent runtime, so Python hook scripts automatically recall relevant context before each prompt and retain conversations after each turn. No MCP server, no changes to your ZCode workflow.
## Quick Start
:::tip Recommended: Hindsight Cloud
[Sign up free](https://ui.hindsight.vectorize.io/signup) for a Hindsight Cloud API key — no self-hosting, no local daemon to manage.
:::
```bash
# Install the CLI
pip install hindsight-zcode
# Install the hooks (defaults to Hindsight Cloud)
hindsight-zcode install --api-url https://api.hindsight.vectorize.io --api-token your-api-key
# Restart ZCode — memory is live
```
The installer copies the hook scripts to `~/.zcode/hooks/hindsight/`, registers them in `~/.zcode/cli/config.json` (merged with any existing hooks), and creates `~/.hindsight/zcode.json` for your personal config. It never touches your Claude Code config at `~/.claude/settings.json`.
**Self-hosting alternative** — connect to a local `hindsight-embed` daemon by omitting the flags:
```bash
hindsight-zcode install
```
To uninstall:
```bash
hindsight-zcode uninstall
```
### Alternative: install as a ZCode plugin
ZCode can install Hindsight directly from a plugin marketplace — no `pip` step. The same hook scripts ship as a hooks-only Claude Code plugin (`hindsight-zcode`) in the Hindsight marketplace:
```
# In ZCode: add the Hindsight marketplace, then install the plugin
zcode plugins add-marketplace vectorize-io/hindsight
zcode plugins install hindsight-zcode
```
When installed this way, ZCode registers the hooks automatically (no config-file edit). Provide your Hindsight credentials via environment variables (`HINDSIGHT_API_URL`, `HINDSIGHT_API_TOKEN`) or by creating `~/.hindsight/zcode.json`:
```json
{
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "hsk_your_token"
}
```
## Features
- **Auto-recall** — before each prompt, queries Hindsight for relevant memories and injects them as additional context (visible to the model, not the transcript)
- **Auto-retain** — after each response, stores the turn to Hindsight for future recall
- **No MCP required** — plain Python hook scripts calling Hindsight's REST API; nothing to run alongside ZCode
- **Cross-tool memory** — the same Hindsight bank is shared across Claude Code, Cursor, and other Hindsight integrations, so memory follows you between tools
- **Dynamic bank IDs** — supports per-project memory isolation based on the working directory
- **Zero runtime dependencies** — the hook scripts are pure Python stdlib; the `pip install` only ships the one-time installer
## Architecture
ZCode embeds the Claude Code agent runtime and reads the standard Claude Code hook schema from its own config namespace, `~/.zcode/cli/config.json` (with `hooks.enabled: true`). The plugin wires three hook events:
| Hook | Event | Purpose |
|------|-------|---------|
| `session_start.py` | `SessionStart` | Warm up — verify Hindsight is reachable |
| `recall.py` | `UserPromptSubmit` | **Auto-recall** — query memories, inject as `additionalContext` |
| `retain.py` | `Stop` | **Auto-retain** — assemble the turn, POST to Hindsight |
On `UserPromptSubmit`, the hook reads the prompt, queries Hindsight for the most relevant memories, and emits a context block that ZCode injects before sending the turn to the model:
```
<hindsight_memories>
Relevant memories from past conversations...
Current time - 2026-03-27 09:14
- Project uses FastAPI with asyncpg — not SQLAlchemy [world] (2026-03-26)
- Preferred testing framework: pytest with pytest-asyncio [experience] (2026-03-26)
</hindsight_memories>
```
On `Stop`, the hook pairs the user prompt (captured at `UserPromptSubmit`) with the agent's response and POSTs the turn to Hindsight. ZCode does not provide a `SessionEnd` hook event, so retention rides `Stop` — every turn is stored as it completes.
## Connection Modes
### 1. External API (recommended)
Connect to a running Hindsight server (cloud or self-hosted) via `~/.hindsight/zcode.json`:
```json
{
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "hsk_your_token"
}
```
### 2. Local Daemon
Run `hindsight-embed` locally. The `session_start.py` hook detects it on `apiPort` (default `9077`). The daemon is not auto-started by the plugin — start it separately:
```bash
uvx hindsight-embed
```
Then leave `hindsightApiUrl` empty in your config and the plugin connects to `http://localhost:9077`.
## Configuration
Default config ships in `~/.zcode/hooks/hindsight/settings.json`. For personal overrides that survive updates, create `~/.hindsight/zcode.json`. Most settings can also be overridden via environment variable.
**Loading order** (later entries win):
1. Built-in defaults
2. Plugin `settings.json` (at `~/.zcode/hooks/hindsight/settings.json`)
3. User config (`~/.hindsight/zcode.json`)
4. Environment variables
---
### Connection
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `hindsightApiUrl` | `HINDSIGHT_API_URL` | `""` | URL of the Hindsight API server. Empty = local daemon. |
| `hindsightApiToken` | `HINDSIGHT_API_TOKEN` | `null` | API token for authentication. Required for Hindsight Cloud. |
| `apiPort` | `HINDSIGHT_API_PORT` | `9077` | Port for the local `hindsight-embed` daemon. |
---
### Memory Bank
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `bankId` | `HINDSIGHT_BANK_ID` | `"zcode"` | The bank to read from and write to. All sessions share this bank unless `dynamicBankId` is enabled. |
| `bankMission` | `HINDSIGHT_BANK_MISSION` | coding assistant prompt | Describes the agent's purpose. Sent when creating or updating the bank. |
| `dynamicBankId` | `HINDSIGHT_DYNAMIC_BANK_ID` | `false` | When `true`, derives a unique bank ID from `dynamicBankGranularity` fields — useful for per-project isolation. |
| `agentName` | `HINDSIGHT_AGENT_NAME` | `"zcode"` | Agent name used in dynamic bank ID derivation. |
---
### Auto-Recall
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `autoRecall` | `HINDSIGHT_AUTO_RECALL` | `true` | Master switch for auto-recall. |
| `recallBudget` | `HINDSIGHT_RECALL_BUDGET` | `"mid"` | Search depth: `"low"` (fast), `"mid"` (balanced), `"high"` (thorough). |
| `recallMaxTokens` | `HINDSIGHT_RECALL_MAX_TOKENS` | `1024` | Token budget for the injected memory block. |
---
### Auto-Retain
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `autoRetain` | `HINDSIGHT_AUTO_RETAIN` | `true` | Master switch for auto-retain. |
| `retainEveryNTurns` | `HINDSIGHT_RETAIN_EVERY_N_TURNS` | `1` | Retain every N turns. Default `1` stores every turn on `Stop`. |
## Relationship to ZCode's built-in memory
ZCode ships its own local, per-project memory (`~/.zcode/cli/memories/`). Hindsight is complementary: it stores memory in a **cloud (or self-hosted) bank that is shared across tools** — the same bank powers Claude Code, Cursor, and other Hindsight integrations — so your context follows you between agents and machines rather than staying local to one ZCode project.
+1 -11
View File
@@ -140,16 +140,6 @@
"link": "/sdks/integrations/claude-code",
"icon": "/img/icons/claude-code.png"
},
{
"id": "zcode",
"name": "ZCode",
"description": "No-MCP long-term memory for ZCode (Z.ai's GLM desktop coding agent) via Hindsight hooks. Recalls relevant context before each prompt and retains conversations after each turn.",
"type": "official",
"by": "hindsight",
"category": "tool",
"link": "/sdks/integrations/zcode",
"icon": "/img/icons/zcode.svg"
},
{
"id": "claude-agent-sdk",
"name": "Claude Agent SDK",
@@ -383,7 +373,7 @@
{
"id": "eve",
"name": "Eve",
"description": "Automatic long-term memory for Vercel Eve agents. Memory is injected before each turn and retained after, with no model tool-calling.",
"description": "Long-term memory for Vercel Eve agents. A one-line MCP connection exposing retain, recall, and reflect.",
"type": "official",
"by": "hindsight",
"category": "framework",
@@ -10,12 +10,6 @@ For the source code, see [`hindsight-integrations/eve`](https://github.com/vecto
← [Back to main changelog](/changelog)
## [0.2.0](https://github.com/vectorize-io/hindsight/tree/integrations/eve/v0.2.0)
**Features**
- Added an auto-memory mode that works without model tool-calling, enabling Eve to capture memories automatically.<span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/benfrank241" target="_blank" rel="noopener noreferrer" style={{color: "var(--ifm-color-primary)", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: "4px", verticalAlign: "middle"}}><img src="https://github.com/benfrank241.png?size=40" alt="@benfrank241" width="18" height="18" style={{borderRadius: "50%"}} />@benfrank241</a><span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/vectorize-io/hindsight/commit/dd7e25245" target="_blank" rel="noopener noreferrer" style={{fontFamily: "var(--ifm-font-family-monospace, monospace)", fontSize: "0.85em", color: "var(--ifm-color-emphasis-600)"}}>dd7e25245</a>
## [0.1.0](https://github.com/vectorize-io/hindsight/tree/integrations/eve/v0.1.0)
**Features**
Binary file not shown.

Before

Width:  |  Height:  |  Size: 612 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

-10
View File
@@ -1,10 +0,0 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="zcodeA" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#4F7CFF"/>
<stop offset="1" stop-color="#1BC7B4"/>
</linearGradient>
</defs>
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#zcodeA)"/>
<path d="M22 22h20v5.2L28.4 42H42v6H22v-5.2L35.6 28H22z" fill="#FFFFFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 479 B

+936
View File
@@ -2010,6 +2010,531 @@
}
}
},
"/v1/default/banks/{bank_id}/knowledge-base/tree": {
"get": {
"tags": [
"Knowledge Base"
],
"summary": "Get the knowledge-base tree",
"description": "Return the knowledge base as a nested tree of folders and pages.",
"operationId": "get_knowledge_base_tree",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KnowledgeTreeResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/knowledge-base/folders": {
"post": {
"tags": [
"Knowledge Base"
],
"summary": "Create a knowledge-base folder",
"description": "Create a folder, optionally nested under a parent folder.",
"operationId": "create_knowledge_folder",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateFolderRequest"
}
}
}
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KnowledgeNode"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/knowledge-base/pages": {
"post": {
"tags": [
"Knowledge Base"
],
"summary": "Create a knowledge-base page",
"description": "Create a page (a mental model + tree node). Content is generated asynchronously; use the returned operation_id to track completion.",
"operationId": "create_knowledge_page",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePageRequest"
}
}
}
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateKnowledgePageResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/knowledge-base/graph": {
"get": {
"tags": [
"Knowledge Base"
],
"summary": "Knowledge-base constellation graph",
"description": "Return pages as nodes linked by shared tags, for the constellation view.",
"operationId": "get_knowledge_base_graph",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KnowledgePageGraphResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/knowledge-base/export": {
"get": {
"tags": [
"Knowledge Base"
],
"summary": "Export the knowledge base as an OKF bundle",
"description": "Return a portable OKF bundle: a nested index.md, one <id>.md per page, and history logs.",
"operationId": "export_knowledge_base",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KnowledgePageBundleResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}": {
"get": {
"tags": [
"Knowledge Base"
],
"summary": "Get a knowledge-base page",
"description": "Return a single page as an OKF document (frontmatter + markdown body).",
"operationId": "get_knowledge_page",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "page_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Page Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KnowledgePageResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}": {
"patch": {
"tags": [
"Knowledge Base"
],
"summary": "Rename or move a knowledge-base node",
"description": "Rename a node (set `name`) and/or move it under another folder (set `parent_id`, null for the root).",
"operationId": "update_knowledge_node",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "node_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Node Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateNodeRequest"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KnowledgeNode"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"delete": {
"tags": [
"Knowledge Base"
],
"summary": "Delete a knowledge-base node",
"description": "Delete a folder or page and its whole subtree (pages' mental models are removed too).",
"operationId": "delete_knowledge_node",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "node_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Node Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/directives": {
"get": {
"tags": [
@@ -7744,6 +8269,61 @@
"title": "CreateDirectiveRequest",
"description": "Request model for creating a directive."
},
"CreateFolderRequest": {
"properties": {
"name": {
"type": "string",
"title": "Name"
},
"parent_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Parent Id"
}
},
"type": "object",
"required": [
"name"
],
"title": "CreateFolderRequest",
"description": "Create a folder under an optional parent folder."
},
"CreateKnowledgePageResponse": {
"properties": {
"page_id": {
"type": "string",
"title": "Page Id"
},
"mental_model_id": {
"type": "string",
"title": "Mental Model Id"
},
"operation_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Operation Id"
}
},
"type": "object",
"required": [
"page_id",
"mental_model_id"
],
"title": "CreateKnowledgePageResponse",
"description": "Result of creating a page: the node id, its mental model, and the refresh op."
},
"CreateMentalModelRequest": {
"properties": {
"id": {
@@ -7838,6 +8418,71 @@
"title": "CreateMentalModelResponse",
"description": "Response model for mental model creation."
},
"CreatePageRequest": {
"properties": {
"name": {
"type": "string",
"title": "Name"
},
"source_query": {
"type": "string",
"title": "Source Query"
},
"parent_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Parent Id"
},
"tags": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Tags"
},
"max_tokens": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Max Tokens"
},
"trigger": {
"anyOf": [
{
"$ref": "#/components/schemas/MentalModelTrigger-Input"
},
{
"type": "null"
}
]
}
},
"type": "object",
"required": [
"name",
"source_query"
],
"title": "CreatePageRequest",
"description": "Create a page (a mental model + tree node) under an optional parent folder."
},
"CreateWebhookRequest": {
"properties": {
"url": {
@@ -9064,6 +9709,268 @@
"title": "IncludeOptions",
"description": "Options for including additional data in recall results."
},
"KnowledgeNode": {
"properties": {
"id": {
"type": "string",
"title": "Id"
},
"kind": {
"type": "string",
"enum": [
"folder",
"page"
],
"title": "Kind"
},
"name": {
"type": "string",
"title": "Name"
},
"parent_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Parent Id"
},
"mental_model_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Mental Model Id",
"description": "Backing mental model id (pages only)."
},
"managed": {
"type": "boolean",
"title": "Managed",
"description": "Client-set flag: true = system-owned, false = hand-authored.",
"default": false
},
"description": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Description",
"description": "Page source query (OKF `description`)."
},
"tags": {
"items": {
"type": "string"
},
"type": "array",
"title": "Tags",
"default": []
},
"timestamp": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Timestamp",
"description": "Last refresh (page) or last update (folder)."
},
"children": {
"items": {
"$ref": "#/components/schemas/KnowledgeNode"
},
"type": "array",
"title": "Children",
"default": []
}
},
"type": "object",
"required": [
"id",
"kind",
"name"
],
"title": "KnowledgeNode",
"description": "A node in the knowledge-base tree \u2014 a folder or a page.\n\nPages carry ``description``/``tags`` from their backing mental model. The\nknowledge base is client-managed (CRUD); ``managed`` lets a client tag a node\nas system-owned vs. hand-authored."
},
"KnowledgePageBundleFile": {
"properties": {
"path": {
"type": "string",
"title": "Path"
},
"content": {
"type": "string",
"title": "Content"
}
},
"type": "object",
"required": [
"path",
"content"
],
"title": "KnowledgePageBundleFile",
"description": "One file in a portable OKF bundle."
},
"KnowledgePageBundleResponse": {
"properties": {
"files": {
"items": {
"$ref": "#/components/schemas/KnowledgePageBundleFile"
},
"type": "array",
"title": "Files"
}
},
"type": "object",
"required": [
"files"
],
"title": "KnowledgePageBundleResponse",
"description": "A portable OKF bundle \u2014 a flat set of markdown files (index + pages + logs)."
},
"KnowledgePageGraphResponse": {
"properties": {
"nodes": {
"items": {
"additionalProperties": true,
"type": "object"
},
"type": "array",
"title": "Nodes"
},
"edges": {
"items": {
"additionalProperties": true,
"type": "object"
},
"type": "array",
"title": "Edges"
},
"total_pages": {
"type": "integer",
"title": "Total Pages"
},
"total_edges": {
"type": "integer",
"title": "Total Edges"
}
},
"type": "object",
"required": [
"nodes",
"edges",
"total_pages",
"total_edges"
],
"title": "KnowledgePageGraphResponse",
"description": "Constellation graph of knowledge pages linked by shared tags."
},
"KnowledgePageResponse": {
"properties": {
"id": {
"type": "string",
"title": "Id"
},
"name": {
"type": "string",
"title": "Name"
},
"type": {
"type": "string",
"title": "Type",
"description": "OKF document type \u2014 from a `type:<x>` tag, else 'knowledge-page'."
},
"description": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Description",
"description": "The source query that rebuilds the page."
},
"tags": {
"items": {
"type": "string"
},
"type": "array",
"title": "Tags",
"default": []
},
"timestamp": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Timestamp",
"description": "Last refresh time (falls back to creation)."
},
"body": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Body",
"description": "The page's synthesized markdown body."
},
"markdown": {
"type": "string",
"title": "Markdown",
"description": "The full OKF document: YAML frontmatter + markdown body."
}
},
"type": "object",
"required": [
"id",
"name",
"type",
"markdown"
],
"title": "KnowledgePageResponse",
"description": "A knowledge page rendered as an OKF document."
},
"KnowledgeTreeResponse": {
"properties": {
"roots": {
"items": {
"$ref": "#/components/schemas/KnowledgeNode"
},
"type": "array",
"title": "Roots"
}
},
"type": "object",
"required": [
"roots"
],
"title": "KnowledgeTreeResponse",
"description": "The knowledge base as a nested folder/page tree."
},
"LLMRequestEntry": {
"properties": {
"id": {
@@ -12797,6 +13704,35 @@
}
}
},
"UpdateNodeRequest": {
"properties": {
"name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Name"
},
"parent_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Parent Id"
}
},
"type": "object",
"title": "UpdateNodeRequest",
"description": "Rename and/or move a node. Each field applies only when present."
},
"UpdateWebhookRequest": {
"properties": {
"url": {
+51 -68
View File
@@ -1,19 +1,18 @@
# Hindsight for Eve
Automatic long-term memory for [Vercel Eve](https://github.com/vercel/eve) agents, powered by
[Hindsight](https://vectorize.io/hindsight). Two files give your agent memory that **just
works** — relevant memory is injected before every turn, and each exchange is saved after —
**without the model ever choosing to call a tool.**
Long-term memory for [Vercel Eve](https://github.com/vercel/eve) agents, powered by
[Hindsight](https://vectorize.io/hindsight). One file gives your agent `retain`, `recall`,
and `reflect` over [Hindsight's MCP server](https://hindsight.vectorize.io) — so it
remembers facts across sessions and deployments instead of starting cold every time.
## How it works
Eve is filesystem-first. This package wires two authored files that call Hindsight's REST API
directly, so memory never depends on the LLM deciding to call a tool:
- **`agent/instructions/hindsight.ts`** — a dynamic instructions resolver that, before each
turn, recalls the user's stored memory from Hindsight and injects it as a system message.
- **`agent/hooks/hindsight.ts`** — a hook that, after each turn, retains the user message and
the assistant's answer to Hindsight.
Eve is filesystem-first: an agent gains a capability by dropping a file under
`agent/connections/`. This package wraps eve's `defineMcpClientConnection`, pre-filling the
Hindsight MCP endpoint, a model-facing description, and bearer auth. The model discovers the
tools through `connection__search` and calls them as `connection__hindsight__recall`,
`connection__hindsight__retain`, and `connection__hindsight__reflect`. The connection's URL
and token never reach the model.
## Install
@@ -25,97 +24,81 @@ npm install @vectorize-io/hindsight-eve
## Quick start
Create two files:
Create `agent/connections/hindsight.ts`:
```ts
// agent/instructions/hindsight.ts
import { hindsightMemory } from "@vectorize-io/hindsight-eve";
import { defineHindsightConnection } from "@vectorize-io/hindsight-eve";
export default hindsightMemory();
export default defineHindsightConnection();
```
```ts
// agent/hooks/hindsight.ts
import { hindsightRetainHook } from "@vectorize-io/hindsight-eve";
That's it. By default the connection reads:
export default hindsightRetainHook();
```
That's it. Both read their config from the environment:
| Env var | Purpose |
| ------------------- | ------------------------------------------------------------- |
| `HINDSIGHT_API_KEY` | Bearer token sent as `Authorization: Bearer <key>` |
| `HINDSIGHT_API_URL` | Hindsight REST base (defaults to Hindsight Cloud) |
| `HINDSIGHT_BANK_ID` | Bank to scope memory to (defaults to `default`; auto-created) |
| Env var | Purpose |
| ----------------------- | ---------------------------------------------------------------- |
| `HINDSIGHT_API_KEY` | Bearer token sent as `Authorization: Bearer <key>` |
| `HINDSIGHT_MCP_URL` | MCP endpoint (defaults to Hindsight Cloud) |
| `HINDSIGHT_MCP_BANK_ID` | Optional bank to scope memory to, sent as the `X-Bank-Id` header |
### Hindsight Cloud
Set `HINDSIGHT_API_KEY` to a key from your [Hindsight Cloud](https://hindsight.vectorize.io)
dashboard. `HINDSIGHT_API_URL` defaults to `https://api.hindsight.vectorize.io`, so no URL is
dashboard. The connection defaults to `https://api.hindsight.vectorize.io/mcp`, so no URL is
needed.
### Self-hosted
Point at your own server and (optionally) pick a bank:
```bash
export HINDSIGHT_API_URL="http://localhost:8000"
export HINDSIGHT_BANK_ID="my-project"
export HINDSIGHT_API_KEY="…" # or pass apiKey: null below for a no-auth server
export HINDSIGHT_MCP_URL="http://localhost:8000/mcp"
export HINDSIGHT_MCP_BANK_ID="my-project"
export HINDSIGHT_API_KEY="…" # or omit and pass apiKey: null below for a no-auth server
```
```ts
import { hindsightMemory } from "@vectorize-io/hindsight-eve";
import { defineHindsightConnection } from "@vectorize-io/hindsight-eve";
export default hindsightMemory({ apiUrl: "http://localhost:8000", apiKey: null });
// A local server with no auth:
export default defineHindsightConnection({
url: "http://localhost:8000/mcp",
apiKey: null,
});
```
## Options
Both factories accept the same options (each falls back to its env var):
```ts
hindsightMemory({
apiUrl, // string — REST base; defaults to HINDSIGHT_API_URL, then Cloud
defineHindsightConnection({
url, // string — MCP endpoint; defaults to HINDSIGHT_MCP_URL, then Cloud
apiKey, // string | null — bearer token; null = no auth (local dev)
bankId, // string — bank to scope memory to
recallQuery, // string — the broad query used for recall (see below)
budget, // "low" | "mid" | "high" — recall result budget (default "mid")
maxTokens, // number — recall token budget (default 1024)
context, // string — `context` tag written on retained items (default "eve")
includeAssistantReply, // boolean — also retain the assistant's reply (default false)
timeoutMs, // number — HTTP timeout (default 15000)
onError, // (err, phase) => void — failures degrade silently via this (default console.warn)
bankId, // string — scope memory to a bank (X-Bank-Id header)
description, // string — override the model-facing description
tools, // { allow } | { block } — narrow which Hindsight tools the model sees
approval, // human-in-the-loop policy, e.g. once() from "eve/tools/approval"
});
```
## Recall is profile-based, not per-message
Restrict the agent to read-only recall, and require approval the first time:
Eve's instruction resolver runs at the start of a turn and **cannot see the live user
message**, so recall uses a fixed broad query (default:
`"user preferences, identity, and working context"`) to surface the user's ambient
profile/context each turn. This is ideal for "the agent knows you" — preferences, identity,
ongoing context — and is deterministic. Tune it with `recallQuery`. (Per-message, query-
specific retrieval inherently needs a tool the model calls; that's out of scope here.)
```ts
import { defineHindsightConnection } from "@vectorize-io/hindsight-eve";
import { once } from "eve/tools/approval";
## Notes
- Memory is scoped to a **bank** (one isolated store, e.g. per user). Point both files at the
same `HINDSIGHT_BANK_ID`.
- By default only the **user's** message is retained (the durable signal) — set
`includeAssistantReply: true` to also store the assistant's reply.
- Retains run asynchronously and never block a turn; failures degrade via `onError`.
- The recall block injected into context is fenced with a sentinel so recalled facts are never
re-retained.
export default defineHindsightConnection({
tools: { allow: ["recall", "reflect"] },
approval: once(),
});
```
## Verify
Run your agent. Tell it a durable preference in one chat ("whenever you write me code, use
Python with full type hints and no comments"). Start a **fresh** chat and ask for something —
the agent applies the remembered preference, because the memory was injected before the model
ran, with no tool call.
With the connection in place, run your agent and ask it something it would need to look up
("what did we decide about X last week?"). Eve's `connection__search` surfaces the Hindsight
tools and the model calls `connection__hindsight__recall`. To seed memory, have the agent
`retain` a fact in one session and `recall` it in the next.
## Links
- [Hindsight docs](https://hindsight.vectorize.io)
- [Eve hooks](https://github.com/vercel/eve/blob/main/docs/guides/hooks.md) ·
[Eve dynamic capabilities](https://github.com/vercel/eve/blob/main/docs/guides/dynamic-capabilities.md)
- [Eve connections](https://github.com/vercel/eve/blob/main/docs/connections.mdx)
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "@vectorize-io/hindsight-eve",
"version": "0.2.0",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@vectorize-io/hindsight-eve",
"version": "0.2.0",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^22.0.0",
@@ -16,7 +16,7 @@
"vitest": "^4.1.2"
},
"engines": {
"node": ">=24"
"node": ">=22"
},
"peerDependencies": {
"eve": ">=0.11.0"
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@vectorize-io/hindsight-eve",
"version": "0.2.0",
"description": "Automatic long-term memory for Vercel Eve agents — Hindsight memory injected before each turn and retained after, with no model tool-calling",
"version": "0.1.0",
"description": "Hindsight long-term memory for Vercel Eve agents - a one-line MCP connection exposing retain, recall, and reflect",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
@@ -15,7 +15,7 @@
"eve",
"vercel",
"agents",
"hooks",
"mcp",
"memory",
"hindsight",
"llm",
@@ -1,127 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { hindsightAutoRecall, hindsightRetainHook } from "./auto-memory";
import { SENTINEL_OPEN } from "./client";
const OPTS = { apiUrl: "http://test", apiKey: "k", bankId: "b" };
const CTX = { session: { id: "s1" }, channel: { kind: "web" } } as unknown;
/** Mock fetch, routing by URL; returns recall results or a retain ack. */
function mockFetch(recallResults: unknown[] = []): ReturnType<typeof vi.fn> {
const fn = vi.fn(async (url: string) => ({
ok: true,
status: 200,
json: async () => (url.includes("/recall") ? { results: recallResults } : { success: true }),
text: async () => "",
}));
vi.stubGlobal("fetch", fn);
return fn;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handlers = (def: { events: unknown }): any => def.events;
afterEach(() => vi.unstubAllGlobals());
describe("hindsightRetainHook", () => {
it("retains the user's message on turn.completed (user-only by default)", async () => {
const fetchFn = mockFetch();
const ev = handlers(hindsightRetainHook(OPTS));
ev["message.received"]({ data: { turnId: "t1", message: "I prefer tabs" } });
ev["message.completed"]({ data: { turnId: "t1", message: "Got it.", finishReason: "stop" } });
await ev["turn.completed"]({ data: { turnId: "t1" } }, CTX);
expect(fetchFn).toHaveBeenCalledTimes(1);
const [url, init] = fetchFn.mock.calls[0];
expect(url).toBe("http://test/v1/default/banks/b/memories");
const body = JSON.parse(init.body);
expect(body.async).toBe(true);
expect(body.items[0].content).toBe("User: I prefer tabs");
expect(body.items[0].context).toBe("eve");
expect(body.items[0].metadata).toMatchObject({ sessionId: "s1", turnId: "t1", channel: "web" });
});
it("includes the assistant reply when includeAssistantReply is set", async () => {
const fetchFn = mockFetch();
const ev = handlers(hindsightRetainHook({ ...OPTS, includeAssistantReply: true }));
ev["message.received"]({ data: { turnId: "t1", message: "I prefer tabs" } });
ev["message.completed"]({ data: { turnId: "t1", message: "Got it.", finishReason: "stop" } });
await ev["turn.completed"]({ data: { turnId: "t1" } }, CTX);
expect(JSON.parse(fetchFn.mock.calls[0][1].body).items[0].content).toBe(
"User: I prefer tabs\n\nAssistant: Got it."
);
});
it("ignores non-terminal assistant steps (finishReason !== 'stop')", async () => {
const fetchFn = mockFetch();
const ev = handlers(hindsightRetainHook(OPTS));
ev["message.received"]({ data: { turnId: "t1", message: "hi" } });
ev["message.completed"]({
data: { turnId: "t1", message: "calling tool", finishReason: "tool-calls" },
});
await ev["turn.completed"]({ data: { turnId: "t1" } }, CTX);
// still retains (user text present), but content has no assistant half
expect(JSON.parse(fetchFn.mock.calls[0][1].body).items[0].content).toBe("User: hi");
});
it("does not retain a turn with no user message", async () => {
const fetchFn = mockFetch();
const ev = handlers(hindsightRetainHook(OPTS));
ev["message.completed"]({ data: { turnId: "t1", message: "orphan", finishReason: "stop" } });
await ev["turn.completed"]({ data: { turnId: "t1" } }, CTX);
expect(fetchFn).not.toHaveBeenCalled();
});
it("never throws on a retain failure (degrades via onError)", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: false,
status: 500,
json: async () => ({}),
text: async () => "boom",
}))
);
const onError = vi.fn();
const ev = handlers(hindsightRetainHook({ ...OPTS, onError }));
ev["message.received"]({ data: { turnId: "t1", message: "x" } });
await expect(ev["turn.completed"]({ data: { turnId: "t1" } }, CTX)).resolves.toBeUndefined();
expect(onError).toHaveBeenCalledWith(expect.anything(), "retain");
});
});
describe("hindsightAutoRecall", () => {
it("recalls and returns injected instructions containing the memories", async () => {
const fetchFn = mockFetch([{ id: "1", text: "prefers Python" }]);
const ev = handlers(hindsightAutoRecall(OPTS));
const result = await ev["turn.started"]({ data: { turnId: "t1" } }, CTX);
const [url, init] = fetchFn.mock.calls[0];
expect(url).toBe("http://test/v1/default/banks/b/memories/recall");
expect(JSON.parse(init.body).query).toBe("user preferences, identity, and working context");
expect(result.markdown).toContain(SENTINEL_OPEN);
expect(result.markdown).toContain("- prefers Python");
});
it("returns undefined when there is nothing to recall", async () => {
mockFetch([]);
const ev = handlers(hindsightAutoRecall(OPTS));
expect(await ev["turn.started"]({ data: { turnId: "t1" } }, CTX)).toBeUndefined();
});
it("returns undefined and reports onError on a recall failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: false,
status: 500,
json: async () => ({}),
text: async () => "boom",
}))
);
const onError = vi.fn();
const ev = handlers(hindsightAutoRecall({ ...OPTS, onError }));
expect(await ev["turn.started"]({ data: { turnId: "t1" } }, CTX)).toBeUndefined();
expect(onError).toHaveBeenCalledWith(expect.anything(), "recall");
});
});
@@ -1,118 +0,0 @@
/**
* Automatic, no-tool long-term memory for Vercel Eve agents, backed by
* Hindsight's REST API. Two authored files give an agent memory that works
* without the model ever choosing to call a tool:
*
* ```ts
* // agent/instructions/hindsight.ts — recall: inject memory before each turn
* import { hindsightMemory } from "@vectorize-io/hindsight-eve";
* export default hindsightMemory();
*
* // agent/hooks/hindsight.ts — retain: save each exchange after the turn
* import { hindsightRetainHook } from "@vectorize-io/hindsight-eve";
* export default hindsightRetainHook();
* ```
*
* This module is the only one that imports `eve`. The HTTP client and config
* resolution are kept pure (in `./client` and `./config`) so they unit-test
* without the framework.
*/
import { defineHook, type HookDefinition } from "eve/hooks";
import { defineDynamic, defineInstructions, type DynamicSentinel } from "eve/instructions";
import { HindsightRestClient, buildRecallMarkdown } from "./client.js";
import {
buildRetainContent,
recordAssistantMessage,
recordUserMessage,
resolveAutoMemory,
takeTurn,
type AutoMemoryOptions,
type TurnBuffer,
} from "./config.js";
export type { AutoMemoryOptions } from "./config.js";
/**
* Inject the user's stored memory as a system message before each turn.
* Drop the returned value as the default export of `agent/instructions/hindsight.ts`.
*
* Recall uses a fixed broad query (not the live message eve's instruction
* resolver can't see it), which surfaces the user's ambient profile/context.
* Tune it with `recallQuery`.
*/
export function hindsightAutoRecall(options: AutoMemoryOptions = {}): DynamicSentinel {
const cfg = resolveAutoMemory(options);
const client = new HindsightRestClient(cfg.apiUrl, cfg.apiKey, cfg.timeoutMs);
return defineDynamic({
events: {
"turn.started": async (): Promise<unknown> => {
try {
const { results } = await client.recall(cfg.bankId, cfg.recallQuery, {
budget: cfg.budget,
maxTokens: cfg.maxTokens,
});
if (results.length === 0) return undefined;
return defineInstructions({ markdown: buildRecallMarkdown(results) });
} catch (error) {
cfg.onError(error, "recall");
return undefined;
}
},
},
});
}
/** Primary name for {@link hindsightAutoRecall} — the memory-injection half. */
export const hindsightMemory = hindsightAutoRecall;
/**
* Retain each completed exchange to Hindsight. Drop the returned value as the
* default export of `agent/hooks/hindsight.ts`.
*
* Pairs the user message (`message.received`) with the final assistant answer
* (`message.completed` where `finishReason === "stop"`) by `turnId`, then
* retains on `turn.completed`. All side effects are guarded a failure warns
* via `onError` and never breaks the turn.
*/
export function hindsightRetainHook(options: AutoMemoryOptions = {}): HookDefinition {
const cfg = resolveAutoMemory(options);
const client = new HindsightRestClient(cfg.apiUrl, cfg.apiKey, cfg.timeoutMs);
const buffer: TurnBuffer = new Map();
return defineHook({
events: {
"message.received": (event) => {
recordUserMessage(buffer, event.data.turnId, event.data.message);
},
"message.completed": (event) => {
// Only the terminal assistant text; intermediate steps end in "tool-calls".
if (event.data.finishReason === "stop" && event.data.message) {
recordAssistantMessage(buffer, event.data.turnId, event.data.message);
}
},
"turn.completed": async (event, ctx) => {
try {
const content = buildRetainContent(
takeTurn(buffer, event.data.turnId),
cfg.includeAssistantReply
);
if (content === null) return;
const metadata: Record<string, string> = {
sessionId: ctx.session.id,
turnId: event.data.turnId,
};
if (ctx.channel.kind) metadata.channel = ctx.channel.kind;
await client.retain(
cfg.bankId,
[{ content, context: cfg.context, metadata, timestamp: new Date().toISOString() }],
{ async: true }
);
} catch (error) {
cfg.onError(error, "retain");
}
},
},
});
}

Some files were not shown because too many files have changed in this diff Show More