Compare commits

..
Author SHA1 Message Date
Dikshant PradhanandClaude Opus 4.8 8afec653d8 docs(oracle): correct managed-service note, add connection caveat
The connection layer builds the Oracle DSN from the URL as a plain
host:port/service_name descriptor — wallet-based mTLS, TLS/TCPS, and TNS
aliases / full connect descriptors are not wired up. The previous "Least
privilege" note implied Oracle Autonomous Database works via an
ADMIN-provisioned user, which is misleading since ADB defaults to wallet/mTLS.

- Reworded the managed-service note to drop the specific ADB claim while
  keeping the accurate requirement (ASSM tablespace + CTXAPP).
- Added an "Easy Connect only" warning documenting that wallet/mTLS/TLS and
  TNS descriptors are unsupported, and that transport encryption must be
  handled at the network layer.

Applied to the current and version-0.8 copies; regenerated the docs skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01PginSDrapXsoDd6gN5Pszo
2026-07-07 14:29:20 -07:00
Dikshant PradhanandClaude Opus 4.8 b68c61f672 docs: add Oracle Database setup guide
Hindsight supports Oracle Database 23ai as a storage backend, but the docs
only mentioned it in passing — a one-paragraph note on the Storage page and a
couple of Configuration reference rows, with no `oracle+oracledb://` example
anywhere. This adds a dedicated Oracle Database page under Hosting.

The guide covers requirements (Oracle 23ai, the ASSM-tablespace requirement
for VECTOR columns, Oracle Text / CTXAPP), installing the python-oracledb
driver, a local quick start via scripts/dev/start-oracle.sh, production
provisioning SQL + connection URL + env vars + migrations, a config reference,
the differences from PostgreSQL, and troubleshooting. Content is grounded in
the CI Oracle job, the dev script, and the backend code.

Registered in the sidebar and cross-linked from Storage and Configuration.
Regenerated the docs agent-skill and mirrored the change into
versioned_docs/version-0.8 so it ships on the currently-served version.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01PginSDrapXsoDd6gN5Pszo
2026-07-07 14:29:20 -07:00
Ben 041f0f13f4 release(zed): v0.2.0 2026-07-07 16:02:06 -04:00
9dee1c594b refactor(zed): port setup CLI to Node, drop the Python dependency (#2599)
* refactor(zed): port setup CLI to Node, drop the Python dependency

The Zed integration is configuration-only — it writes the `context_servers`
entry into Zed's settings.json and a recall/retain rule into AGENTS.md — and the
MCP server it configures runs via `npx mcp-remote`, so Node.js was already a hard
requirement. Requiring Python *as well* just to write two config files meant
users needed two runtimes.

Port the `hindsight-zed` CLI to a zero-dependency Node CLI so the integration
needs only Node:

- Node CLI under `src/` + `bin/hindsight-zed.js`, shipped via `package.json`
  (matches the existing TypeScript integrations; release-integration.yml already
  detects package.json for npm publishing).
- Behavior-preserving: same commands (`init`/`status`/`uninstall`), flags,
  `--print-only`, env/file/flag config resolution, JSONC-safe settings edits,
  and fenced AGENTS.md rule block.
- Tests ported to Node's built-in runner (`node --test`) — 21 tests.
- CI (`test.yml`) updated to run `npm test` on Node 22 instead of pytest.
- Removes the Python package (`hindsight_zed/`, `pyproject.toml`, `uv.lock`,
  Python `tests/`).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(zed): make the Node package publishable by the release workflow

The release workflow classifies any integration with a package.json as
`type=typescript` and unconditionally runs `npm ci` + `npm run build` in
the integration dir. This zero-dependency, no-build JS package had neither,
so `integrations/zed/v*` would fail at release time (invisible in test CI,
which only runs `npm test`):

- add a no-op `build` script so `npm run build` succeeds
- commit package-lock.json so `npm ci` succeeds (it refuses to run without
  one, even with zero deps); lockfile has no node_modules entries, so
  check-integration-lockfiles.sh passes trivially
- drop the stray settings.json (a local `init` scaffold accidentally
  committed) and gitignore it

Verified locally: node --test (21/21), npm ci, npm run build, and
npm publish --dry-run all pass; tarball ships only bin/src/README.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* docs(zed): update setup to Node/npx (drop pip install)

* refactor(zed): scope npm package as @vectorize-io/hindsight-zed

Match the scoped-name convention of the other TS integrations
(@vectorize-io/hindsight-ai-sdk, -chat, -openclaw). CLI/bin command stays
'hindsight-zed'; npx/global-install references updated to the scoped name.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: DK09876 <[email protected]>
2026-07-07 16:01:33 -04:00
Ben a1ebb2d9c6 feat(llm): recover outer JSON span when fence stripping yields non-JSON (#2610)
Grafts the parse-validated fallback from #2557 onto the line-based fence
stripper merged in #2563: after stripping, if the candidate is not valid
JSON (partial/absent fence, prose-wrapped or truncated output), fall back
to the outermost parseable {..}/[..] span. Never returns a worse candidate
than the raw content.
2026-07-07 15:44:02 -04:00
Parafee41 06ddf041e5 fix(minimax): disable thinking by default (#2558) 2026-07-07 15:27:50 -04:00
poog26andBen d251fcb7d2 Fix _strip_code_fences truncating JSON when content contains inner backticks (#2563)
* Fix _strip_code_fences truncating JSON when content contains inner backticks

The old implementation used content.split('')[0] to strip
markdown code fences from LLM responses. This finds the FIRST occurrence of '''
after the opening fence — so when the extracted JSON itself contains literal
triple-backtick characters (e.g. facts about code fence formatting), the split
matches those inner backticks and truncates the JSON mid-string.

Replace with line-based fence detection that only matches fences at line
boundaries per the markdown spec. Inner backticks inside JSON string values
are preserved since they aren't at line boundaries.

* test(llm): cover inner-backtick fence stripping regression

---------

Co-authored-by: Ben <[email protected]>
2026-07-07 15:25:55 -04:00
Parafee41 e839c65537 fix(cli): set default user agent (#2564) 2026-07-07 15:12:18 -04:00
Evoandr266-tech 0cde79b831 fix(consolidation): default invalid dedup actions to keep (#2565)
Co-authored-by: r266-tech <[email protected]>
2026-07-07 15:04:04 -04:00
Ben 8767a518db docs(skill): sync configuration reference for BM25 term cap (#2609) 2026-07-07 15:01:21 -04:00
Parafee41 10ed288d80 build control plane client dependency (#2566) 2026-07-07 14:48:40 -04:00
7143684a81 feat(recall): add opt-in BM25 query term cap (#2567)
* Add opt-in BM25 query term cap

* docs(config): document HINDSIGHT_API_BM25_MAX_QUERY_TERMS

---------

Co-authored-by: r266-tech <[email protected]>
Co-authored-by: Ben <[email protected]>
2026-07-07 14:41:02 -04:00
Nick Old 11da432db2 Fix chunk delete deadlock ordering (#2570) 2026-07-07 14:28:43 -04:00
ishanmalikandishanmalik 73d3231bbd fix(api): accept verbatim extraction mode in bank manifests (#2576)
Co-authored-by: ishanmalik <[email protected]>
2026-07-07 14:06:35 -04:00
Ben 59d825dfca release(opencode): v0.2.7 2026-07-07 13:50:23 -04:00
Sanderhoff-alt ae7099fd02 fix(opencode): install plugin SDK at runtime (#2574)
The OpenCode plugin imports @opencode-ai/plugin/tool from its
built dist entrypoint, so the package must be present in
OpenCode's isolated plugin cache. Keeping it only as a peer
dependency lets npm skip it during plugin installation, which can
make the plugin fail to load with ERR_MODULE_NOT_FOUND.

Move @opencode-ai/plugin into dependencies and remove the
peer-only declaration. Keep @vectorize-io/hindsight-client as a
runtime dependency, and sync package-lock.json so npm installs the
cache tree needed by OpenCode.

Verified with npm run build, npm pack --json, and a local opencode
plugin install from the generated tarball. The generated cache
contained both runtime dependencies and direct import of the
plugin entrypoint succeeded.
2026-07-07 13:49:47 -04:00
Ben 56db6d7cf6 release(codex): v0.3.1 2026-07-07 13:47:50 -04:00
Ben 0eb52762ae release(claude-code): v0.7.3 2026-07-07 13:47:12 -04:00
ishanmalikandishanmalik e93c560288 feat(integrations): add recall score floors (#2575)
Co-authored-by: ishanmalik <[email protected]>
2026-07-07 13:44:42 -04:00
Evo 1f213d00f7 fix(api): allow clearing memory occurred dates with null (#2607) 2026-07-07 13:42:19 -04:00
Parafee41 8f51f99dde fix(retain): preserve JSON chunks during output retry (#2579) 2026-07-07 11:34:24 -04:00
Sanderhoff-alt 5cc1482a72 fix(memory): return metadata from memory browse endpoints (#2583)
The list and get memory-unit paths selected tags and timing fields
but skipped the memory_units metadata column, so metadata retained
on facts was invisible outside recall.

Select and serialize metadata for live and invalidated memory units,
add a curation regression test for both paths, and update docs plus
OpenAPI examples.
2026-07-07 11:11:04 -04:00
Chris Bartholomew 639d84ad32 fix(parsers): coerce non-bytes buffers before the UTF-8 charset probe in markitdown (#2586)
* fix(parsers): coerce non-bytes buffers before the UTF-8 charset probe

MarkitdownParser._utf8_stream_info() is type-hinted file_data: bytes and calls
file_data.decode('utf-8') to check whether a text file is valid UTF-8 before
handing markitdown an explicit charset hint. But callers may pass a
buffer-protocol object that is not a concrete bytes (a memoryview, or a
native/Rust-backed buffer), which has no .decode — raising
AttributeError: '...' object has no attribute 'decode' and failing every
text-file parse (.txt/.json/.md/.csv/.html/...).

The temp-file write in _convert_sync a few lines up already relies only on the
buffer protocol, so the decode probe was the sole spot assuming concrete bytes.
Coerce via bytes(file_data) before the probe. Adds a regression test with a
buffer-only object (no .decode).

* test(parsers): use memoryview stand-in for the non-bytes buffer case

The previous fixture defined a PEP 688 __buffer__ class, which bytes() only
recognizes on Python 3.12+; on 3.11 the CI shard raised
'TypeError: cannot convert ... object to bytes'. Use a memoryview instead — it
has no .decode and bytes(memoryview) works on every supported version, so the
test stays portable while still exercising the coercion path.
2026-07-07 11:04:22 -04:00
Evoandr266-tech 1c74f795a6 docs(eve): sync assistant reply default (#2588)
Co-authored-by: r266-tech <[email protected]>
2026-07-07 10:34:59 -04:00
Parafee41 b4f9fbe1b5 refresh search vector on memory curation (#2552) 2026-07-07 10:13:17 -04:00
Evoandr266-tech b992ba996d fix(agent-sdk): release recall token fix as 0.1.1 (#2596)
Co-authored-by: r266-tech <[email protected]>
2026-07-07 09:54:00 -04:00
Ben f00d3c7f66 Blog: Eve automatic memory (hindsight-eve v0.2.0) (#2584)
* Blog: Eve automatic memory (hindsight-eve v0.2.0)

New post covering the v0.2.0 rewrite of the Vercel Eve integration: memory
is now automatic (instructions resolver recalls before each turn, hook
retains after) with no model-called memory tool. Supersedes the v0.1 draft
in #2480. Adds cover + three demo screenshots (teach -> observation -> recall).
2026-07-06 14:57:48 -04:00
Ben e97b615547 release(eve): v0.2.1 2026-07-06 14:08:30 -04:00
Ben 29cc1d7fdc feat(eve): retain the assistant reply by default (#2585)
Flip `includeAssistantReply` to default `true` so the auto-retain hook stores
both the user's message and the assistant's reply, not just the user's message.
The assistant's reply is usually where the answer lives (the decision, the
solution, the code), and this matches every other Hindsight integration that
does automatic retain:

- agent-framework (same provider/after_run pattern as eve): include_input +
  include_response both hardcoded true
- opencode: retainMode "full-session" (user + assistant) by default
- claude-code: retainRoles ["user", "assistant"] by default

eve was the only auto-retain integration defaulting to user-only. Set
`includeAssistantReply: false` to keep the old behavior.

Updates JSDoc, README, and repurposes the "user-only by default" tests to
assert the new default (both), with the opt-out (false) still covered.
2026-07-06 14:04:35 -04:00
Ben 016b5f0363 release(eve): v0.2.0 2026-07-03 11:06:47 -04:00
Ben dd7e252452 feat(eve): auto-memory mode (v0.2.0) — no model tool-calling (#2527)
Replace the MCP-connection helper with automatic long-term memory backed by
Hindsight's REST API. Memory no longer depends on the model choosing to call a
tool (which proved unreliable — the model would reach for bash, a subagent, or
just acknowledge a fact without saving it).

Two authored files now give an Eve agent memory that just works:
- agent/instructions/hindsight.ts -> hindsightMemory(): a defineDynamic
  instructions resolver that recalls the user's stored memory and injects it as
  a system message before each turn.
- agent/hooks/hindsight.ts -> hindsightRetainHook(): a defineHook that retains
  the user message + assistant answer after each turn.

Pure core (HindsightRestClient, resolver, turn-pairing, recall formatting) is
split from the eve-importing wrappers and unit-tested with a mocked fetch.
Config via HINDSIGHT_API_KEY / HINDSIGHT_API_URL / HINDSIGHT_BANK_ID. Recall is
profile-based (eve's instruction resolver can't see the live user message).
Feedback-loop guard fences injected context so recalled facts are never
re-retained. Docs + integrations.json updated; bumped to 0.2.0 (breaking).
2026-07-03 11:02:54 -04:00
Ben fda1a77f70 Add architxt + Hindsight community blog post (#2526)
Community-contributed integration post (by Gareth Cooper) on architxt's
Temporal Mosaic: turning fragmented enterprise architecture documents into
a queryable, current-state view backed by Hindsight. Includes 5 product
screenshots + a co-branded cover, and registers the author in authors.yml.
2026-07-03 09:26:01 -04:00
Nicolò Boschi 0accef8e98 test(retain): add missing llm_temperature_retain to _build_request_body mock (#2537)
`_build_request_body` reads `config.llm_temperature_retain` (added by the
per-operation temperature work, #2459), but test_batch_request_body_strict_
follows_config's SimpleNamespace config never set it, so the test raised
`AttributeError: 'SimpleNamespace' object has no attribute
'llm_temperature_retain'`. It only fails on PRs that touch hindsight-api-slim;
main hides it via path-filtering, so it went unnoticed.

Set it to None (temperature omitted) so the test still asserts purely on the
`strict` flag it targets.
2026-07-03 14:05:31 +02:00
Nicolò Boschi c77e2368de feat(control-plane): animate the memories constellation & open memories in a dialog (#2536)
Constellation (memories + entities views):
- Ambient motion so the star map feels alive: slow per-node drift, a size
  pulse and brightness twinkle (each desynchronized by an id-derived phase),
  a calm breathing shimmer across idle links, and twinkling hub halos.
- On hover, a bead of light travels each of the node's links, so connections
  read as live signal paths rather than static lines.
- Re-measure the canvas via ResizeObserver when its container reflows (e.g.
  the Fullscreen toggle / layout changes) — window "resize" alone missed
  container-only changes, so CSS stretched the old bitmap and squeezed text.

Memories (data) view:
- Drop the right-hand control/detail side panel. Clicking a memory node now
  opens the same rich MemoryDetailModal the table/timeline use.
- Move the constellation controls (Color by, Group by scope, Link types) into
  an inline row above the graph, next to the view toggle — giving the star map
  full width.
2026-07-03 11:46:21 +02:00
Nicolò Boschi 38ef0247c2 fix(curation): drop archive search_vector column, recompute on revert (#2503) (#2514)
The curation archive (invalidated_memory_units) is a `LIKE memory_units`
clone with no index. It carried a `search_vector` column purely as a passive
copy in the invalidate/revert row-move — nothing ever reads it (no text-search
index, and recall/list/get/export all exclude it). But its type is fixed at
tsvector by the clone, while `ensure_text_search_extension` reconciles
`memory_units.search_vector` to text/bm25vector on non-native backends
(pgroonga / pg_textsearch / pg_search / vchord). The archive was never
reconciled, so the curation INSERT ... SELECT round-trip failed:

    column "search_vector" is of type tsvector but expression is of type text

This is the exact situation `embedding` was in (#2209): a config-derived,
recall-only column that has no business on the cold archive. Fix it the same
way `embedding` was fixed (d4f6a8c2e1b3):

- Migration e7c3a9f1b2d5 drops search_vector from invalidated_memory_units
  (PG + Oracle), so there is no column left to mismatch.
- The curation move omits search_vector from arch_cols (alongside embedding),
  so invalidate/revert never copy it.
- On revert, search_vector is recomputed from the row's own text/context/
  text_signals using the *current* text-search backend — right next to the
  existing embedding recompute. This is more correct than the old verbatim
  copy, which could restore a stale/wrong-type vector if the backend changed
  while the fact sat archived.

The per-backend search_vector SQL is extracted into pg_search_vector_expr as a
single source of truth shared by insert and revert (also collapses the three
near-identical insert query blocks into one). pgroonga/pg_textsearch/pg_search
index base columns directly and leave search_vector empty, so the expression is
None for them and the column is simply not written.

Tests: extend the curation suite to assert the archive drops search_vector and
that revert repopulates it (native); add fast unit tests for
pg_search_vector_expr and the per-backend insert column shape.
2026-07-03 11:44:13 +02:00
Parafee41 a158b819f3 fix(control-plane): keep memory filters visible on empty results (#2532) 2026-07-03 09:27:41 +02:00
illidanandillidan 381963c28a Fix JSON viewer unicode output display (#2531)
Co-authored-by: illidan <[email protected]>
2026-07-03 09:27:18 +02:00
BenandBen ba158c9cdb Add Devin Desktop blog cover image (#2524)
Co-authored-by: Ben <[email protected]>
2026-07-02 14:45:05 -04:00
Ben 767a2c0061 Blog: Devin Desktop persistent memory (formerly Windsurf) (#2483)
* Add Devin Desktop persistent memory blog post

Integration walkthrough for hindsight-devin-desktop (Devin Desktop, formerly
Windsurf): persistent memory via a remote MCP server plus an always-on
.devin/rules rule. Supersedes the earlier Windsurf post (same product,
renamed by Cognition in June 2026).
2026-07-02 09:47:40 -04:00
Nicolò Boschi 36334f27a1 refactor(control-plane): drop the Graph view from memories (#2517)
* refactor(control-plane): drop the Graph view from memories

Removes the Cytoscape-based "Graph" visualization from the memories views,
leaving Constellation, Table, and Timeline. The Graph view was the only
consumer of cytoscape, cytoscape-fcose, and the slider UI control.

- Delete the Graph2D component (src/components/graph-2d.tsx); move the
  shared graph data model + API-response converter (still used by the
  Constellation and entities views) into src/components/graph-data.ts.
- Remove the "graph" ViewMode, its tab button, render section, and
  graph-only state/effects (showLabels, maxNodes, linkStats) from
  data-view.tsx. The shared /api/graph data source that feeds all views
  is untouched.
- Drop cytoscape, cytoscape-fcose, @types/cytoscape and the now-orphaned
  @radix-ui/react-slider dependency + ui/slider.tsx.
- Remove the dead graph2d i18n namespace and graph-legend dataView keys
  from all locale catalogs (parity + used-keys tests stay green).

* chore: sync docs-skill openapi.json to 0.8.4

Pre-existing drift: the v0.8.4 release did not regenerate the bundled
docs-skill OpenAPI snapshot, leaving verify-generated-files red. Running
generate-docs-skill.sh bumps only the version string (0.8.3 -> 0.8.4).
Unrelated to the graph-view removal but required to make CI green.
2026-07-02 13:47:01 +02:00
Nicolò Boschi 6a479dddb9 fix(codex): implement strict_schema via forced tool call + repair invalid \escape (#2504) (#2513)
strict_schema was a dead no-op in codex_llm: 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 (same class as #1002/#2339).

- strict_schema=True now routes structured output through a single forced
  function tool (constrained decoding into the response schema), mirroring the
  Anthropic forced-tool_use fix (#2339). No prompt-injected schema, no
  json.loads on free-form text.
- The non-strict fallback and tool-argument parsing now repair invalid
  \escape sequences before giving up, stopping the deterministic retry storm
  for the default config.
2026-07-02 12:09:17 +02:00
Nicolò Boschi 7058d1aad7 fix(control-plane): show all mental models instead of capping at 100 (#2512)
* fix(control-plane): load all mental models instead of capping at 100

The mental models view fetched without a limit, so the dataplane applied
its default cap of 100. Any bank with more than 100 mental models silently
hid the rest — the dashboard's pagination and the files view both operate
over the full in-memory list, so nothing past the first 100 was reachable.

Thread limit/offset through the client and proxy route, and page through
the API in loadData() until a short page is returned, accumulating every
mental model for the bank.

* fix(control-plane): use page size of 100 for mental models paging
2026-07-02 12:02:38 +02:00
237 changed files with 5121 additions and 21559 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "hindsight",
"version": "0.7.2",
"version": "0.7.3",
"description": "Official Hindsight integrations for Claude Code",
"owner": {
"name": "vectorize-io"
+5
View File
@@ -115,6 +115,11 @@ HINDSIGHT_API_LOG_LEVEL=info
# chinese_lindera/lindera(chinese), japanese_lindera/lindera(japanese),
# korean_lindera/lindera(korean), ngram(min,max), edge_ngram(min,max)
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER=
# Optional cap on the number of terms in the native PostgreSQL BM25 tsquery.
# Long queries OR-join every normalized token, which can match too much of a
# large bank. 0 (default) keeps the historical uncapped behavior; a positive
# value bounds only the native backend (other BM25 backends get the raw query).
# HINDSIGHT_API_BM25_MAX_QUERY_TERMS=0
# File Parser (Optional - uses markitdown by default)
# HINDSIGHT_API_FILE_PARSER=markitdown
+7 -12
View File
@@ -520,22 +520,17 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Python
uses: actions/setup-python@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
python-version: '3.11'
- name: Install package and pytest
working-directory: ./hindsight-integrations/zed
# Installs the package (incl. the zstandard runtime dep) so the threads.db
# reader tests can decompress Zed's zstd blobs.
run: pip install -e . pytest
node-version: '22'
- name: Run tests
working-directory: ./hindsight-integrations/zed
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: python -m pytest tests/ -v -m "not requires_real_llm"
# Config-only integration with no dependencies — it uses Node's built-in
# test runner. The runtime MCP bridge is `npx mcp-remote` (Node), so this
# integration requires only Node.js (no Python).
run: npm test
test-omo-integration:
needs: [detect-changes]
@@ -56,7 +56,6 @@ BACKUP_TABLES = [
"observation_history",
"mental_models",
"mental_model_history",
"knowledge_pages",
"directives",
"async_operations",
"webhooks",
@@ -1,52 +0,0 @@
"""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)
@@ -1,110 +0,0 @@
"""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)
@@ -1,71 +0,0 @@
"""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)
@@ -0,0 +1,96 @@
"""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)
+25 -482
View File
@@ -18,7 +18,6 @@ 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 (
@@ -53,6 +52,7 @@ from fastapi.routing import APIRoute
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from hindsight_api import MemoryEngine
from hindsight_api.config import RETAIN_EXTRACTION_MODES
def _annotation_is_nullable(annotation: Any) -> bool:
@@ -1246,7 +1246,7 @@ class CreateBankRequest(BaseModel):
)
retain_extraction_mode: str | None = Field(
default=None,
description="Fact extraction mode: 'concise' (default), 'verbose', or 'custom'.",
description="Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'.",
)
retain_custom_instructions: str | None = Field(
default=None,
@@ -1434,6 +1434,7 @@ class ListMemoryUnitsResponse(BaseModel):
"date": "2024-01-15T10:30:00Z",
"type": "world",
"entities": "Alice (PERSON), Google (ORGANIZATION)",
"metadata": {"source": "slack", "channel": "engineering"},
}
],
"total": 150,
@@ -1667,8 +1668,8 @@ class UpdateMemoryRequest(BaseModel):
@model_validator(mode="after")
def _require_an_edit(self) -> "UpdateMemoryRequest":
if all(
v is None
has_value_edit = any(
v is not None
for v in (
self.text,
self.context,
@@ -1678,7 +1679,9 @@ class UpdateMemoryRequest(BaseModel):
self.entities,
self.state,
)
):
)
has_date_clear = bool({"occurred_start", "occurred_end"} & self.model_fields_set)
if not has_value_edit and not has_date_clear:
raise ValueError("Provide at least one field to update.")
if self.state is not None and self.state not in ("valid", "invalidated"):
raise ValueError("state must be 'valid' or 'invalidated'.")
@@ -2111,150 +2114,6 @@ 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."""
@@ -2348,7 +2207,8 @@ class BankTemplateConfig(BaseModel):
reflect_mission: str | None = Field(default=None, description="Mission/context for Reflect operations")
retain_mission: str | None = Field(default=None, description="Steers what gets extracted during retain")
retain_extraction_mode: str | None = Field(
default=None, description="Fact extraction mode: 'concise' (default), 'verbose', or 'custom'"
default=None,
description="Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'",
)
retain_custom_instructions: str | None = Field(
default=None, description="Custom extraction prompt (when mode='custom')"
@@ -2574,10 +2434,10 @@ def validate_bank_template(manifest: "BankTemplateManifest") -> list[str]:
if manifest.bank:
bank = manifest.bank
if bank.retain_extraction_mode is not None:
valid_modes = ("concise", "verbose", "custom", "chunks")
if bank.retain_extraction_mode not in valid_modes:
if bank.retain_extraction_mode not in RETAIN_EXTRACTION_MODES:
errors.append(
f"bank.retain_extraction_mode: must be one of {valid_modes}, got '{bank.retain_extraction_mode}'"
"bank.retain_extraction_mode: "
f"must be one of {RETAIN_EXTRACTION_MODES}, got '{bank.retain_extraction_mode}'"
)
if bank.retain_custom_instructions and bank.retain_extraction_mode != "custom":
errors.append("bank.retain_custom_instructions: requires retain_extraction_mode='custom'")
@@ -3895,13 +3755,23 @@ def _register_routes(app: FastAPI):
):
"""Curate a single memory unit (edit text / invalidate / revert)."""
try:
occurred_start = (
""
if "occurred_start" in request.model_fields_set and request.occurred_start is None
else request.occurred_start
)
occurred_end = (
""
if "occurred_end" in request.model_fields_set and request.occurred_end is None
else request.occurred_end
)
data = await app.state.memory.update_memory_unit(
bank_id=bank_id,
memory_id=memory_id,
text=request.text,
context=request.context,
occurred_start=request.occurred_start,
occurred_end=request.occurred_end,
occurred_start=occurred_start,
occurred_end=occurred_end,
new_fact_type=request.fact_type,
entities=request.entities,
state=request.state,
@@ -4926,333 +4796,6 @@ 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
@@ -1,263 +0,0 @@
"""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)
@@ -638,6 +638,7 @@ ENV_RECALL_BUDGET_MAX = "HINDSIGHT_API_RECALL_BUDGET_MAX"
# Recall candidate gating (per-source cap + BM25 score floor)
ENV_BM25_MIN_SCORE = "HINDSIGHT_API_BM25_MIN_SCORE"
ENV_BM25_MAX_QUERY_TERMS = "HINDSIGHT_API_BM25_MAX_QUERY_TERMS"
ENV_RECALL_MAX_CANDIDATES_PER_SOURCE = "HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE"
# Per-strategy recall boost. Prioritises specific retrieval arms (semantic,
# bm25, graph, temporal) on recall via a human priority level — e.g.
@@ -789,6 +790,9 @@ DEFAULT_SEMANTIC_MIN_SIMILARITY = 0.3
# zero-score (non-matching) rows on backends — notably VectorChord — whose
# operator ranks every document rather than pre-filtering to term matches.
DEFAULT_BM25_MIN_SCORE = 0.0
# Native tsvector BM25 can optionally cap the OR tsquery built from normalized
# query tokens. 0 preserves the historical uncapped behavior.
DEFAULT_BM25_MAX_QUERY_TERMS = 0
# Per-source candidate cap applied to each retrieval arm (semantic, BM25, graph,
# temporal) before RRF, so a single over-expanding backend cannot fill the
# reranker's global candidate budget on its own. 0 disables the cap.
@@ -1209,6 +1213,19 @@ def _parse_positive_int(name: str, raw: str | None, default: int) -> int:
return parsed
def _parse_non_negative_int(name: str, raw: str | None, default: int) -> int:
"""Parse an env var that must be an integer >= 0."""
if raw is None or raw == "":
return default
try:
parsed = int(raw)
except ValueError as e:
raise ValueError(f"{name} must be an integer, got {raw!r}") from e
if parsed < 0:
raise ValueError(f"{name} must be >= 0, got {parsed}")
return parsed
def _parse_optional_positive_int(name: str, raw: str | None) -> int | None:
"""Parse an optional env var that must be a positive integer when set."""
if raw is None or raw == "":
@@ -1979,6 +1996,7 @@ class HindsightConfig:
reflect_llm_strategy: LLMStrategyConfig | None = None
consolidation_llm_members: list[LLMMemberConfig] = field(default_factory=list)
consolidation_llm_strategy: LLMStrategyConfig | None = None
bm25_max_query_terms: int = DEFAULT_BM25_MAX_QUERY_TERMS
# Class-level sets for configuration categorization
@@ -2179,6 +2197,9 @@ class HindsightConfig:
f"Invalid semantic_min_similarity: {self.semantic_min_similarity}. Must be between 0.0 and 1.0"
)
if self.bm25_max_query_terms < 0:
raise ValueError(f"Invalid bm25_max_query_terms: {self.bm25_max_query_terms}. Must be >= 0")
# Validate bedrock_service_tier
valid_bedrock_tiers = (None, "flex", "priority", "reserved")
if self.llm_bedrock_service_tier not in valid_bedrock_tiers:
@@ -2608,6 +2629,11 @@ class HindsightConfig:
reranker_max_candidates=int(os.getenv(ENV_RERANKER_MAX_CANDIDATES, str(DEFAULT_RERANKER_MAX_CANDIDATES))),
semantic_min_similarity=float(os.getenv(ENV_SEMANTIC_MIN_SIMILARITY, str(DEFAULT_SEMANTIC_MIN_SIMILARITY))),
bm25_min_score=float(os.getenv(ENV_BM25_MIN_SCORE, str(DEFAULT_BM25_MIN_SCORE))),
bm25_max_query_terms=_parse_non_negative_int(
ENV_BM25_MAX_QUERY_TERMS,
os.getenv(ENV_BM25_MAX_QUERY_TERMS),
DEFAULT_BM25_MAX_QUERY_TERMS,
),
recall_max_candidates_per_source=int(
os.getenv(ENV_RECALL_MAX_CANDIDATES_PER_SOURCE, str(DEFAULT_RECALL_MAX_CANDIDATES_PER_SOURCE))
),
@@ -102,6 +102,15 @@ class _DedupDecision(BaseModel):
text: str = "" # the synthesized merged observation (when action == "merge")
reason: str = ""
@field_validator("action", mode="before")
@classmethod
def _normalize_action(cls, value: object) -> str:
if isinstance(value, str) and value in {"merge", "keep"}:
return value
logger.warning("Invalid consolidation dedup action %r; defaulting to keep", value)
return "keep"
_DEDUP_PROMPT = """You reconcile long-term memory observations. A NEW observation is about to be \
stored, and it is highly similar to an EXISTING one:
@@ -9,6 +9,33 @@ 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."""
@@ -93,101 +120,39 @@ class PostgreSQLOps(DataAccessOps):
config = get_config()
table = self._get_mu_table()
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
"""
# 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
"""
results = await conn.fetch(
query,
@@ -6570,13 +6570,18 @@ class MemoryEngine(MemoryEngineInterface):
)
collist = await self._memory_unit_columns(conn)
# 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"')
# 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)
# --- Edit fields (live rows only): text / context / dates / fact_type / entities ---
doing_edit = any(
@@ -6634,6 +6639,17 @@ class MemoryEngine(MemoryEngineInterface):
mentioned_at=live["mentioned_at"],
entities=[r["canonical_name"] for r in ent_rows],
)
# Keep the stored text-search vector in sync with curated
# text/context edits. Use the incoming parameters here:
# PostgreSQL evaluates UPDATE RHS expressions before the
# sibling SET assignments take effect, so column references
# would see the pre-edit text/context.
from .db.ops_postgresql import pg_search_vector_expr
sv_expr = pg_search_vector_expr(get_config(), text_col="$3", context_col="$4")
search_vector_clause = (
f",\n search_vector = {sv_expr}" if sv_expr else ""
)
await enqueue_relink_victims(conn, bank_id, [memory_id], ops=backend.ops)
await conn.execute(
f"""
@@ -6641,7 +6657,7 @@ class MemoryEngine(MemoryEngineInterface):
SET text = $3, context = $4, fact_type = $5, occurred_start = $6,
occurred_end = $7, event_date = $8, embedding = $9::vector,
consolidated_at = NULL, consolidation_failed_at = NULL,
edited_at = now(), updated_at = now()
edited_at = now(), updated_at = now(){search_vector_clause}
WHERE id = $1 AND bank_id = $2
""",
str(memory_uuid),
@@ -6695,14 +6711,29 @@ 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 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.
# 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.
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() "
@@ -7446,7 +7477,7 @@ class MemoryEngine(MemoryEngineInterface):
f"""
SELECT id, text, event_date, context, fact_type, document_id,
mentioned_at, occurred_start, occurred_end, chunk_id, proof_count,
tags, consolidated_at, consolidation_failed_at, edited_at, {curation_cols}
tags, metadata, consolidated_at, consolidation_failed_at, edited_at, {curation_cols}
FROM {source_table}
{where_clause}
ORDER BY mentioned_at DESC NULLS LAST, created_at DESC
@@ -7501,6 +7532,7 @@ class MemoryEngine(MemoryEngineInterface):
"chunk_id": row["chunk_id"] if row["chunk_id"] else None,
"proof_count": row["proof_count"] if row["proof_count"] is not None else 1,
"tags": list(row["tags"]) if row["tags"] else [],
"metadata": conn.parse_json(row["metadata"]) if row["metadata"] is not None else {},
"consolidated_at": row["consolidated_at"].isoformat() if row["consolidated_at"] else None,
"consolidation_failed_at": (
row["consolidation_failed_at"].isoformat() if row["consolidation_failed_at"] else None
@@ -7553,7 +7585,7 @@ class MemoryEngine(MemoryEngineInterface):
# back to the archive (with its invalidation bookkeeping) on a miss.
select_cols = (
"id, text, context, event_date, occurred_start, occurred_end, "
"mentioned_at, fact_type, document_id, chunk_id, tags, source_memory_ids, "
"mentioned_at, fact_type, document_id, chunk_id, tags, metadata, source_memory_ids, "
"observation_scopes, edited_at"
)
row = await conn.fetchrow(
@@ -7597,6 +7629,7 @@ class MemoryEngine(MemoryEngineInterface):
"document_id": row["document_id"] if row["document_id"] else None,
"chunk_id": str(row["chunk_id"]) if row["chunk_id"] else None,
"tags": row["tags"] if row["tags"] else [],
"metadata": conn.parse_json(row["metadata"]) if row["metadata"] is not None else {},
"observation_scopes": row["observation_scopes"] if row["observation_scopes"] else None,
"state": unit_state,
"invalidation_reason": row["invalidation_reason"],
@@ -11119,335 +11152,6 @@ 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,
@@ -13176,4 +12880,3 @@ class MemoryEngine(MemoryEngineInterface):
result_metadata={"mental_model_id": mental_model_id, "name": mental_model["name"]},
dedupe_by_bank=False,
)
@@ -186,7 +186,11 @@ class MarkitdownParser(FileParser):
if Path(filename).suffix.lower() not in _TEXT_EXTENSIONS:
return None
try:
file_data.decode("utf-8")
# file_data may arrive as a non-``bytes`` buffer (e.g. a memoryview or
# a native/Rust-backed buffer object) that has no ``.decode``; coerce
# through the buffer protocol before the UTF-8 probe. The ``tmp.write``
# in the caller already relies only on the same buffer protocol.
bytes(file_data).decode("utf-8")
except UnicodeDecodeError:
return None
from markitdown import StreamInfo
@@ -53,6 +53,53 @@ __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):
"""
@@ -336,7 +383,18 @@ class CodexLLM(LLMInterface):
strict_schema: bool = False,
return_usage: bool = False,
) -> Any:
"""Make API call to Codex backend with SSE streaming."""
"""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.
"""
start_time = time.time()
# Proactively refresh the OAuth access_token if it's near expiry.
@@ -361,11 +419,22 @@ class CodexLLM(LLMInterface):
else:
user_messages.append(msg)
# Add JSON schema instruction if response_format is provided
# 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
if response_format is not None and hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
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
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
# gpt-5.2-codex only supports "detailed" reasoning summary
reasoning_summary = "detailed" if "5.2" in self.model else self.reasoning_summary
@@ -392,6 +461,20 @@ 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",
@@ -412,8 +495,15 @@ class CodexLLM(LLMInterface):
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
response.raise_for_status()
# Parse SSE stream
content = await self._parse_sse_stream(response)
# 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)
# Codex SSE carries no usage block; stash the same char/4 estimate
# the success path traces so a later parse/validate failure records
@@ -426,7 +516,28 @@ class CodexLLM(LLMInterface):
)
# Handle structured output
if response_format is not None:
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:
# Models may wrap JSON in markdown
clean_content = content
if "```json" in content:
@@ -437,13 +548,20 @@ class CodexLLM(LLMInterface):
try:
json_data = json.loads(clean_content)
except json.JSONDecodeError as e:
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
# 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
if skip_validation:
result = json_data
@@ -872,8 +990,13 @@ class CodexLLM(LLMInterface):
try:
arguments = json.loads(arguments_str)
except json.JSONDecodeError:
logger.warning(f"Failed to parse tool arguments: {arguments_str}")
arguments = {}
# 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 = {}
tool_calls.append(
LLMToolCall(
@@ -67,23 +67,68 @@ class ProviderResponseError(RuntimeError):
self.retryable = retryable
def _is_json(text: str) -> bool:
"""True if ``text`` parses as a JSON value."""
try:
json.loads(text)
except (json.JSONDecodeError, ValueError):
return False
return True
def _outer_json_span(content: str) -> str | None:
"""Return the outermost ``{...}`` / ``[...]`` span if it parses as JSON, else None.
Fallback for responses where fences are partial/absent or the model wrapped
the JSON in surrounding prose. Only returned when it is valid JSON so callers
never receive a worse candidate than the raw content.
"""
starts = [i for i in (content.find("{"), content.find("[")) if i >= 0]
ends = [i for i in (content.rfind("}"), content.rfind("]")) if i >= 0]
if not starts or not ends:
return None
start, end = min(starts), max(ends)
if end <= start:
return None
candidate = content[start : end + 1].strip()
return candidate if _is_json(candidate) else None
def _strip_code_fences(content: str) -> str:
"""Strip markdown code fences from LLM response if present.
Many LLM providers (MiniMax, some Ollama models, Claude via proxies)
wrap JSON responses in ```json ... ``` fences even when json_object
response format is requested. This strips the fences while preserving
the JSON content inside. Returns the original content unchanged if
no fences are detected.
response format is requested. Fences are detected by line (a closing
``` must sit alone on its line) so triple-backticks *inside* JSON string
values do not truncate the payload. When the stripped candidate is not
valid JSON (partial fence, prose-wrapped output, truncated response), fall
back to the outermost parseable JSON span. Returns the original content
unchanged if no better candidate is found.
"""
if "```" not in content:
return content
try:
if "```json" in content:
return content.split("```json")[1].split("```")[0].strip()
return content.split("```")[1].split("```")[0].strip()
except (IndexError, ValueError):
return content
candidate = content
if "```" in content:
lines = content.split("\n")
# Find first line that starts a code fence (``` optionally followed by language)
fence_start = next((i for i, line in enumerate(lines) if line.startswith("```")), None)
if fence_start is not None:
# Find matching closing fence (``` alone or with trailing whitespace)
fence_end = next(
(j for j in range(fence_start + 1, len(lines)) if lines[j].strip() == "```"),
None,
)
if fence_end is not None:
candidate = "\n".join(lines[fence_start + 1 : fence_end]).strip()
if _is_json(candidate):
return candidate
# Fence stripping did not yield valid JSON — try to recover the outer JSON span.
span = _outer_json_span(content)
if span is not None:
return span
return candidate
# Reasoning/thinking tags emitted by extended-thinking models. Some providers
@@ -644,6 +689,11 @@ class OpenAICompatibleLLM(LLMInterface):
# use the widely-supported max_tokens
return "max_tokens"
def _apply_provider_extra_body_defaults(self, extra_body: dict[str, Any]) -> None:
"""Apply provider-specific extra_body defaults while preserving user overrides."""
if self.provider == "minimax":
extra_body.setdefault("thinking", {"type": "disabled"})
async def call(
self,
messages: list[dict[str, str]],
@@ -731,6 +781,7 @@ class OpenAICompatibleLLM(LLMInterface):
# Provider-specific parameters
extra_body: dict[str, Any] = {**self._config_extra_body}
self._apply_provider_extra_body_defaults(extra_body)
if self.provider == "groq":
call_params["seed"] = DEFAULT_LLM_SEED
# Add service_tier if configured
@@ -1149,6 +1200,7 @@ class OpenAICompatibleLLM(LLMInterface):
# Provider-specific parameters
extra_body: dict[str, Any] = {**self._config_extra_body}
self._apply_provider_extra_body_defaults(extra_body)
if self.provider == "groq":
call_params["seed"] = DEFAULT_LLM_SEED
if extra_body:
@@ -64,8 +64,53 @@ async def delete_chunks_by_ids(conn, chunk_ids: list[str]) -> None:
"""
if not chunk_ids:
return
# PostgreSQL's FK cascade deletes child memory_links in executor-chosen
# order. Concurrent chunk deletes for the same bank can then lock overlapping
# memory_links in opposite orders and deadlock. Delete links explicitly in a
# total order before deleting chunks so every writer takes row locks the same
# way; the FK cascade still handles anything inserted later in this txn.
await conn.execute(
f"DELETE FROM {fq_table('chunks')} WHERE chunk_id = ANY($1::text[])",
f"""
WITH target_units AS MATERIALIZED (
SELECT id
FROM {fq_table("memory_units")}
WHERE chunk_id = ANY($1::text[])
),
ordered_links AS MATERIALIZED (
SELECT ml.ctid
FROM {fq_table("memory_links")} ml
WHERE EXISTS (
SELECT 1
FROM target_units tu
WHERE tu.id = ml.from_unit_id OR tu.id = ml.to_unit_id
)
ORDER BY
LEAST(ml.from_unit_id, ml.to_unit_id),
GREATEST(ml.from_unit_id, ml.to_unit_id),
ml.link_type,
COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid)
FOR UPDATE OF ml
)
DELETE FROM {fq_table("memory_links")} ml
USING ordered_links ol
WHERE ml.ctid = ol.ctid
""",
chunk_ids,
)
await conn.execute(
f"""
WITH ordered_chunks AS MATERIALIZED (
SELECT chunk_id
FROM {fq_table("chunks")}
WHERE chunk_id = ANY($1::text[])
ORDER BY chunk_id
FOR UPDATE
)
DELETE FROM {fq_table("chunks")} c
USING ordered_chunks oc
WHERE c.chunk_id = oc.chunk_id
""",
chunk_ids,
)
@@ -232,6 +232,55 @@ class FactExtractionResponse(BaseModel):
facts: list[ExtractedFact] = Field(description="List of extracted factual statements")
def _split_chunk_for_output_retry(chunk: str) -> tuple[str, str] | None:
"""Split an oversized extraction chunk without corrupting structured input."""
stripped = chunk.strip()
if len(stripped) <= 1:
return None
try:
parsed = json.loads(stripped)
except (TypeError, ValueError, json.JSONDecodeError):
parsed = None
if isinstance(parsed, list):
if len(parsed) >= 2:
mid = len(parsed) // 2
return json.dumps(parsed[:mid]), json.dumps(parsed[mid:])
if len(parsed) == 1 and isinstance(parsed[0], dict):
turn = parsed[0]
content = turn.get("content")
if isinstance(content, str) and len(content) > 1:
cut = len(content) // 2
first_turn = dict(turn)
second_turn = dict(turn)
first_turn["content"] = content[:cut]
second_turn["content"] = content[cut:]
return json.dumps([first_turn]), json.dumps([second_turn])
return None
# Split plain text at the midpoint, preferring sentence boundaries nearby.
mid_point = len(stripped) // 2
search_range = int(len(stripped) * 0.2)
search_start = max(0, mid_point - search_range)
search_end = min(len(stripped), mid_point + search_range)
best_split = mid_point
for ending in [". ", "! ", "? ", "\n\n"]:
pos = stripped.rfind(ending, search_start, search_end)
if pos != -1:
best_split = pos + len(ending)
break
first_half = stripped[:best_split].strip()
second_half = stripped[best_split:].strip()
if not first_half or not second_half or first_half == stripped or second_half == stripped:
return None
return first_half, second_half
class ExtractedFactVerbose(BaseModel):
"""A single extracted fact with verbose field descriptions for detailed extraction."""
@@ -1664,33 +1713,22 @@ async def _extract_facts_with_auto_split(
metadata=metadata,
)
except OutputTooLongError:
# Output exceeded token limits - split the chunk in half and retry
# Output exceeded token limits - split the chunk and retry. Conversation
# chunks are JSON arrays, so preserve array/turn boundaries when possible.
logger.warning(
f"Output too long for chunk {chunk_index + 1}/{total_chunks} "
f"({len(chunk)} chars). Splitting in half and retrying..."
f"({len(chunk)} chars). Splitting and retrying..."
)
# Split at the midpoint, preferring sentence boundaries
mid_point = len(chunk) // 2
split_chunks = _split_chunk_for_output_retry(chunk)
if split_chunks is None:
logger.warning(
f"Cannot make progress splitting chunk {chunk_index + 1}/{total_chunks} "
f"({len(chunk)} chars); dropping this sub-chunk."
)
return [], TokenUsage()
# Try to find a sentence boundary near the midpoint
# Look for ". ", "! ", "? " within 20% of midpoint
search_range = int(len(chunk) * 0.2)
search_start = max(0, mid_point - search_range)
search_end = min(len(chunk), mid_point + search_range)
sentence_endings = [". ", "! ", "? ", "\n\n"]
best_split = mid_point
for ending in sentence_endings:
pos = chunk.rfind(ending, search_start, search_end)
if pos != -1:
best_split = pos + len(ending)
break
# Split the chunk
first_half = chunk[:best_split].strip()
second_half = chunk[best_split:].strip()
first_half, second_half = split_chunks
logger.info(
f"Split chunk {chunk_index + 1} into two sub-chunks: {len(first_half)} chars and {len(second_half)} chars"
@@ -15,7 +15,7 @@ from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Optional
from ...config import get_config
from ...config import DEFAULT_BM25_MAX_QUERY_TERMS, get_config
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from ..sql import create_sql_dialect
@@ -222,7 +222,12 @@ async def retrieve_semantic_bm25_combined(
# --- BM25 UNION ALL arms (one per fact_type, only when tokens present) ---
if _include_bm25:
text_ext = config.text_search_extension
bm25_text_param: str = dialect.prepare_bm25_text(tokens, query_text, text_search_extension=text_ext)
bm25_text_param: str = dialect.prepare_bm25_text(
tokens,
query_text,
text_search_extension=text_ext,
max_query_terms=getattr(config, "bm25_max_query_terms", DEFAULT_BM25_MAX_QUERY_TERMS),
)
for i, ft in enumerate(fact_types):
arms.append(
dialect.build_bm25_arm(
@@ -449,6 +449,7 @@ class SQLDialect(ABC):
query_text: str,
*,
text_search_extension: str = "native",
max_query_terms: int | None = None,
) -> str:
"""Prepare the text parameter value for BM25 search.
@@ -459,6 +460,8 @@ class SQLDialect(ABC):
tokens: Tokenized query words.
query_text: Original query text.
text_search_extension: Full-text search backend variant.
max_query_terms: Optional backend-specific token cap. 0 or None
leaves query terms uncapped.
Returns:
Prepared text string to bind as the BM25 text parameter.
@@ -303,6 +303,7 @@ class OracleDialect(SQLDialect):
query_text: str,
*,
text_search_extension: str = "native",
max_query_terms: int | None = None,
) -> str:
# Oracle Text: filter tokens with special chars, escape reserved words
# with curly braces (e.g. "about" → "{about}"), and join with OR.
@@ -254,8 +254,11 @@ class PostgreSQLDialect(SQLDialect):
query_text: str,
*,
text_search_extension: str = "native",
max_query_terms: int | None = None,
) -> str:
if text_search_extension in ("vchord", "pg_textsearch", "pgroonga", "pg_search"):
return query_text
if max_query_terms is not None and max_query_terms > 0:
tokens = tokens[:max_query_terms]
# native tsvector: join tokens with OR operator
return " | ".join(tokens)
@@ -5,6 +5,7 @@ import pytest_asyncio
import httpx
from datetime import datetime
from hindsight_api.api import create_app
from hindsight_api.api.http import BankTemplateManifest, validate_bank_template
@pytest_asyncio.fixture
@@ -81,6 +82,17 @@ class TestImportValidation:
assert set(data["mental_models_created"]) == {"test-model-one", "test-model-two"}
assert set(data["directives_created"]) == {"Be concise", "Use examples"}
def test_verbatim_extraction_mode_is_valid(self):
"""verbatim is a valid retain extraction mode in bank manifests."""
manifest = BankTemplateManifest.model_validate(
{
"version": "1",
"bank": {"retain_extraction_mode": "verbatim"},
}
)
assert validate_bank_template(manifest) == []
@pytest.mark.asyncio
async def test_import_invalid_version(self, api_client, bank_id):
"""Reject manifest with unsupported version."""
@@ -0,0 +1,50 @@
"""Regression coverage for deterministic chunk deletion ordering."""
import pytest
from hindsight_api.engine.retain import chunk_storage
class RecordingConn:
def __init__(self) -> None:
self.calls: list[tuple[str, tuple[object, ...]]] = []
async def execute(self, sql: str, *args: object) -> None:
self.calls.append((sql, args))
@pytest.mark.asyncio
async def test_delete_chunks_by_ids_predeletes_links_before_chunks():
conn = RecordingConn()
chunk_ids = ["chunk-b", "chunk-a"]
await chunk_storage.delete_chunks_by_ids(conn, chunk_ids)
assert len(conn.calls) == 2
link_sql, link_args = conn.calls[0]
chunk_sql, chunk_args = conn.calls[1]
assert link_args == (chunk_ids,)
assert chunk_args == (chunk_ids,)
assert "DELETE FROM" in link_sql
assert "memory_links" in link_sql
assert "target_units AS MATERIALIZED" in link_sql
assert "ordered_links AS MATERIALIZED" in link_sql
assert "ORDER BY" in link_sql
assert "FOR UPDATE OF ml" in link_sql
assert "DELETE FROM" in chunk_sql
assert "chunks" in chunk_sql
assert "ordered_chunks AS MATERIALIZED" in chunk_sql
assert "ORDER BY chunk_id" in chunk_sql
assert "FOR UPDATE" in chunk_sql
@pytest.mark.asyncio
async def test_delete_chunks_by_ids_noops_without_chunks():
conn = RecordingConn()
await chunk_storage.delete_chunks_by_ids(conn, [])
assert conn.calls == []
@@ -0,0 +1,185 @@
"""
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"
@@ -5,6 +5,7 @@ guard the fix in CI — unlike the real-LLM integration test, which only trigger
the path stochastically.
"""
import logging
import types
import uuid
from dataclasses import dataclass
@@ -142,6 +143,38 @@ async def test_dedup_llm_missing_action_defaults_to_keep() -> None:
conn.execute.assert_not_called() # missing action is a conservative no-merge
def test_dedup_decision_accepts_exact_valid_actions() -> None:
assert _DedupDecision(action="merge").action == "merge"
assert _DedupDecision(action="keep").action == "keep"
def test_dedup_decision_invalid_action_defaults_to_keep(caplog) -> None:
with caplog.at_level(logging.WARNING):
decision = _DedupDecision(action="need_input", reason="model asked for more context")
assert decision.action == "keep"
assert "need_input" in caplog.text
assert "defaulting to keep" in caplog.text
def test_dedup_decision_near_miss_merge_defaults_to_keep(caplog) -> None:
with caplog.at_level(logging.WARNING):
decision = _DedupDecision(action="Merge")
assert decision.action == "keep"
assert "Merge" in caplog.text
def test_dedup_decision_non_scalar_action_defaults_to_keep(caplog) -> None:
with caplog.at_level(logging.WARNING):
list_decision = _DedupDecision(action=[])
dict_decision = _DedupDecision(action={"value": "merge"})
assert list_decision.action == "keep"
assert dict_decision.action == "keep"
assert "defaulting to keep" in caplog.text
async def test_dedup_llm_merge_folds_into_twin() -> None:
kwargs, conn, llm = _ctx()
kwargs["create_source_ids"] = [uuid.uuid4(), uuid.uuid4()]
@@ -78,6 +78,39 @@ async def test_patch_invalidate_and_revert_over_http(api_client, memory):
await memory.delete_bank(bank_id, request_context=RequestContext())
@pytest.mark.asyncio
async def test_patch_clears_occurred_dates_with_explicit_null(api_client, memory):
bank_id = f"curation-http-clear-dates-{uuid.uuid4().hex[:8]}"
mem_id = await _insert_fact(memory, bank_id, "Release v1.2 happened on Monday.")
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE memory_units
SET occurred_start = '2024-01-15T10:30:00Z',
occurred_end = '2024-01-15T11:00:00Z'
WHERE id = $1
""",
uuid.UUID(mem_id),
)
resp = await api_client.patch(
f"/v1/default/banks/{bank_id}/memories/{mem_id}",
json={"occurred_start": None, "occurred_end": None},
)
assert resp.status_code == 200, resp.text
assert resp.json()["occurred_start"] is None
assert resp.json()["occurred_end"] is None
resp = await api_client.get(f"/v1/default/banks/{bank_id}/memories/{mem_id}")
assert resp.status_code == 200
assert resp.json()["occurred_start"] is None
assert resp.json()["occurred_end"] is None
await memory.delete_bank(bank_id, request_context=RequestContext())
@pytest.mark.asyncio
async def test_patch_not_found_returns_404(api_client, memory):
bank_id = f"curation-http-404-{uuid.uuid4().hex[:8]}"
@@ -4,8 +4,7 @@ Unit tests that verify the abstraction interfaces work correctly
without requiring a live database connection.
"""
import json
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, patch
import pytest
@@ -748,6 +747,85 @@ 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
# ---------------------------------------------------------------------------
@@ -9,12 +9,87 @@ BaseException'), which happened when last_error was only set in the
BadRequestError handler and not for non-dict JSON responses.
"""
import json
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def test_output_retry_split_preserves_conversation_array_boundaries():
"""OutputTooLong retry splitting must keep conversation chunks valid JSON arrays."""
from hindsight_api.engine.retain.fact_extraction import _split_chunk_for_output_retry
turns = [
{"role": "user", "content": "alpha"},
{"role": "assistant", "content": "bravo"},
{"role": "user", "content": "charlie"},
{"role": "assistant", "content": "delta"},
]
split = _split_chunk_for_output_retry(json.dumps(turns))
assert split is not None
first, second = split
assert json.loads(first) == turns[:2]
assert json.loads(second) == turns[2:]
def test_output_retry_split_divides_single_oversized_turn_content():
"""A lone oversized conversation turn is split inside content and rewrapped."""
from hindsight_api.engine.retain.fact_extraction import _split_chunk_for_output_retry
turn = {"role": "user", "content": "abcdefghijklmnopqrstuvwxyz", "name": "casey"}
split = _split_chunk_for_output_retry(json.dumps([turn]))
assert split is not None
first, second = split
first_turn = json.loads(first)[0]
second_turn = json.loads(second)[0]
assert first_turn["role"] == "user"
assert second_turn["role"] == "user"
assert first_turn["name"] == "casey"
assert second_turn["name"] == "casey"
assert first_turn["content"] + second_turn["content"] == turn["content"]
def test_output_retry_split_returns_none_when_no_progress_possible():
"""Pathological tiny chunks should be dropped instead of recursively retried."""
from hindsight_api.engine.retain.fact_extraction import _split_chunk_for_output_retry
assert _split_chunk_for_output_retry("x") is None
assert _split_chunk_for_output_retry(json.dumps([{"role": "user", "content": ""}])) is None
@pytest.mark.asyncio
async def test_output_too_long_drops_unsplittable_subchunk_without_recursing():
"""If a chunk cannot be reduced further, auto-split exits gracefully."""
from hindsight_api.engine.llm_wrapper import OutputTooLongError
from hindsight_api.engine.retain.fact_extraction import _extract_facts_with_auto_split
config = _make_config(llm_max_retries=1)
llm_config = _make_llm_config(mock_response={})
with patch(
"hindsight_api.engine.retain.fact_extraction._extract_facts_from_chunk",
side_effect=OutputTooLongError("too long"),
) as extract:
facts, usage = await _extract_facts_with_auto_split(
chunk="x",
chunk_index=0,
total_chunks=1,
event_date=datetime(2023, 1, 1, tzinfo=timezone.utc),
context="",
llm_config=llm_config,
config=config,
agent_name="agent",
)
assert facts == []
assert extract.call_count == 1
def _make_config(llm_max_retries: int = 3, retain_llm_max_retries: int | None = None):
"""Build a minimal HindsightConfig for fact extraction tests."""
from hindsight_api.config import HindsightConfig
@@ -1,241 +0,0 @@
"""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)
config = SimpleNamespace(retain_max_completion_tokens=None, llm_strict_schema=strict, llm_temperature_retain=None)
# provider != "openai" service-tier branch skipped via _provider_impl without attr
llm_config._provider_impl.openai_service_tier = None
@@ -63,3 +63,23 @@ def test_utf8_stream_info_skips_non_utf8_text():
latin1 = "café".encode("latin-1") # 0xe9, invalid as standalone UTF-8
assert MarkitdownParser._utf8_stream_info(latin1, "a.txt") is None
def test_utf8_stream_info_accepts_non_bytes_buffer():
"""file_data may arrive as a buffer-protocol object that is not a Python
``bytes`` (e.g. a memoryview or a native/Rust-backed buffer) and therefore
has no ``.decode``. The UTF-8 probe must coerce via ``bytes()`` instead of
assuming concrete ``bytes``, else every text file fails to parse with
``'...' object has no attribute 'decode'``.
"""
# memoryview is a buffer-protocol object with no ``.decode`` and, unlike a
# PEP 688 ``__buffer__`` class, ``bytes(memoryview)`` works on every
# supported Python version — a portable stand-in for the native buffer the
# storage layer returns.
buf = memoryview("über".encode("utf-8"))
assert not hasattr(buf, "decode") # precondition: would hit the original AttributeError
info = MarkitdownParser._utf8_stream_info(buf, "a.txt")
assert info is not None
assert info.charset == "utf-8"
@@ -6,6 +6,7 @@ These tests cover the move semantics, lossless revert (incl. entity
associations), edit, the guards, listing, and recall exclusion.
"""
import json
import uuid
from unittest.mock import AsyncMock, patch
@@ -21,21 +22,29 @@ from hindsight_api.engine.retain import embedding_processing
async def _insert_memory(
conn, memory: MemoryEngine, bank_id: str, text: str, fact_type: str = "experience"
conn,
memory: MemoryEngine,
bank_id: str,
text: str,
fact_type: str = "experience",
metadata: dict | None = None,
) -> uuid.UUID:
"""Insert a live memory unit with a real embedding, bypassing the LLM pipeline."""
mem_id = uuid.uuid4()
emb = await embedding_processing.generate_embeddings_batch(memory.embeddings, [text])
await conn.execute(
"""
INSERT INTO memory_units (id, bank_id, text, fact_type, embedding, event_date, created_at, updated_at, consolidated_at)
VALUES ($1, $2, $3, $4, $5::vector, NOW(), NOW(), NOW(), NOW())
INSERT INTO memory_units (
id, bank_id, text, fact_type, embedding, event_date, metadata, created_at, updated_at, consolidated_at
)
VALUES ($1, $2, $3, $4, $5::vector, NOW(), $6::jsonb, NOW(), NOW(), NOW())
""",
mem_id,
bank_id,
text,
fact_type,
str(emb[0]),
json.dumps(metadata or {}),
)
return mem_id
@@ -101,11 +110,12 @@ async def _archive_row(conn, mem_id: uuid.UUID) -> dict | None:
return dict(row) if row else None
async def _archive_has_embedding_column(conn) -> bool:
async def _archive_has_column(conn, column: str) -> bool:
return bool(
await conn.fetchval(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = 'invalidated_memory_units' AND column_name = 'embedding'"
"WHERE table_name = 'invalidated_memory_units' AND column_name = $1",
column,
)
)
@@ -174,9 +184,12 @@ 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_embedding_column(conn), (
assert not await _archive_has_column(conn, "embedding"), (
"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"
@@ -216,6 +229,10 @@ 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)
@@ -261,6 +278,10 @@ class TestEdit:
pool = await memory._get_pool()
async with pool.acquire() as conn:
m1 = await _insert_memory(conn, memory, bank_id, "The assistant visited Paris in 2023.")
await conn.execute(
"UPDATE memory_units SET search_vector = to_tsvector('english'::regconfig, text) WHERE id = $1",
m1,
)
obs_id = await _insert_observation(conn, bank_id, "The assistant went to Paris.", [m1])
with (
@@ -279,9 +300,17 @@ class TestEdit:
assert result["state"] == "valid"
async with pool.acquire() as conn:
assert await _in_live(conn, m1), "edited row stays live"
row = dict(await conn.fetchrow("SELECT text, consolidated_at FROM memory_units WHERE id = $1", m1))
row = dict(
await conn.fetchrow(
"SELECT text, consolidated_at, search_vector::text AS search_vector "
"FROM memory_units WHERE id = $1",
m1,
)
)
assert row["text"] == "The user visited Paris in 2023."
assert row["consolidated_at"] is None, "edited memory re-consolidates"
assert "'assist'" not in row["search_vector"], "old text must not stay in native FTS search_vector"
assert "'user'" in row["search_vector"], "new text must refresh native FTS search_vector"
assert str(obs_id) not in await _obs_ids(conn, bank_id), "stale observation re-derived"
await memory.delete_bank(bank_id, request_context=request_context)
@@ -472,6 +501,47 @@ class TestGuardsAndListing:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_list_and_get_memory_units_include_metadata(
self, memory: MemoryEngine, request_context: RequestContext
):
bank_id = f"test-curation-metadata-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
metadata = {"source": "slack", "channel": "engineering", "thread_id": "T123"}
pool = await memory._get_pool()
async with pool.acquire() as conn:
mem_id = await _insert_memory(conn, memory, bank_id, "Fact with metadata.", metadata=metadata)
live = (await memory.list_memory_units(bank_id, request_context=request_context))["items"]
live_item = next(item for item in live if item["id"] == str(mem_id))
assert live_item["metadata"] == metadata
detail = await memory.get_memory_unit(bank_id, str(mem_id), request_context=request_context)
assert detail is not None
assert detail["metadata"] == metadata
with (
patch.object(memory, "submit_async_consolidation", new=AsyncMock()),
patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()),
):
await memory.update_memory_unit(
bank_id, str(mem_id), state="invalidated", reason="stale", request_context=request_context
)
invalid = (await memory.list_memory_units(bank_id, state="invalidated", request_context=request_context))[
"items"
]
assert invalid[0]["id"] == str(mem_id)
assert invalid[0]["metadata"] == metadata
invalid_detail = await memory.get_memory_unit(bank_id, str(mem_id), request_context=request_context)
assert invalid_detail is not None
assert invalid_detail["state"] == "invalidated"
assert invalid_detail["metadata"] == metadata
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_list_filters_by_document(self, memory: MemoryEngine, request_context: RequestContext):
bank_id = f"test-curation-doc-{uuid.uuid4().hex[:8]}"
@@ -0,0 +1,99 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
def _make_minimax(extra_body=None) -> OpenAICompatibleLLM:
return OpenAICompatibleLLM(
provider="minimax",
api_key="test-key",
base_url="",
model="MiniMax-M3",
extra_body=extra_body,
)
def _text_response(content: str = "ok"):
return SimpleNamespace(
error=None,
usage=None,
choices=[
SimpleNamespace(
finish_reason="stop",
message=SimpleNamespace(content=content, tool_calls=None, refusal=None),
)
],
)
def _tool_response():
tool_call = SimpleNamespace(
id="call_minimax_123",
function=SimpleNamespace(name="recall", arguments='{"query": "Project Rin"}'),
)
return SimpleNamespace(
error=None,
usage=None,
choices=[
SimpleNamespace(
finish_reason="tool_calls",
message=SimpleNamespace(content=None, tool_calls=[tool_call], refusal=None),
)
],
)
@pytest.mark.asyncio
async def test_minimax_call_disables_thinking_by_default():
llm = _make_minimax()
llm._client.chat.completions.create = AsyncMock(return_value=_text_response())
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
await llm.call(messages=[{"role": "user", "content": "hi"}], max_retries=0)
assert llm._client.chat.completions.create.call_args.kwargs["extra_body"] == {"thinking": {"type": "disabled"}}
@pytest.mark.asyncio
async def test_minimax_call_preserves_configured_thinking_extra_body():
llm = _make_minimax(extra_body={"thinking": {"type": "enabled"}, "reasoning_split": True})
llm._client.chat.completions.create = AsyncMock(return_value=_text_response())
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
await llm.call(messages=[{"role": "user", "content": "hi"}], max_retries=0)
assert llm._client.chat.completions.create.call_args.kwargs["extra_body"] == {
"thinking": {"type": "enabled"},
"reasoning_split": True,
}
@pytest.mark.asyncio
async def test_minimax_tool_call_disables_thinking_by_default():
llm = _make_minimax()
llm._client.chat.completions.create = AsyncMock(return_value=_tool_response())
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
await llm.call_with_tools(
messages=[{"role": "user", "content": "Search memory."}],
tools=[
{
"type": "function",
"function": {
"name": "recall",
"description": "Recall memories",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
}
],
max_retries=0,
)
assert llm._client.chat.completions.create.call_args.kwargs["extra_body"] == {"thinking": {"type": "disabled"}}
@@ -10,12 +10,18 @@ Covers:
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from hindsight_api.engine.consolidation.prompts import build_batch_consolidation_prompt
from hindsight_api.engine.prompt_utils import output_language_directive
from hindsight_api.engine.reflect.prompts import build_final_system_prompt
from hindsight_api.engine.retain.fact_extraction import _build_extraction_prompt_and_schema
from hindsight_api.engine.search import retrieval as retrieval_mod
from hindsight_api.engine.search.retrieval import tokenize_query
from hindsight_api.engine.sql.postgresql import PostgreSQLDialect
def _baseline_config() -> MagicMock:
@@ -165,3 +171,75 @@ def test_configurable_bm25_language_migration_chains_off_head():
src = target.read_text()
assert 'revision: str = "p4q5r6s7t8u9"' in src
assert 'down_revision: str | Sequence[str] | None = "86f7a033d372"' in src
# ---------------------------------------------------------------------------
# BM25 query term cap
# ---------------------------------------------------------------------------
def test_postgresql_native_bm25_caps_raw_terms_preserving_order():
query = "Alpha beta alpha, gamma delta beta epsilon"
tokens = tokenize_query(query)
assert PostgreSQLDialect().prepare_bm25_text(tokens, query, max_query_terms=3) == "alpha | beta | alpha"
def test_postgresql_native_bm25_zero_cap_keeps_existing_unlimited_behavior():
query = "Alpha beta alpha"
tokens = tokenize_query(query)
assert PostgreSQLDialect().prepare_bm25_text(tokens, query, max_query_terms=0) == "alpha | beta | alpha"
def test_postgresql_extension_bm25_keeps_raw_query_text():
query = "Alpha beta alpha, gamma delta beta epsilon"
tokens = tokenize_query(query)
assert (
PostgreSQLDialect().prepare_bm25_text(tokens, query, text_search_extension="vchord", max_query_terms=3) == query
)
@pytest.mark.asyncio
async def test_combined_retrieval_uses_default_bm25_cap_for_legacy_config(monkeypatch):
class FakeDialect:
max_query_terms: int | None = None
def build_semantic_arm(self, **kwargs):
return "SELECT 'semantic' AS source"
def build_bm25_arm(self, **kwargs):
return "SELECT 'bm25' AS source"
def prepare_bm25_text(self, tokens, query_text, *, text_search_extension="native", max_query_terms=None):
self.max_query_terms = max_query_terms
return " | ".join(tokens)
class FakeConn:
backend_type = "postgresql"
async def fetch(self, query, *params):
return []
fake_dialect = FakeDialect()
legacy_config = SimpleNamespace(
semantic_min_similarity=0.0,
bm25_min_score=0.0,
text_search_extension="native",
text_search_extension_native_language="english",
)
monkeypatch.setattr(retrieval_mod, "get_config", lambda: legacy_config)
monkeypatch.setattr(retrieval_mod, "create_sql_dialect", lambda backend: fake_dialect)
result = await retrieval_mod.retrieve_semantic_bm25_combined(
FakeConn(),
"[0.0]",
"alpha beta",
"bank-1",
["observation"],
5,
)
assert result == {"observation": ([], [])}
assert fake_dialect.max_query_terms == 0
-166
View File
@@ -1,166 +0,0 @@
"""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(), [])
+16 -1
View File
@@ -65,12 +65,14 @@ class TestRecallConfigFields:
"""Hierarchical config fields for internal recall."""
def test_fields_exist_on_dataclass(self):
from hindsight_api.config import HindsightConfig
from hindsight_api.config import DEFAULT_BM25_MAX_QUERY_TERMS, HindsightConfig
names = {f.name for f in dataclasses.fields(HindsightConfig)}
assert "recall_include_chunks" in names
assert "recall_max_tokens" in names
assert "recall_chunks_max_tokens" in names
assert "bm25_max_query_terms" in names
assert HindsightConfig.__dataclass_fields__["bm25_max_query_terms"].default == DEFAULT_BM25_MAX_QUERY_TERMS
def test_fields_are_configurable(self):
from hindsight_api.config import HindsightConfig
@@ -82,6 +84,7 @@ class TestRecallConfigFields:
def test_default_values(self):
from hindsight_api.config import (
DEFAULT_BM25_MAX_QUERY_TERMS,
DEFAULT_RECALL_CHUNKS_MAX_TOKENS,
DEFAULT_RECALL_INCLUDE_CHUNKS,
DEFAULT_RECALL_MAX_TOKENS,
@@ -90,9 +93,11 @@ class TestRecallConfigFields:
assert DEFAULT_RECALL_INCLUDE_CHUNKS is True
assert DEFAULT_RECALL_MAX_TOKENS == 2048
assert DEFAULT_RECALL_CHUNKS_MAX_TOKENS == 1000
assert DEFAULT_BM25_MAX_QUERY_TERMS == 0
def test_env_var_constants(self):
from hindsight_api.config import (
ENV_BM25_MAX_QUERY_TERMS,
ENV_RECALL_CHUNKS_MAX_TOKENS,
ENV_RECALL_INCLUDE_CHUNKS,
ENV_RECALL_MAX_TOKENS,
@@ -101,6 +106,7 @@ class TestRecallConfigFields:
assert ENV_RECALL_INCLUDE_CHUNKS == "HINDSIGHT_API_RECALL_INCLUDE_CHUNKS"
assert ENV_RECALL_MAX_TOKENS == "HINDSIGHT_API_RECALL_MAX_TOKENS"
assert ENV_RECALL_CHUNKS_MAX_TOKENS == "HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS"
assert ENV_BM25_MAX_QUERY_TERMS == "HINDSIGHT_API_BM25_MAX_QUERY_TERMS"
@patch.dict(
"os.environ",
@@ -108,6 +114,7 @@ class TestRecallConfigFields:
"HINDSIGHT_API_RECALL_INCLUDE_CHUNKS": "false",
"HINDSIGHT_API_RECALL_MAX_TOKENS": "777",
"HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS": "333",
"HINDSIGHT_API_BM25_MAX_QUERY_TERMS": "24",
},
)
def test_from_env_reads_overrides(self):
@@ -117,6 +124,14 @@ class TestRecallConfigFields:
assert config.recall_include_chunks is False
assert config.recall_max_tokens == 777
assert config.recall_chunks_max_tokens == 333
assert config.bm25_max_query_terms == 24
@patch.dict("os.environ", {"HINDSIGHT_API_BM25_MAX_QUERY_TERMS": "-1"})
def test_from_env_rejects_negative_bm25_max_query_terms(self):
from hindsight_api.config import HindsightConfig
with pytest.raises(ValueError, match="HINDSIGHT_API_BM25_MAX_QUERY_TERMS must be >= 0"):
HindsightConfig.from_env()
class TestMentalModelTriggerRecallFields:
@@ -1,6 +1,6 @@
"""Tests for _strip_code_fences helper in OpenAI-compatible LLM provider."""
import pytest
import json
from hindsight_api.engine.providers.openai_compatible_llm import _strip_code_fences
@@ -36,6 +36,22 @@ class TestStripCodeFences:
result = _strip_code_fences(content)
assert '{"facts": []}' in result
def test_inner_backticks_preserved(self):
"""Inner triple-backticks inside a JSON string value must not truncate the JSON.
Regression for the fact-extraction case where an extracted fact describes
code-fence behavior, so the JSON payload itself contains a literal
```` ```json ```` — the old split-based stripper matched that inner
occurrence and cut the JSON mid-string.
"""
import json
content = '```json\n{"facts": [{"what": "the model wraps output in ```json fences"}]}\n```'
result = _strip_code_fences(content)
assert result == '{"facts": [{"what": "the model wraps output in ```json fences"}]}'
parsed = json.loads(result)
assert parsed["facts"][0]["what"] == "the model wraps output in ```json fences"
def test_no_fences_no_change(self):
"""Content without any backticks passes through."""
content = "Just some text without fences"
@@ -53,12 +69,25 @@ class TestStripCodeFences:
assert '"line2"' in result
assert "```" not in result
def test_malformed_fence_returns_original(self):
"""Malformed fences (missing closing) return something parseable."""
def test_missing_closing_fence_recovers_json(self):
"""A fence with no closing ``` still recovers the JSON via the outer-span fallback."""
content = '```json\n{"facts": []}'
result = _strip_code_fences(content)
# Should attempt to strip and return best effort
assert json.loads(result) == {"facts": []}
def test_prose_wrapped_json_recovered(self):
"""JSON surrounded by prose (no usable fence) is recovered by the fallback."""
content = 'Sure! Here is the result:\n{"facts": [{"what": "x"}]}\nLet me know if that helps.'
result = _strip_code_fences(content)
assert json.loads(result) == {"facts": [{"what": "x"}]}
def test_non_json_fence_left_for_retry(self):
"""A fenced block that is not JSON yields no valid candidate; content is returned unchanged."""
content = "```\nnot json at all\n```"
result = _strip_code_fences(content)
# No parseable JSON anywhere -> caller sees the stripped body (still a str), never crashes.
assert isinstance(result, str)
assert "not json at all" in result
def test_minimax_style_response(self):
"""Real-world MiniMax response format."""
+46 -9
View File
@@ -10,6 +10,26 @@ use serde::{Deserialize, Serialize};
use serde_json;
use std::collections::HashMap;
const DEFAULT_CLI_USER_AGENT: &str = concat!("hindsight-cli/", env!("CARGO_PKG_VERSION"));
fn default_headers(api_key: Option<&str>) -> Result<reqwest::header::HeaderMap> {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static(DEFAULT_CLI_USER_AGENT),
);
if let Some(key) = api_key {
let auth_value = format!("Bearer {}", key);
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&auth_value)?,
);
}
Ok(headers)
}
/// Convert a progenitor client error into an anyhow error that includes the
/// HTTP response body. Without this, errors render as
/// "Unexpected Response: Response { ... }" with no body, hiding validation
@@ -112,15 +132,7 @@ impl ApiClient {
let mut client_builder =
reqwest::Client::builder().timeout(std::time::Duration::from_secs(120));
if let Some(key) = api_key {
let mut headers = reqwest::header::HeaderMap::new();
let auth_value = format!("Bearer {}", key);
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&auth_value)?,
);
client_builder = client_builder.default_headers(headers);
}
client_builder = client_builder.default_headers(default_headers(api_key.as_deref())?);
let http_client = client_builder.build()?;
@@ -1309,6 +1321,31 @@ pub use types::{
mod tests {
use super::*;
#[test]
fn default_headers_set_cli_user_agent_without_api_key() {
let headers = default_headers(None).unwrap();
assert_eq!(
headers.get(reqwest::header::USER_AGENT).unwrap(),
DEFAULT_CLI_USER_AGENT,
);
assert!(!headers.contains_key(reqwest::header::AUTHORIZATION));
}
#[test]
fn default_headers_keep_authorization_with_cli_user_agent() {
let headers = default_headers(Some("hsk_test")).unwrap();
assert_eq!(
headers.get(reqwest::header::USER_AGENT).unwrap(),
DEFAULT_CLI_USER_AGENT,
);
assert_eq!(
headers.get(reqwest::header::AUTHORIZATION).unwrap(),
"Bearer hsk_test",
);
}
#[test]
fn test_operation_deserialize() {
let json = r#"{
+3 -692
View File
@@ -1382,348 +1382,6 @@ 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.
@@ -5437,42 +5095,6 @@ 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:
@@ -5531,64 +5153,6 @@ 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:
@@ -6392,226 +5956,6 @@ 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:
@@ -6995,6 +6339,9 @@ components:
date: 2024-01-15T10:30:00Z
entities: "Alice (PERSON), Google (ORGANIZATION)"
id: 550e8400-e29b-41d4-a716-446655440000
metadata:
channel: engineering
source: slack
text: Alice works at Google on the AI team
type: world
limit: 100
@@ -7397,29 +6744,6 @@ 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
@@ -8793,19 +8117,6 @@ 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,8 +65,6 @@ type APIClient struct {
FilesAPI *FilesAPIService
KnowledgeBaseAPI *KnowledgeBaseAPIService
LLMTracesAPI *LLMTracesAPIService
MemoryAPI *MemoryAPIService
@@ -104,7 +102,6 @@ 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)
@@ -1,204 +0,0 @@
/*
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)
}
@@ -1,232 +0,0 @@
/*
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)
}
@@ -1,361 +0,0 @@
/*
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)
}
@@ -1,511 +0,0 @@
/*
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)
}
@@ -1,186 +0,0 @@
/*
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)
}
@@ -1,158 +0,0 @@
/*
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)
}
@@ -1,242 +0,0 @@
/*
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)
}
@@ -1,418 +0,0 @@
/*
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)
}
@@ -1,158 +0,0 @@
/*
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)
}
@@ -1,182 +0,0 @@
/*
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,7 +9,6 @@ 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
@@ -51,11 +50,8 @@ 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
@@ -80,12 +76,6 @@ 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
@@ -152,7 +142,6 @@ 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
@@ -592,7 +592,7 @@ class Hindsight:
disposition_empathy: Deprecated. Use update_bank_config(disposition_empathy=...) instead.
disposition: Deprecated. Use update_bank_config(disposition_skepticism=...) instead.
retain_mission: Steers what gets extracted during retain(). Injected alongside built-in rules.
retain_extraction_mode: Fact extraction mode: 'concise' (default), 'verbose', or 'custom'.
retain_extraction_mode: Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'.
retain_custom_instructions: Custom extraction prompt (only active when mode is 'custom').
retain_chunk_size: Target maximum characters for each content chunk during retain.
retain_structured_chunk_size: Maximum characters for a single JSONL line or conversation
@@ -731,7 +731,7 @@ class Hindsight:
disposition_empathy: Deprecated. Use update_bank_config(disposition_empathy=...) instead.
disposition: Deprecated. Use update_bank_config(disposition_skepticism=...) instead.
retain_mission: Steers what gets extracted during retain(). Injected alongside built-in rules.
retain_extraction_mode: Fact extraction mode: 'concise' (default), 'verbose', or 'custom'.
retain_extraction_mode: Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'.
retain_custom_instructions: Custom extraction prompt (only active when mode is 'custom').
retain_chunk_size: Target maximum characters for each content chunk during retain.
retain_structured_chunk_size: Maximum characters for a single JSONL line or conversation
@@ -25,7 +25,6 @@ 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
@@ -75,11 +74,8 @@ 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
@@ -104,12 +100,6 @@ 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
@@ -176,7 +166,6 @@ 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,7 +9,6 @@ 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,11 +44,8 @@ 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
@@ -73,12 +70,6 @@ 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
@@ -145,7 +136,6 @@ 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
@@ -1,94 +0,0 @@
# 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
@@ -1,96 +0,0 @@
# 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
@@ -1,121 +0,0 @@
# 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
@@ -1,141 +0,0 @@
# 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)
@@ -1,89 +0,0 @@
# 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
@@ -1,95 +0,0 @@
# 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
@@ -1,93 +0,0 @@
# 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
@@ -1,116 +0,0 @@
# 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
@@ -1,95 +0,0 @@
# 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
@@ -1,99 +0,0 @@
# 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,12 +32,6 @@ import type {
CreateDirectiveData,
CreateDirectiveErrors,
CreateDirectiveResponses,
CreateKnowledgeFolderData,
CreateKnowledgeFolderErrors,
CreateKnowledgeFolderResponses,
CreateKnowledgePageData,
CreateKnowledgePageErrors,
CreateKnowledgePageResponses,
CreateMentalModelData,
CreateMentalModelErrors,
CreateMentalModelResponses,
@@ -56,9 +50,6 @@ import type {
DeleteDocumentData,
DeleteDocumentErrors,
DeleteDocumentResponses,
DeleteKnowledgeNodeData,
DeleteKnowledgeNodeErrors,
DeleteKnowledgeNodeResponses,
DeleteMentalModelData,
DeleteMentalModelErrors,
DeleteMentalModelResponses,
@@ -74,9 +65,6 @@ import type {
ExportDocumentsData,
ExportDocumentsErrors,
ExportDocumentsResponses,
ExportKnowledgeBaseData,
ExportKnowledgeBaseErrors,
ExportKnowledgeBaseResponses,
FileRetainData,
FileRetainErrors,
FileRetainResponses,
@@ -109,15 +97,6 @@ import type {
GetGraphData,
GetGraphErrors,
GetGraphResponses,
GetKnowledgeBaseGraphData,
GetKnowledgeBaseGraphErrors,
GetKnowledgeBaseGraphResponses,
GetKnowledgeBaseTreeData,
GetKnowledgeBaseTreeErrors,
GetKnowledgeBaseTreeResponses,
GetKnowledgePageData,
GetKnowledgePageErrors,
GetKnowledgePageResponses,
GetMemoriesTimeseriesData,
GetMemoriesTimeseriesErrors,
GetMemoriesTimeseriesResponses,
@@ -241,9 +220,6 @@ import type {
UpdateDocumentData,
UpdateDocumentErrors,
UpdateDocumentResponses,
UpdateKnowledgeNodeData,
UpdateKnowledgeNodeErrors,
UpdateKnowledgeNodeResponses,
UpdateMemoryData,
UpdateMemoryErrors,
UpdateMemoryResponses,
@@ -678,138 +654,6 @@ 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
*
@@ -453,7 +453,7 @@ export type BankTemplateConfig = {
/**
* Retain Extraction Mode
*
* Fact extraction mode: 'concise' (default), 'verbose', or 'custom'
* Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'
*/
retain_extraction_mode?: string | null;
/**
@@ -1080,7 +1080,7 @@ export type CreateBankRequest = {
/**
* Retain Extraction Mode
*
* Fact extraction mode: 'concise' (default), 'verbose', or 'custom'.
* Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'.
*/
retain_extraction_mode?: string | null;
/**
@@ -1153,42 +1153,6 @@ 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
*
@@ -1251,35 +1215,6 @@ 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
*
@@ -2021,184 +1956,6 @@ 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
*
@@ -4181,22 +3938,6 @@ 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
*
@@ -5530,313 +5271,6 @@ 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?: {
+1 -1
View File
@@ -500,7 +500,7 @@ export class HindsightClient {
dispositionEmpathy?: number;
/** Steers what gets extracted during retain(). Injected alongside built-in rules. */
retainMission?: string;
/** Fact extraction mode: 'concise' (default), 'verbose', or 'custom'. */
/** Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'. */
retainExtractionMode?: string;
/** Custom extraction prompt (only active when retainExtractionMode is 'custom'). */
retainCustomInstructions?: string;
+1 -4
View File
@@ -11,6 +11,7 @@
"public"
],
"scripts": {
"prebuild": "npm run build -w @vectorize-io/hindsight-client",
"dev": "next dev --turbopack -p ${PORT:-9999}",
"build": "NODE_ENV=production next build && npm run build:standalone",
"build:standalone": "rm -rf standalone && SERVER_JS=$(find .next/standalone -path '*/node_modules' -prune -o -name 'server.js' -print | head -1) && test -n \"$SERVER_JS\" || (echo 'Error: server.js not found in .next/standalone - standalone build failed' && exit 1) && STANDALONE_ROOT=$(dirname \"$SERVER_JS\") && cp -r \"$STANDALONE_ROOT\" standalone && cp -r .next/standalone/node_modules standalone/node_modules && mkdir -p standalone/.next && cp -r .next/static standalone/.next/static && mkdir -p standalone/public && (cp -r public/* standalone/public/ 2>/dev/null || true)",
@@ -38,14 +39,12 @@
"@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",
@@ -55,8 +54,6 @@
"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,7 +9,6 @@ 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";
@@ -58,7 +57,7 @@ import {
import { LlmHealthDialog } from "@/components/llm-health-dialog";
import { ExtractDialog } from "@/components/extract-dialog";
type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "knowledge" | "profile";
type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "profile";
type DataSubTab = "world" | "experience" | "observations" | "mental-models";
type BankConfigTab =
| "general"
@@ -639,13 +638,6 @@ export default function BankPage() {
<EntitiesView />
</div>
)}
{/* Knowledge base Tab — KnowledgeBaseView renders its own header. */}
{view === "knowledge" && (
<div>
<KnowledgeBaseView />
</div>
)}
</div>
</main>
</div>
@@ -8,6 +8,8 @@ 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(
@@ -26,6 +28,12 @@ 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,
@@ -1,35 +0,0 @@
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 }
);
}
}
@@ -1,38 +0,0 @@
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 }
);
}
}
@@ -1,35 +0,0 @@
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 }
);
}
}
@@ -1,92 +0,0 @@
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 }
);
}
}
@@ -1,43 +0,0 @@
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 }
);
}
}
@@ -1,38 +0,0 @@
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 }
);
}
}
@@ -1,35 +0,0 @@
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-2d";
import type { GraphData, GraphNode, GraphLink } from "./graph-data";
// ============================================================================
// Types
@@ -21,6 +21,12 @@ 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;
}
// ============================================================================
@@ -372,6 +378,7 @@ 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,
};
});
@@ -448,15 +455,24 @@ export function Constellation({
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
// Screen positions
// 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).
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 sx = cx + n.wx * zoom;
const sy = cy + n.wy * zoom;
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;
screenX[i] = sx;
screenY[i] = sy;
visible[i] = sx > -margin && sx < W + margin && sy > -margin && sy < H + margin ? 1 : 0;
@@ -508,11 +524,39 @@ 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 {
const baseAlpha = 0.06 + Math.min(zoom * 0.04, 0.1);
// 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;
ctx.lineWidth = 0.4;
for (const link of linksWithIndices) {
@@ -661,11 +705,18 @@ 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 baseR = nodeSizeFn ? nodeSizeFn(n.node) : 2.5 + Math.min(n.linkCount * 0.15, 2.5);
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 r = Math.max(1.5, baseR * Math.min(zoom, 2));
// Opacity varies — fewer links = dimmer, more links = brighter
const baseAlpha = 0.45 + Math.min(n.linkCount * 0.03, 0.5);
// 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;
// Dot — star-like: heat-gradient color, varied size & opacity
ctx.beginPath();
@@ -679,12 +730,14 @@ export function Constellation({
}
ctx.fill();
// Soft glow halo for brighter stars (high link count)
// 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.
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);
ctx.globalAlpha = (0.06 + Math.min(n.linkCount * 0.005, 0.08)) * twinkle;
ctx.fill();
}
@@ -1119,9 +1172,17 @@ 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,11 +16,9 @@ import {
ChevronsRight,
Settings2,
Eye,
EyeOff,
RefreshCw,
CheckCircle,
Clock,
Network,
List,
Search,
Layers,
@@ -33,8 +31,6 @@ 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,
@@ -43,16 +39,15 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { MemoryDetailPanel } from "./memory-detail-panel";
import { MemoryDetailModal } from "./memory-detail-modal";
import { Graph2D, convertHindsightGraphData, GraphNode } from "./graph-2d";
import { convertHindsightGraphData, GraphNode } from "./graph-data";
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 = "graph" | "table" | "timeline" | "constellation";
type ViewMode = "table" | "timeline" | "constellation";
// Categorical palette for coloring observation scopes (exact tag sets) when
// "Group by scope" clusters the constellation. Distinct, reasonably separable hues.
@@ -105,7 +100,6 @@ 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);
@@ -132,10 +126,7 @@ export function DataView({
last_consolidated_at: string | null;
} | null>(null);
// Graph controls state
const [showLabels, setShowLabels] = useState(true);
const [maxNodes, setMaxNodes] = useState<number | undefined>(undefined);
const [showControlPanel, setShowControlPanel] = useState(true);
// Constellation controls state
const [visibleLinkTypes, setVisibleLinkTypes] = useState<Set<string>>(
new Set(["semantic", "temporal", "entity", "causal"])
);
@@ -152,17 +143,6 @@ 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 (
@@ -230,6 +210,8 @@ 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 => {
@@ -239,7 +221,7 @@ export function DataView({
return "semantic";
};
// Convert data for Graph2D (graph data is already filtered server-side)
// Convert data for the constellation (graph data is already filtered server-side)
const graph2DData = useMemo(() => {
if (!data) return { nodes: [], links: [] };
const fullData = convertHindsightGraphData(data);
@@ -253,44 +235,11 @@ 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) => {
const nodeData = data?.table_rows?.find((row: any) => row.id === node.id);
if (nodeData) {
setSelectedGraphNode(nodeData);
}
},
[data]
);
const handleGraphNodeClick = useCallback((node: GraphNode) => {
// Open the memory dialog for the clicked node (same dialog the table/timeline use).
setModalMemoryId(node.id);
}, []);
// Memoized color functions to prevent graph re-initialization
// Uses brand colors: primary blue (#0074d9), teal (#009296), amber for entity, purple for causal
@@ -509,19 +458,6 @@ 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 ? (
@@ -529,7 +465,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 ? (
) : data && data.total_units === 0 && !hasActiveMemoryFilters ? (
<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>
@@ -644,7 +580,7 @@ export function DataView({
</Button>
)}
<div className="text-sm text-muted-foreground">
{searchQuery || tagFilters.length > 0 ? (
{hasActiveMemoryFilters ? (
t("matchingMemories", { count: filteredTableRows.length })
) : data.table_rows?.length < data.total_units ? (
<span>
@@ -656,11 +592,8 @@ export function DataView({
onClick={() => {
const newLimit = Math.min(data.total_units, fetchLimit + 1000);
setFetchLimit(newLimit);
loadData(
newLimit,
searchQuery || undefined,
tagFilters.length > 0 ? tagFilters : undefined
);
const { tags, match } = resolveTagQuery();
loadData(newLimit, searchQuery || undefined, tags, match);
}}
className="ml-2 text-primary hover:underline"
>
@@ -705,11 +638,10 @@ export function DataView({
{t("pendingCount", { count: consolidationStatus.pending_consolidation })}
<button
onClick={() =>
loadData(
fetchLimit,
searchQuery || undefined,
tagFilters.length > 0 ? tagFilters : undefined
)
(() => {
const { tags, match } = resolveTagQuery();
loadData(fetchLimit, searchQuery || undefined, tags, match);
})()
}
disabled={loading}
className="ml-0.5 opacity-70 hover:opacity-100 disabled:opacity-40 transition-opacity"
@@ -734,17 +666,6 @@ 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 ${
@@ -771,244 +692,76 @@ export function DataView({
</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>
{/* 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>
{(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>
</div>
</div>
)}
)}
{(compactMode || viewMode === "constellation") && (
<div className="flex gap-0">
<div className="flex-1 min-w-0 border border-border rounded-lg overflow-hidden">
<div className="border border-border rounded-lg overflow-hidden">
<Constellation
key={compactMode ? "compact" : "full"}
data={graph2DData}
@@ -1049,119 +802,6 @@ 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>
)}
@@ -1396,9 +1036,7 @@ export function DataView({
})()
) : (
<div className="text-center py-12 text-muted-foreground">
{data.table_rows?.length > 0
? t("noMemoriesMatchFilter")
: t("noMemoriesFound")}
{hasActiveMemoryFilters ? 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-2d";
import { convertHindsightGraphData, GraphNode } from "./graph-data";
type EntityGraphResponse = Awaited<ReturnType<typeof client.getEntityGraph>>;
@@ -1,726 +0,0 @@
"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 };
}
@@ -0,0 +1,84 @@
// 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 };
}
@@ -1,684 +0,0 @@
"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,12 +154,23 @@ export function MentalModelsView() {
setLoading(true);
try {
const mentalModelsData = await client.listMentalModels(
currentBank,
selectedTags.length > 0 ? selectedTags : undefined,
selectedTags.length > 0 ? tagsMatch : undefined
);
setMentalModels(mentalModelsData.items || []);
// 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);
} catch (error) {
console.error("Error loading mental models:", error);
} finally {
@@ -10,7 +10,6 @@ import {
Database,
FileText,
Users,
Network,
ChevronLeft,
ChevronRight,
Settings,
@@ -18,7 +17,7 @@ import {
import { cn } from "@/lib/utils";
import Link from "next/link";
type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "knowledge" | "profile";
type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "profile";
interface SidebarProps {
currentTab: NavItem;
@@ -36,7 +35,6 @@ 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,7 +14,19 @@ 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".
@@ -1,25 +0,0 @@
"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 };
+13 -123
View File
@@ -38,19 +38,6 @@ 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;
@@ -606,115 +593,6 @@ 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
*/
@@ -1327,7 +1205,13 @@ export class ControlPlaneClient {
/**
* List mental models for a bank
*/
async listMentalModels(bankId: string, tags?: string[], tagsMatch?: string) {
async listMentalModels(
bankId: string,
tags?: string[],
tagsMatch?: string,
limit?: number,
offset?: number
) {
const params = new URLSearchParams();
if (tags && tags.length > 0) {
tags.forEach((t) => params.append("tags", t));
@@ -1335,6 +1219,12 @@ 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<{
+1 -74
View File
@@ -147,8 +147,7 @@
"memoryBank": "Speicherbank",
"expandSidebar": "Seitenleiste ausklappen",
"collapseSidebar": "Seitenleiste einklappen",
"collapse": "Einklappen",
"knowledge": "Knowledge base"
"collapse": "Einklappen"
},
"overviewAndOperations": "Übersichtsstatistik und Hintergrundoperationen für diesen Speicherbank.",
"operations": "Operationen",
@@ -535,7 +534,6 @@
"allConsolidatedWithDate": "Alle Erinnerungen konsolidiert (zuletzt: {date})",
"pendingConsolidation": "{count} Erinnerungen stehen zur Konsolidierung aus",
"constellation": "Konstellation",
"graph": "Graph",
"table": "Tabelle",
"timeline": "Zeitleiste",
"hidePanel": "Bereich ausblenden",
@@ -549,20 +547,6 @@
"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",
@@ -1454,15 +1438,6 @@
"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",
@@ -1713,55 +1688,7 @@
"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"
}
}
+1 -74
View File
@@ -148,8 +148,7 @@
"memoryBank": "Memory Bank",
"expandSidebar": "Expand sidebar",
"collapseSidebar": "Collapse sidebar",
"collapse": "Collapse",
"knowledge": "Knowledge base"
"collapse": "Collapse"
},
"overviewAndOperations": "Overview statistics and background operations for this memory bank.",
"operations": "Operations",
@@ -535,7 +534,6 @@
"allConsolidatedWithDate": "All memories consolidated (last: {date})",
"pendingConsolidation": "{count} memories pending consolidation",
"constellation": "Constellation",
"graph": "Graph",
"table": "Table",
"timeline": "Timeline",
"hidePanel": "Hide panel",
@@ -549,20 +547,6 @@
"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",
@@ -1454,15 +1438,6 @@
"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",
@@ -1713,55 +1688,7 @@
"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"
}
}
+1 -74
View File
@@ -147,8 +147,7 @@
"memoryBank": "Banco de memoria",
"expandSidebar": "Expandir barra lateral",
"collapseSidebar": "Contraer barra lateral",
"collapse": "Contraer",
"knowledge": "Knowledge base"
"collapse": "Contraer"
},
"overviewAndOperations": "Estadísticas generales y operaciones en segundo plano de este banco de memoria.",
"operations": "Operaciones",
@@ -535,7 +534,6 @@
"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",
@@ -549,20 +547,6 @@
"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",
@@ -1454,15 +1438,6 @@
"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",
@@ -1713,55 +1688,7 @@
"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"
}
}
+1 -74
View File
@@ -147,8 +147,7 @@
"memoryBank": "Banque de mémoire",
"expandSidebar": "Développer la barre latérale",
"collapseSidebar": "Réduire la barre latérale",
"collapse": "Réduire",
"knowledge": "Knowledge base"
"collapse": "Réduire"
},
"overviewAndOperations": "Statistiques générales et opérations en arrière-plan pour cette banque mémoire.",
"operations": "Opérations",
@@ -535,7 +534,6 @@
"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",
@@ -549,20 +547,6 @@
"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",
@@ -1454,15 +1438,6 @@
"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",
@@ -1713,55 +1688,7 @@
"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"
}
}
+1 -74
View File
@@ -147,8 +147,7 @@
"memoryBank": "メモリバンク",
"expandSidebar": "サイドバーを展開",
"collapseSidebar": "サイドバーを折りたたむ",
"collapse": "折りたたむ",
"knowledge": "Knowledge base"
"collapse": "折りたたむ"
},
"overviewAndOperations": "このメモリバンクの概要統計とバックグラウンド操作。",
"operations": "操作",
@@ -535,7 +534,6 @@
"allConsolidatedWithDate": "すべてのメモリが統合済み(最終:{date})",
"pendingConsolidation": "{count}件のメモリが統合待ち",
"constellation": "コンステレーション",
"graph": "グラフ",
"table": "テーブル",
"timeline": "タイムライン",
"hidePanel": "パネルを非表示",
@@ -549,20 +547,6 @@
"linkTypes": "リンクの種類",
"nodes": "ノード",
"links": "リンク",
"graphTitle": "グラフ",
"linksWithCount": "リンク({count}件)",
"clickToFilter": "・クリックでフィルター",
"semantic": "セマンティック",
"temporal": "時系列",
"entity": "エンティティ",
"causal": "因果",
"displayTitle": "表示",
"showLabels": "ラベルを表示",
"performanceTitle": "パフォーマンス",
"maxNodes": "最大ノード数",
"allLinksVisible": "表示中のノード間のすべてのリンクが表示されています。",
"limitedTo50Nodes": "⚠️ パフォーマンスのため50ノードに制限されています。合計:{count}",
"clickNodeForDetails": "ノードをクリックして詳細を確認",
"columnObservation": "観察",
"columnMemory": "メモリ",
"columnSources": "ソース",
@@ -1454,15 +1438,6 @@
"factTypeObservation": "オブザベーション",
"actionClearContent": "内容をクリア"
},
"graph2d": {
"loading": "グラフを読み込み中...",
"emptyState": "表示するメモリがありません",
"linkTypeCausal": "因果 ({type})",
"linkTypeGeneric": "{type} リンク",
"linkTooltipEntity": "エンティティ:",
"linkTooltipWeight": "ウェイト:",
"controlsHint": "ドラッグで移動 • スクロールでズーム • ノードをダブルクリックでフォーカス • 背景をクリックでリセット"
},
"constellation": {
"instructions": "スクロールでズーム · ドラッグで移動 · ホバーで探索 · クリックで選択",
"hudStats": "{memories} 件のメモリ · {visible} 件表示 · {labels} 件のラベル · {links} 件のリンク · ズーム {zoom}x",
@@ -1713,55 +1688,7 @@
"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"
}
}
+1 -74
View File
@@ -147,8 +147,7 @@
"memoryBank": "메모리 뱅크",
"expandSidebar": "사이드바 펼치기",
"collapseSidebar": "사이드바 접기",
"collapse": "접기",
"knowledge": "Knowledge base"
"collapse": "접기"
},
"overviewAndOperations": "이 메모리 뱅크의 개요 통계 및 백그라운드 작업.",
"operations": "작업",
@@ -535,7 +534,6 @@
"allConsolidatedWithDate": "모든 메모리 통합됨 (마지막: {date})",
"pendingConsolidation": "{count}개 메모리 통합 대기 중",
"constellation": "별자리",
"graph": "그래프",
"table": "표",
"timeline": "타임라인",
"hidePanel": "패널 숨기기",
@@ -549,20 +547,6 @@
"linkTypes": "링크 유형",
"nodes": "노드",
"links": "링크",
"graphTitle": "그래프",
"linksWithCount": "링크 ({count})",
"clickToFilter": "· 클릭하여 필터링",
"semantic": "의미적",
"temporal": "시간적",
"entity": "엔티티",
"causal": "인과적",
"displayTitle": "표시",
"showLabels": "레이블 표시",
"performanceTitle": "성능",
"maxNodes": "최대 노드",
"allLinksVisible": "표시된 노드 간의 모든 링크가 표시됩니다.",
"limitedTo50Nodes": "⚠️ 성능을 위해 50개 노드로 제한됩니다. 전체: {count}",
"clickNodeForDetails": "노드를 클릭하면 세부 정보를 볼 수 있습니다",
"columnObservation": "관찰",
"columnMemory": "메모리",
"columnSources": "소스",
@@ -1454,15 +1438,6 @@
"factTypeObservation": "관찰",
"actionClearContent": "콘텐츠 지우기"
},
"graph2d": {
"loading": "그래프 로딩 중...",
"emptyState": "표시할 메모리가 없습니다",
"linkTypeCausal": "인과 ({type})",
"linkTypeGeneric": "{type} 링크",
"linkTooltipEntity": "엔티티:",
"linkTooltipWeight": "가중치:",
"controlsHint": "드래그하여 이동 • 스크롤하여 확대/축소 • 노드 더블클릭으로 포커스 • 배경 클릭으로 초기화"
},
"constellation": {
"instructions": "스크롤하여 확대/축소 · 드래그하여 이동 · 호버하여 탐색 · 클릭하여 선택",
"hudStats": "{memories}개 메모리 · {visible}개 표시 · {labels}개 레이블 · {links}개 링크 · 줌 {zoom}x",
@@ -1713,55 +1688,7 @@
"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"
}
}
+1 -74
View File
@@ -147,8 +147,7 @@
"memoryBank": "Banco de memória",
"expandSidebar": "Expandir barra lateral",
"collapseSidebar": "Recolher barra lateral",
"collapse": "Recolher",
"knowledge": "Knowledge base"
"collapse": "Recolher"
},
"overviewAndOperations": "Estatísticas gerais e operações em segundo plano para este banco de memória.",
"operations": "Operações",
@@ -535,7 +534,6 @@
"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",
@@ -549,20 +547,6 @@
"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",
@@ -1454,15 +1438,6 @@
"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",
@@ -1713,55 +1688,7 @@
"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"
}
}

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