Compare commits

...
Author SHA1 Message Date
Dikshant PradhanandClaude Opus 4.8 6e065f7516 docs(oracle): document the required schema, dimension matching, and start-oracle re-run
Verifying the Oracle guide end-to-end surfaced three setup steps that weren't
documented and that block a first-time deployment:

- HINDSIGHT_API_DATABASE_SCHEMA must be set to the Oracle schema user. The
  default `public` is a PostgreSQL notion and makes migrations fail with
  ORA-01435. Added it to the configure step (with a warning), the quick start,
  the config reference table, and troubleshooting.
- Migrations must run with the same embedding dimension as the serving model,
  or retain fails with ORA-51803. Added a warning to the migrate step and a
  troubleshooting row (including the --embedding-dimension resize path).
- The dev quick-start container can report a provisioning error on a cold
  start's first run; noted that re-running the idempotent script succeeds.

Mirrored into versioned_docs/version-0.8 and 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 16:57:13 -07:00
DK09876andClaude Opus 4.8 0338ab4d73 fix(oracle): don't await the synchronous cursor.close() in _set_session_schema (#2613)
oracledb's AsyncCursor.close() is not a coroutine, so awaiting it raised
"object NoneType can't be used in 'await' expression" on every acquire()
under a non-public schema. This broke the database health check and all
retain/recall/reflect operations on Oracle whenever a non-public schema was
active — which is the norm on Oracle, since a schema is a user and the
default `public` schema does not exist there.

Drop the erroneous await. Add unit regression tests (no live Oracle needed —
a fake cursor whose close() is synchronous, exactly like oracledb) covering
both the non-public path (previously raised TypeError) and the public no-op
path. These run in the standard test suite, unlike the label-gated Oracle
integration job.


Claude-Session: https://claude.ai/code/session_01PginSDrapXsoDd6gN5Pszo

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 16:49:51 -07:00
DK09876andClaude Opus 4.8 f4106fff55 docs: add Oracle Database setup guide (#2612)
* 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

* 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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 14:58:03 -07:00
BenandClaude Opus 4.8 d4f700ed22 Blog: Give Zed's AI Assistant a Persistent Memory (#2598)
* Blog: persistent memory for the Zed editor (hindsight-zed v0.1.0)

New post on the Zed integration: wires Zed's Agent Panel to the Hindsight MCP
server (recall/retain/reflect) plus a global AGENTS.md rule, so the assistant
remembers decisions and conventions across sessions. Grounded in the v0.1.0
source; em-dash-free. Adds a series-style cover.

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

* Blog: update Zed install to the Node CLI (npx), per #2599

hindsight-zed is now a zero-dependency Node CLI: `npx hindsight-zed init`
(or `npm install -g`). Node.js only, no Python. Mechanism unchanged.

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

* Blog: swap Zed cover to the typographic "Memory for Zed" poster

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 16:31:25 -04: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
167 changed files with 5411 additions and 3213 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]
@@ -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 -10
View File
@@ -52,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:
@@ -1245,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,
@@ -1433,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,
@@ -1666,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,
@@ -1677,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'.")
@@ -2203,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')"
@@ -2429,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'")
@@ -3750,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,
@@ -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,
@@ -1297,7 +1297,10 @@ class OracleBackend(DatabaseBackend):
if schema and schema != "public":
cursor = conn.cursor()
await cursor.execute(f'ALTER SESSION SET CURRENT_SCHEMA = "{schema}"')
await cursor.close()
# oracledb's AsyncCursor.close() is synchronous (not a coroutine);
# awaiting it raises "object NoneType can't be used in 'await'
# expression" and aborts every acquire() under a non-public schema.
cursor.close()
@asynccontextmanager
async def acquire(self) -> AsyncIterator[OracleConnection]:
@@ -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"],
@@ -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]}"
+145 -2
View File
@@ -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
# ---------------------------------------------------------------------------
@@ -769,3 +847,68 @@ class TestNormalizeSchema:
assert backend.normalize_schema("public") is None
assert backend.normalize_schema("tenant_abc") == "tenant_abc"
assert backend.normalize_schema(None) is None
# ---------------------------------------------------------------------------
# OracleBackend._set_session_schema regression
# ---------------------------------------------------------------------------
class TestOracleSetSessionSchema:
"""Regression coverage for _set_session_schema (no live Oracle required)."""
@pytest.mark.asyncio
async def test_does_not_await_synchronous_cursor_close(self):
"""A non-public schema is applied without awaiting the sync cursor.close().
oracledb's AsyncCursor.close() is synchronous (returns None), so
``await cursor.close()`` raised "object NoneType can't be used in
'await' expression" on every acquire() under a non-public schema —
breaking the DB health check and all memory operations on Oracle.
Reproduced with a fake cursor whose close() is synchronous, exactly
like oracledb: this test fails (TypeError) against the buggy code and
passes once the erroneous await is removed.
"""
from hindsight_api.engine import memory_engine
from hindsight_api.engine.db.oracle import OracleBackend
executed: list[str] = []
closed = {"count": 0}
class _FakeAsyncCursor:
async def execute(self, sql: str) -> None:
executed.append(sql)
def close(self) -> None: # synchronous, like oracledb.AsyncCursor.close
closed["count"] += 1
class _FakeConn:
def cursor(self) -> "_FakeAsyncCursor":
return _FakeAsyncCursor()
backend = OracleBackend()
token = memory_engine._current_schema.set("TENANT_X")
try:
await backend._set_session_schema(_FakeConn())
finally:
memory_engine._current_schema.reset(token)
assert closed["count"] == 1
assert any('ALTER SESSION SET CURRENT_SCHEMA = "TENANT_X"' in s for s in executed)
@pytest.mark.asyncio
async def test_public_schema_is_a_noop(self):
"""The default ``public`` schema does no session work on Oracle."""
from hindsight_api.engine import memory_engine
from hindsight_api.engine.db.oracle import OracleBackend
class _FakeConn:
def cursor(self):
raise AssertionError("cursor() must not be called for the public schema")
backend = OracleBackend()
token = memory_engine._current_schema.set("public")
try:
await backend._set_session_schema(_FakeConn())
finally:
memory_engine._current_schema.reset(token)
@@ -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
@@ -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
+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
View File
@@ -6339,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
@@ -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
@@ -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;
/**
+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",
@@ -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,
@@ -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 };
}
@@ -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 {
@@ -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 -1
View File
@@ -1205,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));
@@ -1213,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<{
@@ -534,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",
@@ -548,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",
@@ -1453,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",
@@ -534,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",
@@ -548,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",
@@ -1453,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",
@@ -534,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",
@@ -548,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",
@@ -1453,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",
@@ -534,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",
@@ -548,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",
@@ -1453,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",
@@ -534,7 +534,6 @@
"allConsolidatedWithDate": "すべてのメモリが統合済み(最終:{date})",
"pendingConsolidation": "{count}件のメモリが統合待ち",
"constellation": "コンステレーション",
"graph": "グラフ",
"table": "テーブル",
"timeline": "タイムライン",
"hidePanel": "パネルを非表示",
@@ -548,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": "ソース",
@@ -1453,15 +1438,6 @@
"factTypeObservation": "オブザベーション",
"actionClearContent": "内容をクリア"
},
"graph2d": {
"loading": "グラフを読み込み中...",
"emptyState": "表示するメモリがありません",
"linkTypeCausal": "因果 ({type})",
"linkTypeGeneric": "{type} リンク",
"linkTooltipEntity": "エンティティ:",
"linkTooltipWeight": "ウェイト:",
"controlsHint": "ドラッグで移動 • スクロールでズーム • ノードをダブルクリックでフォーカス • 背景をクリックでリセット"
},
"constellation": {
"instructions": "スクロールでズーム · ドラッグで移動 · ホバーで探索 · クリックで選択",
"hudStats": "{memories} 件のメモリ · {visible} 件表示 · {labels} 件のラベル · {links} 件のリンク · ズーム {zoom}x",
@@ -534,7 +534,6 @@
"allConsolidatedWithDate": "모든 메모리 통합됨 (마지막: {date})",
"pendingConsolidation": "{count}개 메모리 통합 대기 중",
"constellation": "별자리",
"graph": "그래프",
"table": "표",
"timeline": "타임라인",
"hidePanel": "패널 숨기기",
@@ -548,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": "소스",
@@ -1453,15 +1438,6 @@
"factTypeObservation": "관찰",
"actionClearContent": "콘텐츠 지우기"
},
"graph2d": {
"loading": "그래프 로딩 중...",
"emptyState": "표시할 메모리가 없습니다",
"linkTypeCausal": "인과 ({type})",
"linkTypeGeneric": "{type} 링크",
"linkTooltipEntity": "엔티티:",
"linkTooltipWeight": "가중치:",
"controlsHint": "드래그하여 이동 • 스크롤하여 확대/축소 • 노드 더블클릭으로 포커스 • 배경 클릭으로 초기화"
},
"constellation": {
"instructions": "스크롤하여 확대/축소 · 드래그하여 이동 · 호버하여 탐색 · 클릭하여 선택",
"hudStats": "{memories}개 메모리 · {visible}개 표시 · {labels}개 레이블 · {links}개 링크 · 줌 {zoom}x",
@@ -534,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",
@@ -548,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",
@@ -1453,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",
@@ -534,7 +534,6 @@
"allConsolidatedWithDate": "所有記憶已整合(最近:{date}",
"pendingConsolidation": "{count} 條記憶待整合",
"constellation": "星座圖",
"graph": "圖譜",
"table": "表格",
"timeline": "時間軸",
"hidePanel": "隱藏面板",
@@ -548,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": "來源",
@@ -1453,15 +1438,6 @@
"factTypeObservation": "觀察",
"actionClearContent": "清除內容"
},
"graph2d": {
"loading": "載入中圖譜...",
"emptyState": "目前沒有記憶可顯示",
"linkTypeCausal": "因果 ({type})",
"linkTypeGeneric": "{type} 連結",
"linkTooltipEntity": "實體:",
"linkTooltipWeight": "權重:",
"controlsHint": "拖曳平移 • 滾動縮放 • 雙擊節點聚焦 • 選取背景重設"
},
"constellation": {
"instructions": "滾動縮放 · 拖曳平移 · 懸停探索 · 選取項目",
"hudStats": "{memories} 條記憶 · {visible} 條可見 · {labels} 個標籤 · {links} 條連結 · 縮放 {zoom}x",
@@ -534,7 +534,6 @@
"allConsolidatedWithDate": "所有记忆已整合(最近:{date}",
"pendingConsolidation": "{count} 条记忆待整合",
"constellation": "星座图",
"graph": "图谱",
"table": "表格",
"timeline": "时间线",
"hidePanel": "隐藏面板",
@@ -548,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": "来源",
@@ -1453,15 +1438,6 @@
"factTypeObservation": "观察",
"actionClearContent": "清除内容"
},
"graph2d": {
"loading": "正在加载图谱...",
"emptyState": "暂无记忆可显示",
"linkTypeCausal": "因果 ({type})",
"linkTypeGeneric": "{type} 链接",
"linkTooltipEntity": "实体:",
"linkTooltipWeight": "权重:",
"controlsHint": "拖拽平移 • 滚动缩放 • 双击节点聚焦 • 点击背景重置"
},
"constellation": {
"instructions": "滚动缩放 · 拖拽平移 · 悬停探索 · 点击选择",
"hudStats": "{memories} 条记忆 · {visible} 条可见 · {labels} 个标签 · {links} 条链接 · 缩放 {zoom}x",
@@ -534,7 +534,6 @@
"allConsolidatedWithDate": "所有記憶已整合(最近:{date}",
"pendingConsolidation": "{count} 條記憶待整合",
"constellation": "星座圖",
"graph": "圖譜",
"table": "表格",
"timeline": "時間軸",
"hidePanel": "隱藏面板",
@@ -548,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": "來源",
@@ -1453,15 +1438,6 @@
"factTypeObservation": "觀察",
"actionClearContent": "清除內容"
},
"graph2d": {
"loading": "載入中圖譜...",
"emptyState": "尚無記憶可顯示",
"linkTypeCausal": "因果 ({type})",
"linkTypeGeneric": "{type} 連結",
"linkTooltipEntity": "實體:",
"linkTooltipWeight": "權重:",
"controlsHint": "拖曳平移 • 滾動縮放 • 雙擊節點聚焦 • 按一下背景重設"
},
"constellation": {
"instructions": "滾動縮放 · 拖曳平移 · 懸停探索 · 按一下選取",
"hudStats": "{memories} 條記憶 · {visible} 條可見 · {labels} 個標籤 · {links} 條連結 · 縮放 {zoom}x",
@@ -0,0 +1,130 @@
---
title: "Devin Desktop Persistent Memory (Formerly Windsurf)"
authors: [benfrank241]
slug: "2026/07/02/devin-desktop-persistent-memory"
date: 2026-07-02T13:00
tags: [hindsight, devin-desktop, devin, windsurf, codeium, memory, persistent-memory, mcp, tutorial]
description: "Add persistent memory to Devin Desktop (formerly Windsurf): a remote MCP server plus one always-on rule that recalls at task start and retains as you work."
image: /img/blog/devin-desktop-persistent-memory.png
hide_table_of_contents: true
---
![Devin Desktop Persistent Memory with Hindsight](/img/blog/devin-desktop-persistent-memory.png)
[Devin Desktop](https://devin.ai) is the editor Cognition rebranded from Windsurf (formerly Codeium) in June 2026. The name changed; the gap didn't. Devin reads your codebase and holds a plan within a session, but it carries nothing across sessions. Close the editor, reopen it tomorrow, and the agent is a fresh model again, with no memory of the decision you talked through last week or the convention you set on Tuesday.
The `hindsight-devin-desktop` integration adds persistent long-term memory to Devin. It's worth understanding *how* it gets there, because Devin Desktop doesn't expose lifecycle hooks to third parties. There's no place to bolt a `sessionStart` recall or a `stop` retain. Instead the integration uses two things the editor *does* support: **remote [Model Context Protocol](https://modelcontextprotocol.io) (MCP) servers** and **always-on workspace rules**.
<!-- truncate -->
## TL;DR
- Devin Desktop (formerly Windsurf) has no third-party lifecycle hooks, so memory is wired through MCP plus a rule, not hook scripts.
- `hindsight-devin-desktop init` connects the Hindsight **remote MCP server** (Devin gets `recall` / `retain` / `reflect` tools) and writes one **always-on rule** to `.devin/rules/hindsight.md`.
- The rule tells Devin to `recall` at the start of each task and `retain` durable facts as it works.
- No local daemon, no plugin scripts, no per-turn hooks. The MCP endpoint connects straight to [Hindsight Cloud](https://hindsight.vectorize.io) or your self-hosted server.
- This is **model-driven memory**: the rule rides in every request, but the actual recall/retain calls are Devin's decision. That's the main tradeoff versus deterministic hook-based integrations.
## Why Devin Desktop Needs Persistent Memory
A new Devin session starts with whatever it can see: your open files, the workspace, and any rules you've written in `.devin/rules/`. What it can't see is the past. The bug you traced through three files yesterday, the library you chose and why, the naming convention you've been holding the line on. None of that survives the session boundary unless you wrote it down somewhere Devin reads.
You can pin context by hand with rules files, and for stable facts that works. It doesn't help with the things you didn't know to record in advance. Persistent memory closes that gap: durable facts get retained as you work, and the relevant ones come back on their own next time.
That matters more for an editor you live in all day. A coding agent that reintroduces itself every morning isn't really an assistant. Memory is what turns a fresh-every-session model into one that builds on yesterday.
## How Devin Desktop Persistent Memory Works
Devin Desktop gives third parties two integration points, and `hindsight-devin-desktop` uses both.
**Remote MCP server.** Devin Desktop reads MCP servers from a single global config and supports *remote* servers via `serverUrl` with custom headers, so the integration points Devin straight at the Hindsight MCP endpoint with no local process to manage:
```json
{
"mcpServers": {
"hindsight": {
"serverUrl": "https://api.hindsight.vectorize.io/mcp/my-project/",
"headers": { "Authorization": "Bearer hsk_..." }
}
}
}
```
That gives Devin three tools: `recall` (search memory), `retain` (store a durable fact), and `reflect` (a synthesized, memory-grounded answer). The memory bank is encoded in the endpoint path, so one config line scopes the whole connection to a bank.
**Always-on rule.** Devin Desktop applies any rule file under `.devin/rules/` whose frontmatter says `trigger: always_on` to every request in the workspace. The integration writes one dedicated file, `.devin/rules/hindsight.md`, telling Devin how and when to use those tools:
```markdown
---
trigger: always_on
---
<!-- Managed by hindsight-devin-desktop -->
You have persistent long-term memory through the Hindsight MCP server
(`recall`, `retain`, and `reflect` tools).
- At the start of each task, call `recall` with the user's request to load
relevant decisions, preferences, and project context before you act.
Use what's relevant and ignore the rest.
- When you learn a durable fact, such as an architectural decision, a user
preference, a convention, or anything worth remembering across sessions,
call `retain` to store it.
- Do not mention these memory operations unless the user asks about them.
```
The file carries a sentinel comment (`<!-- Managed by hindsight-devin-desktop -->`) so the integration owns it end to end and can update or remove it idempotently without touching any other rule you've authored. Put together: the MCP server makes memory *available* as tools, and the always-on rule makes Devin *use* them.
## Install
```bash
pip install hindsight-devin-desktop
cd your-project
hindsight-devin-desktop init --api-token YOUR_HINDSIGHT_API_KEY --bank-id my-project
```
`init` merges the `mcpServers` entry into Devin Desktop's global MCP config and writes the rule into `./.devin/rules/hindsight.md`. Reload Devin Desktop (or refresh MCP servers) and the `hindsight` tools are live.
Three commands cover the lifecycle: `hindsight-devin-desktop init` adds the MCP server and the recall/retain rule, `status` shows whether both are configured, and `uninstall` removes them. If your MCP config isn't plain JSON (comments, or some other tool owns it), `init` won't clobber it. It prints the snippet to paste instead, which you can also get anytime with `hindsight-devin-desktop init --print-only`.
## Cloud or Self-Hosted
By default the integration points at Hindsight Cloud (`https://api.hindsight.vectorize.io`), which needs an API key from your dashboard. To run against your own server, pass `--api-url`. If it's an open local server, you can skip the token entirely:
```bash
hindsight-devin-desktop init --api-url http://localhost:8888 --bank-id my-project
```
Settings can also come from the environment: `HINDSIGHT_API_URL` (the API endpoint, defaulting to Cloud), `HINDSIGHT_API_TOKEN` (the bearer token, required for Cloud), and `HINDSIGHT_DEVIN_DESKTOP_BANK_ID` (the bank to scope memory to, defaulting to `devin-desktop`). Point two projects at the same bank to share memory, or give each its own bank for isolation.
## A Rebrand Detail Worth Knowing
Because Devin Desktop is a rebrand of Windsurf, a couple of on-disk paths still carry the old name, and the integration handles that so you don't have to. The global MCP config still lives under `~/.codeium/windsurf/` (that's Devin Desktop's data directory, unchanged by the rename), while the workspace rule now lives under `.devin/rules/`, with `.windsurf/rules/` kept as a legacy fallback. If you used the integration back when it was the Windsurf package, your existing rule keeps working and the new path takes precedence going forward.
## The Tradeoff: Model-Driven, Not Hook-Driven
This is worth being direct about, because it's the real difference between this integration and the hook-based ones for Claude Code or the Cursor CLI.
Hook-based integrations are **deterministic**. A `sessionStart` hook recalls before the agent ever sees the prompt; a `stop` hook retains after every task, whether or not the model thought to. The recall and retain happen because the harness fires an event, not because the agent decided to.
Devin Desktop doesn't offer that surface to third parties, so `hindsight-devin-desktop` is **model-driven**. The always-on rule is injected into every request, so the instruction to use memory is always present, but the actual `recall` and `retain` calls are Devin's decision. In practice modern models follow a short, concrete always-on rule reliably. But it's an instruction, not a guarantee: Devin can skip a `retain` on a task it didn't judge memorable, or answer from context without calling `recall` first. If you want memory pulled for a specific task, you can just ask ("check memory for how we handled auth"), and `reflect` is there to consolidate on demand. The honest framing: Devin Desktop trades the guarantees of hooks for the simplicity of a remote MCP server and one rule file, with no local daemon and nothing to keep running.
## Frequently Asked Questions
**Is Devin Desktop the same as Windsurf?**
Yes. Cognition rebranded the Windsurf editor (formerly Codeium) to Devin Desktop in June 2026. The `hindsight-devin-desktop` package is the maintained integration; it writes its rule to `.devin/rules/` and still reads the MCP config under `~/.codeium/windsurf/`, which is unchanged by the rebrand.
**Does Devin Desktop have built-in memory across sessions?**
No. A new session starts fresh. Persistent memory comes from an integration like `hindsight-devin-desktop` that gives Devin recall and retain over a memory layer.
**Will memory recall slow Devin down?**
Recall is the agent's call, not a per-prompt hook, so there's no fixed overhead on every turn. When Devin does recall, a Hindsight Cloud query is typically well under a second.
**Does it work with self-hosted Hindsight?**
Yes. Pass `--api-url` (or set `HINDSIGHT_API_URL`) to point at your server. For an open local server with no auth, omit the token.
## Further reading
- [What is agent memory?](https://vectorize.io/what-is-agent-memory): the foundational concepts behind recall, retention, and memory banks.
- [Best AI agent memory systems](https://vectorize.io/articles/best-ai-agent-memory-systems): how the major agent memory frameworks compare.
- [Cursor persistent memory](/blog/2026/06/12/cursor-persistent-memory): the hook-based sibling integration for the Cursor editor and CLI.
- [One memory for every AI tool](/blog/2026/04/07/one-memory-for-every-ai-tool): point Devin and your other agents at the same bank.
@@ -0,0 +1,110 @@
---
title: "From Documents to Decisions: architxt and Hindsight"
authors: [garethjcooper]
slug: "2026/07/03/architxt-hindsight-temporal-mosaic"
date: 2026-07-03T12:00
tags: [hindsight, architxt, integration, memory, temporal, enterprise-architecture, community, tutorial]
description: "How architxt uses Hindsight as a time-aware agent memory layer to turn fragmented enterprise architecture documents into a queryable, current-state Temporal Mosaic."
image: /img/blog/architxt-hindsight.png
hide_table_of_contents: true
---
![From documents to decisions: architxt and Hindsight](/img/blog/architxt-hindsight.png)
Every enterprise system is described somewhere. The problem is *where*.
Architecture decisions live in impact assessments buried in SharePoint. Integration details hide in Confluence pages written three years ago. Current-state diagrams sit in slide decks from a programme that was "phase 2'd" into oblivion. When someone asks, "How does billing actually work now?", the answer is never in one place. It is spread across dozens of documents, each written at a different time, for a different audience, with different assumptions about what was "current."
This is the problem architxt was built to solve. It integrates with [Hindsight](https://github.com/vectorize-io/hindsight), an agent memory system used as a durable, time-aware memory layer for enterprise semantic search and cross-document reasoning.
<!-- truncate -->
## architxt: turn documents into structured knowledge
architxt is a document processing pipeline and research framework, accessed via a web UI. You upload documents (PDFs, Word files, PowerPoint decks) and it extracts clean, structured content using Docling, LLM-based denoising, vision analysis for diagrams, and entity detection. The output is combined with key metadata to prepare it for Hindsight. The main components of this are:
- **Documents** with metadata, tags, and extracted blocks.
- **Entities** (systems, services, capabilities) detected and normalised across documents.
- **Mental models**: reusable LLM prompts that analyse an entity from a specific angle (capabilities, interfaces, summaries). These allow for quick retrieval of common dimensions per entity.
- **A temporal mosaic**: research and query extraction of the best-known state of each entity, regardless of when the source document was written.
![architxt detects and normalises entities across a document, mapping aliases back to a single entity.](/img/blog/architxt-entity-detection.png)
The key insight is that **knowledge freshness is not document freshness**. A 2020 component design is still valid if that component has not changed, even if the rest of the system was rewritten twice since. architxt tracks which document touched which entity, when, and lets you reason across the whole corpus without pretending there is a single "as-is" document.
## The architxt process and minimum viable input
The tool works best when documents arrive with a small amount of consistent data. architxt does not need everything to be perfect; it needs enough structure to know what each document is, when it was produced, and what it is about.
### Document hygiene
Each document should carry:
- **Document ID**: a stable identifier. The same document re-uploaded keeps the same ID.
- **Document date**: the publish date, in ISO 8601. This is the anchor for temporal reasoning.
- **Context**: a curated value describing the layer or purpose of the document, such as impact assessment, component design, or business capability definition.
- **Tags**: consistent filters such as project, domain, system, or data area.
- **Source metadata**: where the document came from (Confluence, SharePoint, a file path) and who produced it.
architxt includes tagging alignment tools that help assign context, tags, source metadata, and entity identifiers. The time saving comes from the bulk change and consistency checking architxt enables across the whole corpus. It aligns information across dozens or hundreds of documents without requiring each one to be manually updated directly through the UI. A document is only useful in the mosaic if you can locate it again, filter by it, and trust its date; architxt uses that externally curated structure to build a reliable, consistent current-state view.
### Entities are the anchor
Entities are the central unit. A component, a service, a capability: each becomes a stable reference point that observations from different documents can attach to. Without entities, a document is just a bag of text. With entities, a sentence in a 2020 design and a paragraph in a 2024 migration document can both refer to the same thing, and architxt can keep the latest view of that thing intact.
In practice, the same entity is rarely called the same thing in every document. One document might say "Billing Engine", another "Billing Service", another "BE". Aliases let architxt map these varied names back to a single entity. The more aliases are known, the more complete the entity timeline becomes.
To make this work across time, entities are embedded into documents with a stable ID, using a lightweight tagging convention such as `Billing Engine (SYS-001)`. The human name can change ("Billing Engine" might become "Billing Platform" in a later design) but the stable ID survives. architxt then resolves the current name against the stable ID, so references from older documents remain usable even after naming conventions shift.
![The entity namespace in architxt: stable IDs, aliases, and the documents each entity appears in.](/img/blog/architxt-entities.png)
This is why the minimum viable input matters. Good metadata and tags make recall precise. A stable, aliased entity namespace makes composition across documents and across years possible. The rest, mental models, reflections, and the temporal mosaic, builds on top of that foundation.
## What Hindsight adds
So, we have the data and have a basic set of metadata. We know there's a goldmine of information, that probably cost thousands or millions of dollars to get written. How do we process and store it in a way that accommodates the variance in source text?
Hindsight stores the extracted knowledge as a durable, time-aware memory bank. Rather than replacing documents with a single summary, it keeps observations as discrete entries that include when they were captured and where they came from. This matters because the temporal mosaic is only possible when you can ask "what do we most recently know about X?" instead of "what does the latest document say?"
Two Hindsight primitives make this work:
- **Retain / Observation**: when architxt extracts facts from a document, they are retained as timestamped, source-referenced facts, and then consolidated into observations. A new document about the same entity does not overwrite the old one; it adds newer facts, and the observation is re-consolidated. The mosaic can then prefer the latest observation per facet while still keeping older ones visible where nothing newer exists.
- **Reflect / Mental models**: mental models are reusable prompts that run over the current set of memories. They can be refreshed as new documentation is added, so summaries, capability lists, and interface descriptions stay current; auto-refresh after consolidation is opt-in. The output is tied to the observations it was based on, so the generated view remains grounded and traceable.
Together, retain and reflect mean the mosaic updates incrementally. New documents are ingested, facts are retained, and mental models are re-run. The "current state" is not rebuilt from scratch; it is the latest layer of a continuously updated knowledge stack.
![Comparing architxt's mental models against a remote Hindsight bank: only on architxt, on both, or only on the bank.](/img/blog/architxt-hindsight-compare.png)
## The Temporal Mosaic: current state without a single source of truth
This combination matters because most architecture tools force one of two models:
1. **Static models**: draw a diagram once, watch it rot.
2. **Designed-vs-delivered reconciliation**: try to maintain two parallel realities and merge them.
architxt takes a third path, using Hindsight as its durable memory layer. The "current state" is a mosaic: the latest reliable knowledge for each entity, sourced from whichever document last touched it. A component from a 2020 design sits next to a service from a 2024 migration document. Seams (contradictions, outdated interfaces, orphaned dependencies) surface only when a query crosses them.
This is the **Temporal Mosaic**. It accepts that organisations do not produce one consistent architecture document. They produce a stream of partial, dated, overlapping documents. Rather than flattening them into a single model, architxt uses Hindsight to make them queryable as a composite, with answers grounded in source documents and tagged with entities so they carry provenance rather than relying on model hallucination.
## What this looks like in practice
Once the documents are tagged and ingested, the question changes. Instead of "which document might have the answer?", you can ask direct questions against the corpus.
For example:
- "What are the integration points between the billing service and the customer platform?"
- "Which capabilities does the order processing system support, and which documents describe them?"
- "Has the data model for customer records changed since the 2022 platform migration?"
![A grounded capability summary for one entity, synthesised from the source documents and organised into facets such as capabilities and interfaces.](/img/blog/architxt-grounded-answer.png)
Hindsight returns grounded answers. Each claim is tied back to the document and observation it came from, so you can verify it rather than trust a generated summary. If two documents disagree, that disagreement is surfaced rather than smoothed over. This turns document search from a guessing game into a structured query.
## In short
The real cost of fragmented architecture knowledge is not the documents themselves. It is the time people spend trying to reconstruct what those documents mean when taken together.
architxt reduces that cost by turning documents into structured, entity-tagged observations. Hindsight keeps those observations alive over time. The Temporal Mosaic is the result: a current-state view that does not pretend the organisation ever produced a single authoritative description, but still makes the combined knowledge searchable, verifiable, and current.
That is the shift. Not more documents, or better diagrams, but a way to extract value from the documents already in place and provide a pathway for keeping that state current as new documents get written. The format and scope of future design documents is unknown, but they will contain information worth leveraging. architxt, coupled with Hindsight, is built to make that possible.
@@ -0,0 +1,141 @@
---
title: "Teach Vercel Eve to Remember: Automatic Agent Memory"
authors: [benfrank241]
slug: "2026/07/06/eve-persistent-memory"
date: 2026-07-06T12:00
tags: [hindsight, eve, vercel, integration, memory, persistent-memory, tutorial]
description: "Vercel Eve agents get automatic long-term memory with hindsight-eve v0.2.0: context injected before every turn, each exchange saved after, no model tool call."
image: /img/blog/eve-persistent-memory.png
hide_table_of_contents: true
---
![Eve Persistent Memory with Hindsight](/img/blog/eve-persistent-memory.png)
[Vercel Eve](https://vercel.com/eve) is an open-source, filesystem-first framework for building AI agents: an agent is a directory of files, a tool is one TypeScript file, and a skill is one Markdown file. Vercel [announced it](https://vercel.com/blog/introducing-eve) in June 2026, and it ships production features like durable execution, sandboxed compute, and OpenTelemetry tracing by default. What it does not ship is long-term memory, and that is where [Hindsight](https://hindsight.vectorize.io) comes in.
The first version of the Hindsight integration for [Eve](https://github.com/vercel/eve) gave the agent memory *tools*: `recall`, `retain`, and `reflect`, exposed over MCP for the model to call. That works, but it has a catch common to every tool-based memory setup. The model has to *decide* to use it. If it doesn't reach for `recall`, the memory may as well not exist.
`@vectorize-io/hindsight-eve` v0.2.0 removes that dependency. Memory is now **automatic**: relevant context is injected before every turn, and each exchange is saved after, without the model ever choosing to call a tool.
<!-- truncate -->
## TL;DR
- v0.2.0 switches from model-called memory tools to **automatic memory**.
- Two authored files: an **instructions resolver** that recalls before each turn and injects the result as a system message, and a **hook** that retains after each turn.
- Both call Hindsight's REST API directly, so memory never depends on the LLM deciding to act.
- Recall is **profile-based, not per-message** (the resolver can't see the live user message). It is ideal for "the agent knows you," and tunable.
- Works with [Hindsight Cloud](https://hindsight.vectorize.io) or a self-hosted server; retains run async and never block a turn.
## Why Automatic Beats a Memory Tool
A tool-based memory layer is only as reliable as the model's judgment about when to use it. Give an agent a `recall` tool and it will use it sometimes: when the prompt obviously calls for history, when it happens to think of it. The rest of the time it answers from a cold context and quietly forgets that you always want Python with type hints, or that this project standardised on a particular convention last week.
That unreliability is the whole problem [agent memory](https://vectorize.io/what-is-agent-memory) is supposed to solve. If remembering is optional, you are back to a stateless agent that occasionally remembers. Making recall and retain automatic, on every turn, is what turns "has a memory tool" into "actually remembers."
## How Eve's Automatic Memory Works
Eve is filesystem-first: an agent gains behaviour by dropping a file into the project. The v0.2.0 integration wires two of them, and neither exposes a tool to the model.
**`agent/instructions/hindsight.ts`** is a dynamic instructions resolver. On `turn.started`, before the model runs, it recalls from your Hindsight bank and injects the results as a system message. The block is fenced with a sentinel comment so recalled facts are never re-retained on the way back out.
**`agent/hooks/hindsight.ts`** is a hook. On `turn.completed`, it retains the exchange to the bank: by default both the user's message and the assistant's reply, since the reply is usually where the answer lives. Retains run asynchronously, so they never add latency to a turn, and failures degrade quietly through an `onError` callback rather than breaking the agent.
Both files call Hindsight's REST API directly (recall via `POST /v1/default/banks/{bank}/memories/recall`, retain via `POST /v1/default/banks/{bank}/memories`). There is no MCP server and no tool for the model to call. Memory happens around the turn, not inside it.
![A retained preference consolidated into an observation in the Hindsight bank, no tool call involved.](/img/blog/eve-bank-observation.png)
## Install and Quick Start
```bash
npm install @vectorize-io/hindsight-eve
```
`eve` is a peer dependency, so you already have it in an Eve project. Then create two files:
```ts
// agent/instructions/hindsight.ts
import { hindsightMemory } from "@vectorize-io/hindsight-eve";
export default hindsightMemory();
```
```ts
// agent/hooks/hindsight.ts
import { hindsightRetainHook } from "@vectorize-io/hindsight-eve";
export default hindsightRetainHook();
```
That is the whole integration. Both read their config from the environment:
| Env var | Purpose |
| --- | --- |
| `HINDSIGHT_API_KEY` | Bearer token, sent as `Authorization: Bearer <key>` |
| `HINDSIGHT_API_URL` | Hindsight REST base (defaults to Hindsight Cloud) |
| `HINDSIGHT_BANK_ID` | Bank to scope memory to (defaults to `default`, auto-created) |
Point both files at the same `HINDSIGHT_BANK_ID` so recall and retain share one store (for example, one bank per user).
## Recall Is Profile-Based, Not Per-Message
This is the design trade-off worth understanding. Eve's instruction resolver runs at the *start* of a turn, before the live user message is available to it. So recall can't be query-specific to what the user just asked. Instead it uses a fixed, broad query (the default is `"user preferences, identity, and working context"`) to surface the user's ambient profile every turn.
That is exactly right for the "the agent knows you" case: preferences, identity, ongoing project context, the durable things that should shape every reply. It is deterministic, and you can tune the query with the `recallQuery` option to match what your agent should always keep in view.
What it is not is per-message semantic retrieval. Pulling the three most relevant facts for *this specific question* inherently needs a tool the model calls at the right moment, and that is out of scope for the automatic path by design. If you need both, the two approaches compose: automatic profile recall for the ambient context, a called tool for targeted lookups.
## Cloud or Self-Hosted
For **Hindsight Cloud**, set `HINDSIGHT_API_KEY` to a key from your dashboard. `HINDSIGHT_API_URL` defaults to `https://api.hindsight.vectorize.io`, so there is nothing else to configure.
For a **self-hosted** server, point at your own base URL. If it runs without auth, pass `apiKey: null`:
```ts
import { hindsightMemory } from "@vectorize-io/hindsight-eve";
export default hindsightMemory({ apiUrl: "http://localhost:8000", apiKey: null });
```
Both factories accept the same options, each falling back to its env var:
| Option | Default | Purpose |
| --- | --- | --- |
| `apiUrl` / `apiKey` / `bankId` | env, then Cloud | connection and bank (`apiKey: null` = no auth) |
| `recallQuery` | `"user preferences, identity, and working context"` | the broad query used for recall |
| `budget` | `"mid"` | recall result budget (`low` / `mid` / `high`) |
| `maxTokens` | `1024` | recall token budget |
| `includeAssistantReply` | `true` | retain the assistant's reply too; set `false` to store only the user's message |
| `context` | `"eve"` | the `context` tag written on retained items |
| `onError` | `console.warn` | where recall/retain failures degrade to |
## Verify It Works
Run your agent and tell it a durable preference in one chat, for example "when I ask for code, always write it in Rust."
![Teaching the agent a durable preference in one chat.](/img/blog/eve-teach-preference.png)
Then start a **fresh** chat and ask for something. The agent applies the remembered preference, because the memory was injected as a system message before the model ran, with no tool call and no prompting from you.
![A fresh chat writes the CSV parser in Rust, applying the remembered preference with no tool call.](/img/blog/eve-recall-rust.png)
## Frequently Asked Questions
**Does the model have to call a tool to use memory?**
No. That is the point of v0.2.0. Recall is injected before the model runs, and retain happens after the turn via a hook. The model never sees or calls a memory tool.
**What gets recalled each turn?**
Your ambient profile: preferences, identity, and working context, via a fixed broad query. The instruction resolver runs before the live user message is available, so recall is profile-based rather than per-message. Tune it with `recallQuery`.
**Does it store the assistant's replies too?**
Yes. By default it retains both the user's message and the assistant's reply, because the reply is usually where the real signal lives: the decision it reached, the solution it described, the code it wrote. If you would rather keep only what the user said, set `includeAssistantReply: false`.
**Does it add latency?**
Retains run asynchronously and never block a turn; recall is a single call before the turn. Failures degrade through `onError` rather than breaking the agent.
## Further reading
- [What is agent memory?](https://vectorize.io/what-is-agent-memory): the foundational concepts behind recall and retention.
- [Best AI agent memory systems](https://vectorize.io/articles/best-ai-agent-memory-systems): how the major agent memory frameworks compare.
- [Vercel AI SDK persistent memory](/blog/2026/06/23/vercel-ai-sdk-persistent-memory): memory for the other Vercel agent stack.
- [One memory for every AI tool](/blog/2026/04/07/one-memory-for-every-ai-tool): point Eve and your other agents at the same bank.
@@ -0,0 +1,127 @@
---
title: "Give Zed's AI Assistant a Persistent Memory"
authors: [benfrank241]
slug: "2026/07/07/zed-persistent-memory"
date: 2026-07-07T12:00
tags: [hindsight, zed, integration, memory, persistent-memory, mcp, tutorial]
description: "The Zed editor's AI assistant is sharp inside a task and a stranger between them. hindsight-zed gives it long-term memory that recalls and retains across sessions."
image: /img/blog/zed-persistent-memory.png
hide_table_of_contents: true
---
![Persistent memory for the Zed editor with Hindsight](/img/blog/zed-persistent-memory.png)
[Zed](https://zed.dev) is a fast, Rust-built editor with a genuinely good AI assistant in its Agent Panel. But like most coding agents, it is brilliant inside a single task and a stranger between them. Close the panel, start a new conversation tomorrow, and it has forgotten that this repo uses pnpm, that you settled on a repository pattern last week, and that you like your tests colocated.
`hindsight-zed` fixes that. One command wires Zed's Agent Panel to a shared [Hindsight](https://github.com/vectorize-io/hindsight) memory so the agent can recall relevant decisions before it answers and retain durable facts as it works, across every session.
<!-- truncate -->
## TL;DR
- `hindsight-zed init` registers the Hindsight **MCP server** in Zed and adds a rule telling the agent to use it.
- The agent gets three tools: `recall`, `retain`, and `reflect`.
- Recall runs at **query time** against your actual message, so it pulls context relevant to what you just asked, with no lag.
- Works with [Hindsight Cloud](https://hindsight.vectorize.io) or a self-hosted server. It is [agent memory](https://vectorize.io/what-is-agent-memory) that outlives the conversation.
## The problem: a great assistant with no yesterday
A coding assistant that forgets everything between sessions makes you the memory. You re-explain the stack, restate the conventions, and re-litigate decisions you already made, every time you open a fresh conversation. The model is capable; it just has no continuity.
Persistent memory closes that gap. The point of memory is that the things you have already told your agent, and the things it has already figured out, are still there next time. That is what turns a per-session tool into an assistant that actually knows your project.
## How Zed's memory works
Zed does not expose a pre-prompt hook, so there is nowhere to automatically inject context before a turn. What Zed does give you is two building blocks, and the integration uses both.
**MCP context servers.** Zed runs [Model Context Protocol](https://modelcontextprotocol.io) servers listed under `context_servers` in its `settings.json` and exposes their tools in the Agent Panel. `hindsight-zed` registers the Hindsight MCP server there, which hands the agent `recall`, `retain`, and `reflect` tools.
**A global instructions file.** Zed includes `~/.config/zed/AGENTS.md` in every agent conversation. The integration writes a short rule into that file, inside a fenced `<!-- HINDSIGHT:BEGIN -->` to `<!-- HINDSIGHT:END -->` block so it never touches your own rules. The rule tells the agent to call `recall` at the start of each task to load relevant decisions, preferences, and project context, and to call `retain` whenever it learns a durable fact worth keeping across sessions.
The result is recall that happens at query time, against the message you actually sent. Because it runs when you ask rather than on a fixed schedule, it can pull the memories relevant to this specific request. From your seat it is automatic: you type, the agent quietly checks its memory, then answers.
One transport note. Zed does not yet ship native HTTP MCP transport, so the server connects through the [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) stdio bridge, run via `npx`. Because that bridge runs on Node.js, and the setup CLI is a Node tool too, Node.js is the only requirement. No Python needed.
## Install and quick start
```bash
npx hindsight-zed init --api-token YOUR_HINDSIGHT_API_KEY --bank-id my-memory
```
`hindsight-zed` is a zero-dependency Node CLI, so `npx` runs it with no global install. Prefer a persistent command? Run `npm install -g hindsight-zed` first.
`init` adds the `hindsight` MCP server to `~/.config/zed/settings.json` and the recall/retain rule to `~/.config/zed/AGENTS.md`. Restart Zed, open the Agent Panel, and the `hindsight` server should show a green dot.
That is the whole setup. Under the hood, the settings entry looks like this:
```jsonc
{
"context_servers": {
"hindsight": {
"source": "custom",
"command": "npx",
"args": [
"-y", "mcp-remote",
"https://api.hindsight.vectorize.io/mcp/my-memory/",
"--header", "Authorization: Bearer YOUR_HINDSIGHT_API_KEY"
]
}
}
}
```
If your `settings.json` uses comments (JSONC), `init` will not rewrite it. Instead it prints the exact `context_servers` entry for you to paste. You can see that snippet any time with `hindsight-zed init --print-only`.
## Commands
| Command | What it does |
| --- | --- |
| `hindsight-zed init` | Add the MCP server and the recall/retain rule |
| `hindsight-zed status` | Show whether the server and rule are configured |
| `hindsight-zed uninstall` | Remove the server and the rule |
| `hindsight-zed init --print-only` | Print the config to add manually |
Prefix any of these with `npx ` if you did not install globally.
## Cloud or self-hosted
For **Hindsight Cloud**, pass an API key from your dashboard. The API URL defaults to `https://api.hindsight.vectorize.io`, so there is nothing else to set.
For a **self-hosted** server, point at your own base URL with `--api-url http://localhost:8888`. An open local server needs no token.
Configuration resolves from flags, environment, or `~/.hindsight/zed.json` (written by `init`):
| Setting | Env var | Default |
| --- | --- | --- |
| API URL | `HINDSIGHT_API_URL` | `https://api.hindsight.vectorize.io` |
| API token | `HINDSIGHT_API_TOKEN` | none (required for Cloud) |
| Bank id | `HINDSIGHT_ZED_BANK_ID` | `zed` |
The bank is one isolated store. Point Zed and your other tools at the same bank id and they share one memory, which is the idea behind [one memory for every AI tool](/blog/2026/04/07/one-memory-for-every-ai-tool).
## Verify it works
Give the agent a durable fact in one conversation: "This repo uses pnpm, never npm." Start a **new** conversation and ask for something related, like "add the date-fns dependency." A memory-aware agent recalls the convention and reaches for pnpm without being reminded, because the fact was retained to Hindsight and recalled against your new request.
You can watch this happen from the other side too. Open your Hindsight bank and you will see the retained convention show up as a stored memory after the first conversation.
## Frequently asked questions
**Does the agent recall automatically?**
Recall is a tool the agent calls, and the global rule tells it to call `recall` at the start of every task. So in practice it runs on its own, and because it runs at query time it uses your actual message to find relevant memory.
**What do I need installed?**
Just Node.js (version 18.3 or newer). `hindsight-zed` is a zero-dependency Node CLI, and Zed's MCP bridge (`mcp-remote`) also runs on Node via `npx`. No Python required.
**Will it overwrite my Zed config?**
No. The rule lives inside a fenced `HINDSIGHT` block in `AGENTS.md`, and `init` leaves the rest untouched. If your `settings.json` has comments, `init` prints the snippet instead of rewriting the file.
**What does `reflect` do?**
Alongside `recall` and `retain`, the agent can call `reflect` to consolidate and reason over what it has stored, so memory improves as it accumulates rather than becoming a flat pile of notes.
## Further reading
- [What is agent memory?](https://vectorize.io/what-is-agent-memory): the concepts behind recall and retention.
- [Best AI agent memory systems](https://vectorize.io/articles/best-ai-agent-memory-systems): how the major memory frameworks compare.
- [Cursor persistent memory](/blog/2026/06/12/cursor-persistent-memory): the same idea for the other AI-first editor.
- [One memory for every AI tool](/blog/2026/04/07/one-memory-for-every-ai-tool): point Zed and your other agents at the same bank.
+6
View File
@@ -2,6 +2,12 @@ hindsight:
name: Hindsight Team
url: https://github.com/vectorize-io/hindsight
garethjcooper:
name: Gareth Cooper
title: Community Contributor
url: https://github.com/garethjcooper
image_url: https://github.com/garethjcooper.png
nicoloboschi:
name: Nicolò Boschi
title: Hindsight Team
+40 -36
View File
@@ -1,12 +1,12 @@
---
sidebar_position: 38
title: "Eve Agent Memory with Hindsight | Integration"
description: "Add long-term memory to Vercel Eve agents with Hindsight. A one-line MCP connection gives your agent retain, recall, and reflect across sessions."
description: "Add automatic long-term memory to Vercel Eve agents with Hindsight. Memory is injected before each turn and retained after — no model tool-calling."
---
# Eve
Long-term memory for [Vercel Eve](https://github.com/vercel/eve) agents using [Hindsight](https://vectorize.io/hindsight). Eve is filesystem-first — an agent gains a capability by dropping a file under `agent/connections/`. The `@vectorize-io/hindsight-eve` package wraps Eve's `defineMcpClientConnection`, so one file gives your agent `retain`, `recall`, and `reflect` over Hindsight's MCP server and it remembers across sessions and deployments.
Automatic long-term memory for [Vercel Eve](https://github.com/vercel/eve) agents using [Hindsight](https://vectorize.io/hindsight). Eve is filesystem-first — an agent gains a capability by dropping a file under `agent/`. The `@vectorize-io/hindsight-eve` package wires two files that call Hindsight's REST API directly, so your agent gets memory that **just works** — relevant memory is injected before every turn and each exchange is retained after — **without the model ever choosing to call a tool.**
## Install
@@ -18,67 +18,71 @@ npm install @vectorize-io/hindsight-eve
## Quick Start
Create `agent/connections/hindsight.ts`:
Create two files:
```ts
import { defineHindsightConnection } from "@vectorize-io/hindsight-eve";
// agent/instructions/hindsight.ts — recall: inject memory before each turn
import { hindsightMemory } from "@vectorize-io/hindsight-eve";
export default defineHindsightConnection();
export default hindsightMemory();
```
The connection reads its defaults from the environment:
```ts
// agent/hooks/hindsight.ts — retain: save each exchange after the turn
import { hindsightRetainHook } from "@vectorize-io/hindsight-eve";
| Env var | Purpose |
| ----------------------- | ---------------------------------------------------------------- |
| `HINDSIGHT_API_KEY` | Bearer token sent as `Authorization: Bearer <key>` |
| `HINDSIGHT_MCP_URL` | MCP endpoint (defaults to Hindsight Cloud) |
| `HINDSIGHT_MCP_BANK_ID` | Optional bank to scope memory to, sent as the `X-Bank-Id` header |
export default hindsightRetainHook();
```
The model discovers the tools via Eve's `connection__search` and calls them as `connection__hindsight__recall`, `connection__hindsight__retain`, and `connection__hindsight__reflect`. The connection's URL and token never reach the model.
Both read their config from the environment:
| Env var | Purpose |
| ------------------- | -------------------------------------------------------------- |
| `HINDSIGHT_API_KEY` | Bearer token sent as `Authorization: Bearer <key>` |
| `HINDSIGHT_API_URL` | Hindsight REST base (defaults to Hindsight Cloud) |
| `HINDSIGHT_BANK_ID` | Bank to scope memory to (defaults to `default`; auto-created) |
### Hindsight Cloud
Set `HINDSIGHT_API_KEY` from your [Hindsight Cloud](https://hindsight.vectorize.io) dashboard. The connection defaults to `https://api.hindsight.vectorize.io/mcp`, so no URL is needed.
Set `HINDSIGHT_API_KEY` from your [Hindsight Cloud](https://hindsight.vectorize.io) dashboard. `HINDSIGHT_API_URL` defaults to `https://api.hindsight.vectorize.io`, so no URL is needed.
### Self-hosted
Point at your own server, optionally scoping to a bank. Use `apiKey: null` for a no-auth local server:
```ts
import { defineHindsightConnection } from "@vectorize-io/hindsight-eve";
import { hindsightMemory } from "@vectorize-io/hindsight-eve";
export default defineHindsightConnection({
url: "http://localhost:8000/mcp",
apiKey: null,
});
// A local server with no auth:
export default hindsightMemory({ apiUrl: "http://localhost:8000", apiKey: null });
```
## Options
Both factories accept the same options (each falls back to its env var):
```ts
defineHindsightConnection({
url, // MCP endpoint; defaults to HINDSIGHT_MCP_URL, then Cloud
hindsightMemory({
apiUrl, // REST base; defaults to HINDSIGHT_API_URL, then Cloud
apiKey, // bearer token; null = no auth (local dev)
bankId, // scope memory to a bank (X-Bank-Id header)
description, // override the model-facing description
tools, // { allow } | { block } — narrow which Hindsight tools the model sees
approval, // human-in-the-loop policy, e.g. once() from "eve/tools/approval"
bankId, // bank to scope memory to
recallQuery, // the broad query used for recall (see below)
budget, // "low" | "mid" | "high" — recall result budget (default "mid")
maxTokens, // recall token budget (default 1024)
context, // `context` tag written on retained items (default "eve")
includeAssistantReply, // also retain the assistant's reply (default true)
timeoutMs, // HTTP timeout (default 15000)
onError, // (err, phase) => void — failures degrade silently (default console.warn)
});
```
Restrict the agent to read-only recall and require approval the first time:
## Recall is profile-based, not per-message
```ts
import { defineHindsightConnection } from "@vectorize-io/hindsight-eve";
import { once } from "eve/tools/approval";
Eve's instruction resolver runs at the start of a turn and **cannot see the live user message**, so recall uses a fixed broad query (default: `"user preferences, identity, and working context"`) to surface the user's ambient profile/context each turn. This is ideal for "the agent knows you" — preferences, identity, ongoing context — and is fully deterministic. Tune it with `recallQuery`. Per-message, query-specific retrieval inherently needs a tool the model calls and is out of scope here.
export default defineHindsightConnection({
tools: { allow: ["recall", "reflect"] },
approval: once(),
});
```
## Verify
Run your agent. Tell it a durable preference in one chat ("whenever you write me code, use Python with full type hints and no comments"). Start a **fresh** chat and ask for something — the agent applies the remembered preference, because the memory was injected before the model ran, with no tool call.
## Links
- [Hindsight docs](https://hindsight.vectorize.io)
- [Eve connections](https://github.com/vercel/eve/blob/main/docs/connections.mdx)
- [Eve hooks](https://github.com/vercel/eve/blob/main/docs/guides/hooks.md) · [Eve dynamic capabilities](https://github.com/vercel/eve/blob/main/docs/guides/dynamic-capabilities.md)
+9 -1
View File
@@ -19,8 +19,16 @@ Zed doesn't yet have native HTTP-MCP transport, so the server is connected throu
## Setup
`hindsight-zed` is a zero-dependency Node CLI — Node.js is the only requirement (already needed for the `mcp-remote` bridge). Run it straight from npm with `npx`:
```bash
pip install hindsight-zed
npx @vectorize-io/hindsight-zed init --api-token YOUR_HINDSIGHT_API_KEY --bank-id my-memory
```
Or install it globally for a persistent command:
```bash
npm install -g @vectorize-io/hindsight-zed
hindsight-zed init --api-token YOUR_HINDSIGHT_API_KEY --bank-id my-memory
```
@@ -28,7 +28,7 @@ import memoriesGo from '!!raw-loader!@site/examples/api/memories.go';
## List memory units
List the memory units in a bank. The response includes each unit's `fact_type` (`world` | `experience` | `observation`), `state` (`valid` | `invalidated`), entities, occurred dates, and — for facts a user has edited — an `edited_at` timestamp. Invalidated rows are **included by default** so curation stays auditable; filter with `state=`.
List the memory units in a bank. The response includes each unit's `fact_type` (`world` | `experience` | `observation`), `state` (`valid` | `invalidated`), metadata, entities, occurred dates, and — for facts a user has edited — an `edited_at` timestamp. Invalidated rows are **included by default** so curation stays auditable; filter with `state=`.
<Tabs>
<TabItem value="python" label="Python">
@@ -47,6 +47,8 @@ List the memory units in a bank. The response includes each unit's `fact_type` (
## Fetch a single memory unit
Fetch a memory unit by ID, including its content, metadata, entities, timestamps, tags, and curation state.
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoriesPy} section="get-memory" language="python" />
@@ -29,6 +29,8 @@ The API service handles all memory operations (retain, recall, reflect).
If not provided, the server uses embedded `pg0` — convenient for development but not recommended for production.
To run against Oracle Database 23ai instead, set `HINDSIGHT_API_DATABASE_BACKEND=oracle` and use an `oracle+oracledb://…` URL. See the [Oracle Database guide](./oracle) for full setup instructions.
The `DATABASE_SCHEMA` setting allows you to use a custom PostgreSQL schema instead of the default `public` schema. This is useful for:
- Multi-database setups where you want Hindsight tables in a dedicated schema
- Hosting platforms (e.g., Supabase) where `public` schema is reserved or shared
@@ -145,6 +147,7 @@ If you need to switch from one extension to another:
| `HINDSIGHT_API_TEXT_SEARCH_EXTENSION` | Text search backend: `native`, `vchord`, `pg_textsearch`, `pgroonga`, or `pg_search` | `native` |
| `HINDSIGHT_API_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE` | PostgreSQL text search dictionary used by the `native` backend (e.g. `english`, `french`, `simple`, `zhparser`) | `english` |
| `HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER` | ParadeDB `pg_search` tokenizer used when creating BM25 indexes. Empty uses ParadeDB's default tokenizer (`unicode_words`). | unset |
| `HINDSIGHT_API_BM25_MAX_QUERY_TERMS` | 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` keeps the historical uncapped behavior; a positive value bounds only the `native` backend (other BM25 backends receive the raw query). | `0` |
| `HINDSIGHT_API_LLM_OUTPUT_LANGUAGE` | When set, forces every LLM-generated artifact (retain facts, consolidation observations, reflect responses) into this language. Free-form (e.g. `Spanish`, `Japanese`). | unset |
Hindsight supports five backends for BM25 keyword retrieval:
+267
View File
@@ -0,0 +1,267 @@
# Oracle Database
Hindsight uses PostgreSQL as its default storage backend, but it also runs on
**Oracle Database 23ai** for organizations that standardize on Oracle
infrastructure. All memory operations — retain, recall, and reflect — work the
same way on Oracle; the backend is selected with a single environment variable.
This guide covers everything needed to run Hindsight against Oracle: the
prerequisites, the driver, a local quick start, provisioning a production
database, running migrations, and the handful of behavioural differences from
PostgreSQL.
:::info When to use Oracle
Oracle is the right choice when your organization already runs Oracle and needs
Hindsight to live inside that footprint. For everything else, the default
PostgreSQL backend is simpler to operate — see [Storage](./storage) for the
rationale. Oracle and PostgreSQL are configured independently; you pick one per
deployment.
:::
## Requirements
| Requirement | Details |
|-------------|---------|
| Oracle Database | **23ai** (23.4+). [Oracle Database Free 23ai](https://www.oracle.com/database/free/) works for development. |
| `VECTOR` type | Used for embeddings. Requires the schema to live in an **ASSM tablespace** (see below). |
| Oracle Text | Full-text search uses Oracle Text indexes. The schema user needs the `CTXAPP` role. |
| Driver | [`python-oracledb`](https://python-oracledb.readthedocs.io/) ≥ 2.5.0, running in **thin mode** — pure Python, no Oracle Instant Client required. |
:::warning The schema must use an ASSM tablespace
Oracle's `SYSTEM` tablespace uses *manual* segment space management (MSSM),
which **does not support `VECTOR` columns**. Create the Hindsight user in a
tablespace with **Automatic Segment Space Management (ASSM)** — otherwise
migrations fail when they create embedding columns. The provisioning SQL below
does this for you.
:::
## Install the driver
The Oracle driver is an optional extra — it is not bundled with the default
packages. Install it alongside Hindsight:
```bash
# With the packaged extra
pip install "hindsight-api-slim[oracle]"
# Or add the driver to an existing install (e.g. the full hindsight-api package)
pip install hindsight-api oracledb
```
If the driver is missing at startup, Hindsight fails with:
`python-oracledb is required for Oracle backend. Install it with: pip install oracledb`.
## Quick start (local Oracle)
The fastest way to try Hindsight on Oracle is the bundled helper script, which
starts a local **Oracle Database Free 23ai** container, provisions the test
user with the correct tablespace and grants, and prints a ready-to-use
connection URL:
```bash
# Start Oracle Free in Docker and bootstrap the hindsight_test user
./scripts/dev/start-oracle.sh
# ...prints:
# export HINDSIGHT_API_DATABASE_BACKEND=oracle
# export HINDSIGHT_API_DATABASE_URL='oracle+oracledb://hindsight_test:hindsight_test@localhost:1521/FREEPDB1'
# Stop and remove the container when done
./scripts/dev/stop-oracle.sh
```
A cold start takes 60120s while the database initializes. If the first run
reports a provisioning error, the database was still starting up — just re-run
`./scripts/dev/start-oracle.sh` once the container is healthy (it is idempotent).
Once the script prints the connection URL, export the variables it shows, and
**also set the schema** to the Oracle user it created:
```bash
export HINDSIGHT_API_DATABASE_SCHEMA=HINDSIGHT_TEST
```
Then run migrations and start the API (see the steps below). Setting the schema
is required on Oracle — see [step 3](#3-configure-hindsight). This is the same
setup Hindsight's CI uses to test the Oracle backend.
## Production setup
### 1. Provision the schema user
Connect to your pluggable database as a privileged user (for example `SYSTEM`)
and create a dedicated tablespace and user for Hindsight. The tablespace **must**
use ASSM so `VECTOR` columns are supported:
```sql
-- ASSM tablespace (required for VECTOR columns). Size to your data volume.
CREATE BIGFILE TABLESPACE hindsight_ts
DATAFILE 'hindsight_ts.dbf' SIZE 2G AUTOEXTEND ON NEXT 500M MAXSIZE UNLIMITED
EXTENT MANAGEMENT LOCAL
SEGMENT SPACE MANAGEMENT AUTO;
-- Dedicated schema user
CREATE USER hindsight IDENTIFIED BY "<strong-password>"
DEFAULT TABLESPACE hindsight_ts
TEMPORARY TABLESPACE temp
QUOTA UNLIMITED ON hindsight_ts;
-- Object privileges Hindsight's migrations need
GRANT CONNECT, RESOURCE, CREATE TABLE, CREATE SEQUENCE, CREATE VIEW, CREATE PROCEDURE TO hindsight;
-- Oracle Text (full-text search indexes)
GRANT CTXAPP TO hindsight;
```
:::note Least privilege
`CONNECT` and `RESOURCE` cover the basics; the explicit `CREATE TABLE / SEQUENCE
/ VIEW / PROCEDURE` grants and `CTXAPP` are what the schema migrations require.
No `DBA` role is needed. On a managed service where `CREATE TABLESPACE` is not
available directly, provision the schema through the platform's admin tooling —
the requirements are unchanged: an **ASSM** default tablespace (needed for
`VECTOR` columns) plus the `CTXAPP` role.
:::
### 2. Build the connection URL
Hindsight uses SQLAlchemy-style URLs. The Oracle form is:
```
oracle+oracledb://USER:PASSWORD@HOST:PORT/SERVICE_NAME
```
| Part | Example | Notes |
|------|---------|-------|
| `USER` / `PASSWORD` | `hindsight` / `s3cret` | The schema user from step 1. URL-encode reserved characters (`@`, `/`, `:`) in the password. |
| `HOST:PORT` | `db.internal:1521` | The listener host and port (Oracle default is `1521`). |
| `SERVICE_NAME` | `FREEPDB1` | The **service name** of your pluggable database (not the SID). `FREEPDB1` for Oracle Free. |
Example:
```
oracle+oracledb://hindsight:s3cret@db.internal:1521/ORCLPDB1
```
:::warning Connection support: Easy Connect only
Hindsight builds the Oracle connection from the URL as a plain
`host:port/service_name` descriptor. **Wallet-based mTLS, TLS/TCPS, and TNS
aliases or full connect descriptors are not currently supported** by the
connection layer. In practice:
- **Oracle Autonomous Database** and other services that require a wallet /
mTLS are not supported as-is — connect to a database reachable over a direct
`host:port/service` listener.
- The driver does not negotiate TLS itself, so secure the connection at the
network layer (private networking, VPN, or a TLS-terminating proxy).
:::
### 3. Configure Hindsight
Point Hindsight at Oracle with two environment variables:
```bash
export HINDSIGHT_API_DATABASE_BACKEND=oracle
export HINDSIGHT_API_DATABASE_URL='oracle+oracledb://hindsight:s3cret@db.internal:1521/ORCLPDB1'
export HINDSIGHT_API_DATABASE_SCHEMA=HINDSIGHT # the Oracle user from step 1
```
`HINDSIGHT_API_DATABASE_BACKEND` defaults to `postgresql`; set it to `oracle` to
select the Oracle backend.
:::warning Set `DATABASE_SCHEMA` to your Oracle user
`HINDSIGHT_API_DATABASE_SCHEMA` defaults to `public` — a PostgreSQL concept. On
Oracle a schema **is a user**, there is no `public` schema, and leaving the
default makes migrations fail with `ORA-01435: user does not exist`. Set it to
the schema user you created in step 1, spelled exactly as Oracle stores it —
**uppercase** (e.g. `HINDSIGHT`) unless you created the user with a quoted
lower-case name.
:::
See [Configuration → Database](./configuration#database) for the full list of
database variables.
### 4. Run migrations
Hindsight runs the same schema migrations on Oracle as on PostgreSQL. By default
the API applies them automatically on startup
(`HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP=true`). To run them explicitly — for
example in a controlled deploy step — use:
```bash
hindsight-admin run-db-migration
```
This routes through the dialect-aware migration runner and creates the Oracle
schema. (Unlike the admin CLI's data-movement commands, `run-db-migration`
is fully supported on Oracle — see [Limitations](#limitations-vs-postgresql).)
:::warning Migrate with your runtime embedding dimension
The embedding `VECTOR` columns are sized to the dimension of the configured
embeddings model. Run migrations with the **same embeddings provider/model you
will serve with** — otherwise the column dimension won't match the vectors the
API produces and retain fails with `ORA-51803: Vector dimension count must
match…` (for example, a schema built for a 384-dim local model rejects the
1536-dim vectors from OpenAI `text-embedding-3-small`). If you change the
embeddings model later, re-run migrations with `--embedding-dimension <N>` to
resize the columns.
:::
### 5. Start the API
```bash
hindsight-api
```
On startup Hindsight logs the resolved database (with credentials masked); it
should show your Oracle host and confirm the Oracle backend is active.
## Configuration reference
Oracle-relevant settings, all documented in full on the
[Configuration](./configuration) page:
| Variable | Purpose |
|----------|---------|
| `HINDSIGHT_API_DATABASE_BACKEND` | `postgresql` (default) or `oracle`. |
| `HINDSIGHT_API_DATABASE_URL` | `oracle+oracledb://…` connection URL. |
| `HINDSIGHT_API_DATABASE_SCHEMA` | Schema/user for the tables. On Oracle set this to your schema user (uppercase); the `public` default fails. |
| `HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP` | Auto-apply migrations when the API boots (default `true`). |
## Limitations vs PostgreSQL
Memory operations behave identically on Oracle, but a few operational and
internal details differ:
- **Admin CLI data commands are PostgreSQL-only.** `hindsight-admin` backup,
restore, bank export/import, and worker-status use asyncpg binary `COPY` and
`TRUNCATE`, which are PostgreSQL-specific and not available on Oracle.
Schema migrations (`run-db-migration`) *are* supported on Oracle.
- **No embedded database.** The `pg0` embedded PostgreSQL used for zero-config
local development has no Oracle equivalent — Oracle always requires a running
instance (use the [quick-start script](#quick-start-local-oracle) locally).
- **Consolidation reconciliation is skipped.** The similarity-based
near-duplicate reconciliation pass in consolidation
(`HINDSIGHT_API_CONSOLIDATION_DEDUP_THRESHOLD`) is a PostgreSQL-only path;
consolidation still runs on Oracle, without that extra reconciliation step.
- **Entity resolution uses Oracle fuzzy matching.** Fuzzy entity lookup during
retain uses Oracle's text matching rather than PostgreSQL's `pg_trgm` trigram
matching. Behaviour is equivalent; the underlying mechanism differs.
## Troubleshooting
| Symptom | Cause / Fix |
|---------|-------------|
| `python-oracledb is required for Oracle backend` | The driver isn't installed. Run `pip install oracledb` (or install the `[oracle]` extra). |
| `ORA-01435: user does not exist` on migration | `HINDSIGHT_API_DATABASE_SCHEMA` is unset (defaults to `public`) or misspelled. Set it to your Oracle schema user, uppercase (e.g. `HINDSIGHT`). |
| `ORA-51803: Vector dimension count must match` on retain | The schema was migrated with a different embedding dimension than the running embeddings model. Migrate with the same embeddings config, or re-run `run-db-migration --embedding-dimension <N>`. |
| Migration errors when creating embedding/`VECTOR` columns | The schema user's default tablespace is not ASSM (often the `SYSTEM` tablespace). Recreate the user in an ASSM tablespace as shown above. |
| Full-text search errors / missing Oracle Text index | The schema user is missing the `CTXAPP` role. Run `GRANT CTXAPP TO <user>;`. |
| `ORA-12514` / service not found | The URL uses a SID or wrong service name. Use the pluggable database **service name** (e.g. `FREEPDB1`), not the SID. |
| Login works manually but fails from Hindsight | A reserved character in the password isn't URL-encoded. Encode `@ / : ?` in the `DATABASE_URL`. |
## See also
- [Storage](./storage) — why PostgreSQL is the default, and how Oracle fits in
- [Configuration](./configuration#database) — all database environment variables
- [Installation](./installation) — packaging and deployment options
- [Admin CLI](./admin-cli) — administrative commands (PostgreSQL-only data operations)
+2
View File
@@ -42,6 +42,8 @@ By building on PostgreSQL, we keep the system simple:
For enterprise deployments, Hindsight also supports Oracle AI Database with full feature parity. All memory operations—retain, recall, and reflect—work identically on Oracle, making it a drop-in option for organizations that standardize on Oracle infrastructure.
See the [Oracle Database guide](./oracle) for setup: prerequisites, provisioning, connection URLs, migrations, and the differences from PostgreSQL.
## Development with pg0
For local development, Hindsight uses **[pg0](https://github.com/vectorize-io/pg0)**—an embedded PostgreSQL distribution.
+1 -1
View File
@@ -68,7 +68,7 @@ func main() {
if memoryID != "" {
// [docs:get-memory]
// Fetch a single memory unit (entities, dates, state).
// Fetch a single memory unit (metadata, entities, dates, state).
memory, _, _ := client.MemoryAPI.GetMemory(ctx, memBankID, memoryID).Execute()
fmt.Printf("Memory: %v\n", memory)
// [/docs:get-memory]
+1 -1
View File
@@ -51,7 +51,7 @@ if (!fact) {
const memoryId = fact.id;
// [docs:get-memory]
// Fetch a single memory unit (entities, dates, state).
// Fetch a single memory unit (metadata, entities, dates, state).
const memory = await (
await fetch(`${HINDSIGHT_URL}/v1/default/banks/${BANK_ID}/memories/${memoryId}`)
).json();
+1 -1
View File
@@ -84,7 +84,7 @@ async def main():
memory_id = fact["id"]
# [docs:get-memory]
# Fetch a single memory unit (includes entities, dates, and state).
# Fetch a single memory unit (includes metadata, entities, dates, and state).
memory = await client.memory.get_memory(bank_id=BANK_ID, memory_id=memory_id)
print(f"Text: {memory['text']}")
+1 -1
View File
@@ -32,7 +32,7 @@ MEMORY_ID=$(hindsight memory list "$BANK_ID" -o json | python3 -c "import sys,js
if [ -n "$MEMORY_ID" ]; then
# [docs:get-memory]
# Fetch a single memory unit (entities, dates, state)
# Fetch a single memory unit (metadata, entities, dates, state)
curl -s "$HINDSIGHT_URL/v1/default/banks/$BANK_ID/memories/$MEMORY_ID"
# [/docs:get-memory]
+6
View File
@@ -232,6 +232,12 @@ const sidebars: SidebarsConfig = {
label: 'Admin CLI',
customProps: { icon: 'lu-terminal' },
},
{
type: 'doc',
id: 'developer/oracle',
label: 'Oracle Database',
customProps: { icon: 'lu-database' },
},
{
type: 'doc',
id: 'developer/extensions',
+1 -1
View File
@@ -373,7 +373,7 @@
{
"id": "eve",
"name": "Eve",
"description": "Long-term memory for Vercel Eve agents. A one-line MCP connection exposing retain, recall, and reflect.",
"description": "Automatic long-term memory for Vercel Eve agents. Memory is injected before each turn and retained after, with no model tool-calling.",
"type": "official",
"by": "hindsight",
"category": "framework",
@@ -8,6 +8,13 @@ import PageHero from '@site/src/components/PageHero';
[← Claude Code integration](/sdks/integrations/claude-code)
## [0.7.3](https://github.com/vectorize-io/hindsight/tree/integrations/claude-code/v0.7.3)
**Features**
- Add recall score floors so memory recall can enforce minimum relevance thresholds.<span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/ishanmalik" target="_blank" rel="noopener noreferrer" style={{color: "var(--ifm-color-primary)", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: "4px", verticalAlign: "middle"}}><img src="https://github.com/ishanmalik.png?size=40" alt="@ishanmalik" width="18" height="18" style={{borderRadius: "50%"}} />@ishanmalik</a><span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/vectorize-io/hindsight/commit/e93c56028" target="_blank" rel="noopener noreferrer" style={{fontFamily: "var(--ifm-font-family-monospace, monospace)", fontSize: "0.85em", color: "var(--ifm-color-emphasis-600)"}}>e93c56028</a>
- Add recall tag filters to the Claude Code memory hook to restrict recall to specific memory tags.<span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/koriyoshi2041" target="_blank" rel="noopener noreferrer" style={{color: "var(--ifm-color-primary)", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: "4px", verticalAlign: "middle"}}><img src="https://github.com/koriyoshi2041.png?size=40" alt="@koriyoshi2041" width="18" height="18" style={{borderRadius: "50%"}} />@koriyoshi2041</a><span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/vectorize-io/hindsight/commit/962140eef" target="_blank" rel="noopener noreferrer" style={{fontFamily: "var(--ifm-font-family-monospace, monospace)", fontSize: "0.85em", color: "var(--ifm-color-emphasis-600)"}}>962140eef</a>
## [0.7.2](https://github.com/vectorize-io/hindsight/tree/integrations/claude-code/v0.7.2)
**Bug Fixes**
@@ -8,6 +8,16 @@ import PageHero from '@site/src/components/PageHero';
[← Codex CLI integration](https://github.com/vectorize-io/hindsight/tree/main/hindsight-integrations/codex)
## [0.3.1](https://github.com/vectorize-io/hindsight/tree/integrations/codex/v0.3.1)
**Features**
- Add configurable recall score floors to control which memories qualify for recall.<span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/ishanmalik" target="_blank" rel="noopener noreferrer" style={{color: "var(--ifm-color-primary)", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: "4px", verticalAlign: "middle"}}><img src="https://github.com/ishanmalik.png?size=40" alt="@ishanmalik" width="18" height="18" style={{borderRadius: "50%"}} />@ishanmalik</a><span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/vectorize-io/hindsight/commit/e93c56028" target="_blank" rel="noopener noreferrer" style={{fontFamily: "var(--ifm-font-family-monospace, monospace)", fontSize: "0.85em", color: "var(--ifm-color-emphasis-600)"}}>e93c56028</a>
**Bug Fixes**
- Increase the daemon health-check timeout to prevent repeated kill/restart loops under slow conditions.<span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/21Felix04" target="_blank" rel="noopener noreferrer" style={{color: "var(--ifm-color-primary)", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: "4px", verticalAlign: "middle"}}><img src="https://github.com/21Felix04.png?size=40" alt="@21Felix04" width="18" height="18" style={{borderRadius: "50%"}} />@21Felix04</a><span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/vectorize-io/hindsight/commit/c5a61db2b" target="_blank" rel="noopener noreferrer" style={{fontFamily: "var(--ifm-font-family-monospace, monospace)", fontSize: "0.85em", color: "var(--ifm-color-emphasis-600)"}}>c5a61db2b</a>
## [0.3.0](https://github.com/vectorize-io/hindsight/tree/integrations/codex/v0.3.0)
**Improvements**
@@ -10,6 +10,18 @@ For the source code, see [`hindsight-integrations/eve`](https://github.com/vecto
← [Back to main changelog](/changelog)
## [0.2.1](https://github.com/vectorize-io/hindsight/tree/integrations/eve/v0.2.1)
**Features**
- Eve integration now retains the assistants reply in memory by default.<span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/benfrank241" target="_blank" rel="noopener noreferrer" style={{color: "var(--ifm-color-primary)", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: "4px", verticalAlign: "middle"}}><img src="https://github.com/benfrank241.png?size=40" alt="@benfrank241" width="18" height="18" style={{borderRadius: "50%"}} />@benfrank241</a><span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/vectorize-io/hindsight/commit/29cc1d7fd" target="_blank" rel="noopener noreferrer" style={{fontFamily: "var(--ifm-font-family-monospace, monospace)", fontSize: "0.85em", color: "var(--ifm-color-emphasis-600)"}}>29cc1d7fd</a>
## [0.2.0](https://github.com/vectorize-io/hindsight/tree/integrations/eve/v0.2.0)
**Features**
- Added an auto-memory mode that works without model tool-calling, enabling Eve to capture memories automatically.<span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/benfrank241" target="_blank" rel="noopener noreferrer" style={{color: "var(--ifm-color-primary)", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: "4px", verticalAlign: "middle"}}><img src="https://github.com/benfrank241.png?size=40" alt="@benfrank241" width="18" height="18" style={{borderRadius: "50%"}} />@benfrank241</a><span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/vectorize-io/hindsight/commit/dd7e25245" target="_blank" rel="noopener noreferrer" style={{fontFamily: "var(--ifm-font-family-monospace, monospace)", fontSize: "0.85em", color: "var(--ifm-color-emphasis-600)"}}>dd7e25245</a>
## [0.1.0](https://github.com/vectorize-io/hindsight/tree/integrations/eve/v0.1.0)
**Features**
@@ -10,6 +10,17 @@ For the source code, see [`hindsight-integrations/opencode`](https://github.com/
← [Back to main changelog](/changelog)
## [0.2.7](https://github.com/vectorize-io/hindsight/tree/integrations/opencode/v0.2.7)
**Features**
- Adds environment variable overrides for retain/recall behavior in the OpenCode integration.<span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/ibousfiha" target="_blank" rel="noopener noreferrer" style={{color: "var(--ifm-color-primary)", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: "4px", verticalAlign: "middle"}}><img src="https://github.com/ibousfiha.png?size=40" alt="@ibousfiha" width="18" height="18" style={{borderRadius: "50%"}} />@ibousfiha</a><span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/vectorize-io/hindsight/commit/cc45e1690" target="_blank" rel="noopener noreferrer" style={{fontFamily: "var(--ifm-font-family-monospace, monospace)", fontSize: "0.85em", color: "var(--ifm-color-emphasis-600)"}}>cc45e1690</a>
- Adds support for configuring retain tags via the HINDSIGHT_RETAIN_TAGS environment variable.<span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/mdbenito" target="_blank" rel="noopener noreferrer" style={{color: "var(--ifm-color-primary)", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: "4px", verticalAlign: "middle"}}><img src="https://github.com/mdbenito.png?size=40" alt="@mdbenito" width="18" height="18" style={{borderRadius: "50%"}} />@mdbenito</a><span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/vectorize-io/hindsight/commit/20da6d760" target="_blank" rel="noopener noreferrer" style={{fontFamily: "var(--ifm-font-family-monospace, monospace)", fontSize: "0.85em", color: "var(--ifm-color-emphasis-600)"}}>20da6d760</a>
**Bug Fixes**
- Fixes plugin SDK availability by installing it at runtime so the OpenCode integration works reliably in more environments.<span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/Sanderhoff-alt" target="_blank" rel="noopener noreferrer" style={{color: "var(--ifm-color-primary)", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: "4px", verticalAlign: "middle"}}><img src="https://github.com/Sanderhoff-alt.png?size=40" alt="@Sanderhoff-alt" width="18" height="18" style={{borderRadius: "50%"}} />@Sanderhoff-alt</a><span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/vectorize-io/hindsight/commit/ae7099fd0" target="_blank" rel="noopener noreferrer" style={{fontFamily: "var(--ifm-font-family-monospace, monospace)", fontSize: "0.85em", color: "var(--ifm-color-emphasis-600)"}}>ae7099fd0</a>
## [0.2.6](https://github.com/vectorize-io/hindsight/tree/integrations/opencode/v0.2.6)
**Features**
@@ -10,6 +10,12 @@ For the source code, see [`hindsight-integrations/zed`](https://github.com/vecto
← [Back to main changelog](/changelog)
## [0.2.0](https://github.com/vectorize-io/hindsight/tree/integrations/zed/v0.2.0)
**Breaking Changes**
- Switched the Zed integration setup CLI from Python to Node.js, removing the Python dependency (installation/setup flow changes).<span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/benfrank241" target="_blank" rel="noopener noreferrer" style={{color: "var(--ifm-color-primary)", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: "4px", verticalAlign: "middle"}}><img src="https://github.com/benfrank241.png?size=40" alt="@benfrank241" width="18" height="18" style={{borderRadius: "50%"}} />@benfrank241</a><span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/vectorize-io/hindsight/commit/9dee1c594" target="_blank" rel="noopener noreferrer" style={{fontFamily: "var(--ifm-font-family-monospace, monospace)", fontSize: "0.85em", color: "var(--ifm-color-emphasis-600)"}}>9dee1c594</a>
## [0.1.0](https://github.com/vectorize-io/hindsight/tree/integrations/zed/v0.1.0)
**Features**
@@ -39,7 +39,7 @@
}
],
"default": null,
"description": "Fact extraction mode: 'concise' (default), 'verbose', or 'custom'",
"description": "Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'",
"title": "Retain Extraction Mode"
},
"retain_custom_instructions": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 612 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+6 -2
View File
@@ -6592,7 +6592,7 @@
}
],
"title": "Retain Extraction Mode",
"description": "Fact extraction mode: 'concise' (default), 'verbose', or 'custom'"
"description": "Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'"
},
"retain_custom_instructions": {
"anyOf": [
@@ -7631,7 +7631,7 @@
}
],
"title": "Retain Extraction Mode",
"description": "Fact extraction mode: 'concise' (default), 'verbose', or 'custom'."
"description": "Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'."
},
"retain_custom_instructions": {
"anyOf": [
@@ -9551,6 +9551,10 @@
"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"
}
@@ -28,7 +28,7 @@ import memoriesGo from '!!raw-loader!@site/examples/api/memories.go';
## List memory units
List the memory units in a bank. The response includes each unit's `fact_type` (`world` | `experience` | `observation`), `state` (`valid` | `invalidated`), entities, occurred dates, and — for facts a user has edited — an `edited_at` timestamp. Invalidated rows are **included by default** so curation stays auditable; filter with `state=`.
List the memory units in a bank. The response includes each unit's `fact_type` (`world` | `experience` | `observation`), `state` (`valid` | `invalidated`), metadata, entities, occurred dates, and — for facts a user has edited — an `edited_at` timestamp. Invalidated rows are **included by default** so curation stays auditable; filter with `state=`.
<Tabs>
<TabItem value="python" label="Python">
@@ -47,6 +47,8 @@ List the memory units in a bank. The response includes each unit's `fact_type` (
## Fetch a single memory unit
Fetch a memory unit by ID, including its content, metadata, entities, timestamps, tags, and curation state.
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoriesPy} section="get-memory" language="python" />
@@ -29,6 +29,8 @@ The API service handles all memory operations (retain, recall, reflect).
If not provided, the server uses embedded `pg0` — convenient for development but not recommended for production.
To run against Oracle Database 23ai instead, set `HINDSIGHT_API_DATABASE_BACKEND=oracle` and use an `oracle+oracledb://…` URL. See the [Oracle Database guide](./oracle) for full setup instructions.
The `DATABASE_SCHEMA` setting allows you to use a custom PostgreSQL schema instead of the default `public` schema. This is useful for:
- Multi-database setups where you want Hindsight tables in a dedicated schema
- Hosting platforms (e.g., Supabase) where `public` schema is reserved or shared
@@ -0,0 +1,267 @@
# Oracle Database
Hindsight uses PostgreSQL as its default storage backend, but it also runs on
**Oracle Database 23ai** for organizations that standardize on Oracle
infrastructure. All memory operations — retain, recall, and reflect — work the
same way on Oracle; the backend is selected with a single environment variable.
This guide covers everything needed to run Hindsight against Oracle: the
prerequisites, the driver, a local quick start, provisioning a production
database, running migrations, and the handful of behavioural differences from
PostgreSQL.
:::info When to use Oracle
Oracle is the right choice when your organization already runs Oracle and needs
Hindsight to live inside that footprint. For everything else, the default
PostgreSQL backend is simpler to operate — see [Storage](./storage) for the
rationale. Oracle and PostgreSQL are configured independently; you pick one per
deployment.
:::
## Requirements
| Requirement | Details |
|-------------|---------|
| Oracle Database | **23ai** (23.4+). [Oracle Database Free 23ai](https://www.oracle.com/database/free/) works for development. |
| `VECTOR` type | Used for embeddings. Requires the schema to live in an **ASSM tablespace** (see below). |
| Oracle Text | Full-text search uses Oracle Text indexes. The schema user needs the `CTXAPP` role. |
| Driver | [`python-oracledb`](https://python-oracledb.readthedocs.io/) ≥ 2.5.0, running in **thin mode** — pure Python, no Oracle Instant Client required. |
:::warning The schema must use an ASSM tablespace
Oracle's `SYSTEM` tablespace uses *manual* segment space management (MSSM),
which **does not support `VECTOR` columns**. Create the Hindsight user in a
tablespace with **Automatic Segment Space Management (ASSM)** — otherwise
migrations fail when they create embedding columns. The provisioning SQL below
does this for you.
:::
## Install the driver
The Oracle driver is an optional extra — it is not bundled with the default
packages. Install it alongside Hindsight:
```bash
# With the packaged extra
pip install "hindsight-api-slim[oracle]"
# Or add the driver to an existing install (e.g. the full hindsight-api package)
pip install hindsight-api oracledb
```
If the driver is missing at startup, Hindsight fails with:
`python-oracledb is required for Oracle backend. Install it with: pip install oracledb`.
## Quick start (local Oracle)
The fastest way to try Hindsight on Oracle is the bundled helper script, which
starts a local **Oracle Database Free 23ai** container, provisions the test
user with the correct tablespace and grants, and prints a ready-to-use
connection URL:
```bash
# Start Oracle Free in Docker and bootstrap the hindsight_test user
./scripts/dev/start-oracle.sh
# ...prints:
# export HINDSIGHT_API_DATABASE_BACKEND=oracle
# export HINDSIGHT_API_DATABASE_URL='oracle+oracledb://hindsight_test:hindsight_test@localhost:1521/FREEPDB1'
# Stop and remove the container when done
./scripts/dev/stop-oracle.sh
```
A cold start takes 60120s while the database initializes. If the first run
reports a provisioning error, the database was still starting up — just re-run
`./scripts/dev/start-oracle.sh` once the container is healthy (it is idempotent).
Once the script prints the connection URL, export the variables it shows, and
**also set the schema** to the Oracle user it created:
```bash
export HINDSIGHT_API_DATABASE_SCHEMA=HINDSIGHT_TEST
```
Then run migrations and start the API (see the steps below). Setting the schema
is required on Oracle — see [step 3](#3-configure-hindsight). This is the same
setup Hindsight's CI uses to test the Oracle backend.
## Production setup
### 1. Provision the schema user
Connect to your pluggable database as a privileged user (for example `SYSTEM`)
and create a dedicated tablespace and user for Hindsight. The tablespace **must**
use ASSM so `VECTOR` columns are supported:
```sql
-- ASSM tablespace (required for VECTOR columns). Size to your data volume.
CREATE BIGFILE TABLESPACE hindsight_ts
DATAFILE 'hindsight_ts.dbf' SIZE 2G AUTOEXTEND ON NEXT 500M MAXSIZE UNLIMITED
EXTENT MANAGEMENT LOCAL
SEGMENT SPACE MANAGEMENT AUTO;
-- Dedicated schema user
CREATE USER hindsight IDENTIFIED BY "<strong-password>"
DEFAULT TABLESPACE hindsight_ts
TEMPORARY TABLESPACE temp
QUOTA UNLIMITED ON hindsight_ts;
-- Object privileges Hindsight's migrations need
GRANT CONNECT, RESOURCE, CREATE TABLE, CREATE SEQUENCE, CREATE VIEW, CREATE PROCEDURE TO hindsight;
-- Oracle Text (full-text search indexes)
GRANT CTXAPP TO hindsight;
```
:::note Least privilege
`CONNECT` and `RESOURCE` cover the basics; the explicit `CREATE TABLE / SEQUENCE
/ VIEW / PROCEDURE` grants and `CTXAPP` are what the schema migrations require.
No `DBA` role is needed. On a managed service where `CREATE TABLESPACE` is not
available directly, provision the schema through the platform's admin tooling —
the requirements are unchanged: an **ASSM** default tablespace (needed for
`VECTOR` columns) plus the `CTXAPP` role.
:::
### 2. Build the connection URL
Hindsight uses SQLAlchemy-style URLs. The Oracle form is:
```
oracle+oracledb://USER:PASSWORD@HOST:PORT/SERVICE_NAME
```
| Part | Example | Notes |
|------|---------|-------|
| `USER` / `PASSWORD` | `hindsight` / `s3cret` | The schema user from step 1. URL-encode reserved characters (`@`, `/`, `:`) in the password. |
| `HOST:PORT` | `db.internal:1521` | The listener host and port (Oracle default is `1521`). |
| `SERVICE_NAME` | `FREEPDB1` | The **service name** of your pluggable database (not the SID). `FREEPDB1` for Oracle Free. |
Example:
```
oracle+oracledb://hindsight:s3cret@db.internal:1521/ORCLPDB1
```
:::warning Connection support: Easy Connect only
Hindsight builds the Oracle connection from the URL as a plain
`host:port/service_name` descriptor. **Wallet-based mTLS, TLS/TCPS, and TNS
aliases or full connect descriptors are not currently supported** by the
connection layer. In practice:
- **Oracle Autonomous Database** and other services that require a wallet /
mTLS are not supported as-is — connect to a database reachable over a direct
`host:port/service` listener.
- The driver does not negotiate TLS itself, so secure the connection at the
network layer (private networking, VPN, or a TLS-terminating proxy).
:::
### 3. Configure Hindsight
Point Hindsight at Oracle with two environment variables:
```bash
export HINDSIGHT_API_DATABASE_BACKEND=oracle
export HINDSIGHT_API_DATABASE_URL='oracle+oracledb://hindsight:s3cret@db.internal:1521/ORCLPDB1'
export HINDSIGHT_API_DATABASE_SCHEMA=HINDSIGHT # the Oracle user from step 1
```
`HINDSIGHT_API_DATABASE_BACKEND` defaults to `postgresql`; set it to `oracle` to
select the Oracle backend.
:::warning Set `DATABASE_SCHEMA` to your Oracle user
`HINDSIGHT_API_DATABASE_SCHEMA` defaults to `public` — a PostgreSQL concept. On
Oracle a schema **is a user**, there is no `public` schema, and leaving the
default makes migrations fail with `ORA-01435: user does not exist`. Set it to
the schema user you created in step 1, spelled exactly as Oracle stores it —
**uppercase** (e.g. `HINDSIGHT`) unless you created the user with a quoted
lower-case name.
:::
See [Configuration → Database](./configuration#database) for the full list of
database variables.
### 4. Run migrations
Hindsight runs the same schema migrations on Oracle as on PostgreSQL. By default
the API applies them automatically on startup
(`HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP=true`). To run them explicitly — for
example in a controlled deploy step — use:
```bash
hindsight-admin run-db-migration
```
This routes through the dialect-aware migration runner and creates the Oracle
schema. (Unlike the admin CLI's data-movement commands, `run-db-migration`
is fully supported on Oracle — see [Limitations](#limitations-vs-postgresql).)
:::warning Migrate with your runtime embedding dimension
The embedding `VECTOR` columns are sized to the dimension of the configured
embeddings model. Run migrations with the **same embeddings provider/model you
will serve with** — otherwise the column dimension won't match the vectors the
API produces and retain fails with `ORA-51803: Vector dimension count must
match…` (for example, a schema built for a 384-dim local model rejects the
1536-dim vectors from OpenAI `text-embedding-3-small`). If you change the
embeddings model later, re-run migrations with `--embedding-dimension <N>` to
resize the columns.
:::
### 5. Start the API
```bash
hindsight-api
```
On startup Hindsight logs the resolved database (with credentials masked); it
should show your Oracle host and confirm the Oracle backend is active.
## Configuration reference
Oracle-relevant settings, all documented in full on the
[Configuration](./configuration) page:
| Variable | Purpose |
|----------|---------|
| `HINDSIGHT_API_DATABASE_BACKEND` | `postgresql` (default) or `oracle`. |
| `HINDSIGHT_API_DATABASE_URL` | `oracle+oracledb://…` connection URL. |
| `HINDSIGHT_API_DATABASE_SCHEMA` | Schema/user for the tables. On Oracle set this to your schema user (uppercase); the `public` default fails. |
| `HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP` | Auto-apply migrations when the API boots (default `true`). |
## Limitations vs PostgreSQL
Memory operations behave identically on Oracle, but a few operational and
internal details differ:
- **Admin CLI data commands are PostgreSQL-only.** `hindsight-admin` backup,
restore, bank export/import, and worker-status use asyncpg binary `COPY` and
`TRUNCATE`, which are PostgreSQL-specific and not available on Oracle.
Schema migrations (`run-db-migration`) *are* supported on Oracle.
- **No embedded database.** The `pg0` embedded PostgreSQL used for zero-config
local development has no Oracle equivalent — Oracle always requires a running
instance (use the [quick-start script](#quick-start-local-oracle) locally).
- **Consolidation reconciliation is skipped.** The similarity-based
near-duplicate reconciliation pass in consolidation
(`HINDSIGHT_API_CONSOLIDATION_DEDUP_THRESHOLD`) is a PostgreSQL-only path;
consolidation still runs on Oracle, without that extra reconciliation step.
- **Entity resolution uses Oracle fuzzy matching.** Fuzzy entity lookup during
retain uses Oracle's text matching rather than PostgreSQL's `pg_trgm` trigram
matching. Behaviour is equivalent; the underlying mechanism differs.
## Troubleshooting
| Symptom | Cause / Fix |
|---------|-------------|
| `python-oracledb is required for Oracle backend` | The driver isn't installed. Run `pip install oracledb` (or install the `[oracle]` extra). |
| `ORA-01435: user does not exist` on migration | `HINDSIGHT_API_DATABASE_SCHEMA` is unset (defaults to `public`) or misspelled. Set it to your Oracle schema user, uppercase (e.g. `HINDSIGHT`). |
| `ORA-51803: Vector dimension count must match` on retain | The schema was migrated with a different embedding dimension than the running embeddings model. Migrate with the same embeddings config, or re-run `run-db-migration --embedding-dimension <N>`. |
| Migration errors when creating embedding/`VECTOR` columns | The schema user's default tablespace is not ASSM (often the `SYSTEM` tablespace). Recreate the user in an ASSM tablespace as shown above. |
| Full-text search errors / missing Oracle Text index | The schema user is missing the `CTXAPP` role. Run `GRANT CTXAPP TO <user>;`. |
| `ORA-12514` / service not found | The URL uses a SID or wrong service name. Use the pluggable database **service name** (e.g. `FREEPDB1`), not the SID. |
| Login works manually but fails from Hindsight | A reserved character in the password isn't URL-encoded. Encode `@ / : ?` in the `DATABASE_URL`. |
## See also
- [Storage](./storage) — why PostgreSQL is the default, and how Oracle fits in
- [Configuration](./configuration#database) — all database environment variables
- [Installation](./installation) — packaging and deployment options
- [Admin CLI](./admin-cli) — administrative commands (PostgreSQL-only data operations)
@@ -42,6 +42,8 @@ By building on PostgreSQL, we keep the system simple:
For enterprise deployments, Hindsight also supports Oracle AI Database with full feature parity. All memory operations—retain, recall, and reflect—work identically on Oracle, making it a drop-in option for organizations that standardize on Oracle infrastructure.
See the [Oracle Database guide](./oracle) for setup: prerequisites, provisioning, connection URLs, migrations, and the differences from PostgreSQL.
## Development with pg0
For local development, Hindsight uses **[pg0](https://github.com/vectorize-io/pg0)**—an embedded PostgreSQL distribution.
@@ -295,6 +295,14 @@
"icon": "lu-terminal"
}
},
{
"type": "doc",
"id": "developer/oracle",
"label": "Oracle Database",
"customProps": {
"icon": "lu-database"
}
},
{
"type": "doc",
"id": "developer/extensions",
@@ -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

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