Compare commits

...
Author SHA1 Message Date
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
97 changed files with 2753 additions and 2080 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"
@@ -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)
+18 -5
View File
@@ -1433,6 +1433,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 +1667,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 +1678,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'.")
@@ -3750,13 +3753,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,
@@ -9,6 +9,33 @@ from .ops import DataAccessOps, TagListingParts
from .result import ResultRow
def pg_search_vector_expr(
config,
*,
text_col: str = "text",
context_col: str = "context",
signals_col: str = "text_signals",
) -> str | None:
"""SQL expression that builds ``search_vector`` for the configured PG text-search backend.
Single source of truth shared by the batch insert (over the ``input_data``
CTE columns) and the curation revert recompute (over a ``memory_units`` row),
so the two can never drift. Returns ``None`` for backends that leave
``search_vector`` unpopulated — pgroonga / pg_textsearch / pg_search index the
base text columns directly and keep only a dummy column, so there is nothing
to build.
``text_search_extension_native_language`` is validated as a PG identifier in
``HindsightConfig.validate()``, so embedding it as a SQL literal is safe.
"""
combined = f"COALESCE({text_col}, '') || ' ' || COALESCE({context_col}, '') || ' ' || COALESCE({signals_col}, '')"
if config.text_search_extension == "vchord":
return f"tokenize({combined}, 'llmlingua2')::bm25_catalog.bm25vector"
if config.text_search_extension == "native":
return f"to_tsvector('{config.text_search_extension_native_language}'::regconfig, {combined})"
return None
class PostgreSQLOps(DataAccessOps):
"""PostgreSQL-specific data access operations using unnest and LATERAL."""
@@ -93,101 +120,39 @@ class PostgreSQLOps(DataAccessOps):
config = get_config()
table = self._get_mu_table()
if config.text_search_extension == "vchord":
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals, search_vector)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals,
tokenize(
COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, ''),
'llmlingua2'
)::bm25_catalog.bm25vector
FROM input_data
RETURNING id
"""
elif config.text_search_extension == "native":
# search_vector is a regular tsvector column populated here using the
# configured native dictionary. It used to be GENERATED ALWAYS with
# a hardcoded 'english', which prevented per-deployment language
# configuration. text_search_extension_native_language is validated
# in HindsightConfig.validate() as a PG identifier, so embedding it
# as a SQL literal is safe.
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals, search_vector)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals,
to_tsvector(
'{config.text_search_extension_native_language}'::regconfig,
COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, '')
)
FROM input_data
RETURNING id
"""
else:
# pg_textsearch, pgroonga, and pg_search: search_vector is a dummy
# TEXT column; the actual full-text index operates on the base text
# columns directly, so we don't populate search_vector at insert time.
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals
FROM input_data
RETURNING id
"""
# search_vector is populated inline for backends that store a real vector
# (native tsvector, vchord bm25vector). pgroonga / pg_textsearch / pg_search
# index the base text columns directly and keep only a dummy column, so the
# expression is None and the column is left out of the insert entirely.
# Same expression is reused by curation revert (see pg_search_vector_expr).
sv_expr = pg_search_vector_expr(config)
sv_insert_col = ", search_vector" if sv_expr else ""
sv_select_val = f",\n {sv_expr}" if sv_expr else ""
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals{sv_insert_col})
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals{sv_select_val}
FROM input_data
RETURNING id
"""
results = await conn.fetch(
query,
@@ -6570,13 +6570,18 @@ class MemoryEngine(MemoryEngineInterface):
)
collist = await self._memory_unit_columns(conn)
# The archive is cold storage, never a recall surface, so the schema gives it
# no `embedding` column at all (dropped in d4f6a8c2e1b3). The move in/out is
# therefore over every memory_units column EXCEPT embedding; on revert the
# embedding is recomputed from the unit's text/dates/entities below. This makes
# a model switch (which re-dimensions memory_units) structurally unable to trip
# a vector-dimension mismatch on the INSERT … SELECT round-trip (#2209).
arch_cols = ", ".join(c for c in (s.strip() for s in collist.split(",")) if c != '"embedding"')
# The archive is cold storage, never a recall surface and carries no index,
# so the schema gives it neither the `embedding` (dropped in d4f6a8c2e1b3)
# nor the `search_vector` column (dropped in e7c3a9f1b2d5). Both are
# recall-surface columns whose type/shape follows server
# config, so the move in/out is over every memory_units column EXCEPT those
# two; on revert each is recomputed from the unit's text/dates/entities below.
# This makes a model switch (which re-dimensions memory_units) structurally
# unable to trip a vector-dimension mismatch (#2209), and a text-search backend
# switch unable to trip a search_vector type mismatch (#2503), on the
# INSERT … SELECT round-trip.
_archive_omitted = ('"embedding"', '"search_vector"')
arch_cols = ", ".join(c for c in (s.strip() for s in collist.split(",")) if c not in _archive_omitted)
# --- Edit fields (live rows only): text / context / dates / fact_type / entities ---
doing_edit = any(
@@ -6634,6 +6639,17 @@ class MemoryEngine(MemoryEngineInterface):
mentioned_at=live["mentioned_at"],
entities=[r["canonical_name"] for r in ent_rows],
)
# Keep the stored text-search vector in sync with curated
# text/context edits. Use the incoming parameters here:
# PostgreSQL evaluates UPDATE RHS expressions before the
# sibling SET assignments take effect, so column references
# would see the pre-edit text/context.
from .db.ops_postgresql import pg_search_vector_expr
sv_expr = pg_search_vector_expr(get_config(), text_col="$3", context_col="$4")
search_vector_clause = (
f",\n search_vector = {sv_expr}" if sv_expr else ""
)
await enqueue_relink_victims(conn, bank_id, [memory_id], ops=backend.ops)
await conn.execute(
f"""
@@ -6641,7 +6657,7 @@ class MemoryEngine(MemoryEngineInterface):
SET text = $3, context = $4, fact_type = $5, occurred_start = $6,
occurred_end = $7, event_date = $8, embedding = $9::vector,
consolidated_at = NULL, consolidation_failed_at = NULL,
edited_at = now(), updated_at = now()
edited_at = now(), updated_at = now(){search_vector_clause}
WHERE id = $1 AND bank_id = $2
""",
str(memory_uuid),
@@ -6695,14 +6711,29 @@ class MemoryEngine(MemoryEngineInterface):
arch_row = await conn.fetchrow(
f"SELECT entity_ids FROM {arch} WHERE id = $1 AND bank_id = $2", str(memory_uuid), bank_id
)
# The archive has no embedding column (see arch_cols above), so the live
# row's embedding defaults to NULL on the way back and is recomputed below
# once entities are restored.
# The archive keeps neither embedding nor search_vector (see arch_cols
# above), so both default to NULL on the way back and are recomputed here:
# the embedding below once entities are restored, the search_vector now
# from the row's own text/context/text_signals.
await conn.execute(
f"INSERT INTO {mu} ({arch_cols}) SELECT {arch_cols} FROM {arch} WHERE id = $1 AND bank_id = $2",
str(memory_uuid),
bank_id,
)
# Rebuild search_vector using the *current* text-search backend, so the
# reverted unit is keyword-searchable again (more correct than carrying a
# verbatim copy that could be stale/wrong-type if the backend changed while
# the fact sat archived). None = pgroonga/pg_textsearch/pg_search, which
# index base columns directly and leave search_vector empty (#2503).
from .db.ops_postgresql import pg_search_vector_expr
sv_expr = pg_search_vector_expr(get_config())
if sv_expr is not None:
await conn.execute(
f"UPDATE {mu} SET search_vector = {sv_expr} WHERE id = $1 AND bank_id = $2",
str(memory_uuid),
bank_id,
)
# Re-consolidate from scratch; links are rebuilt by graph maintenance.
await conn.execute(
f"UPDATE {mu} SET consolidated_at = NULL, consolidation_failed_at = NULL, updated_at = now() "
@@ -7446,7 +7477,7 @@ class MemoryEngine(MemoryEngineInterface):
f"""
SELECT id, text, event_date, context, fact_type, document_id,
mentioned_at, occurred_start, occurred_end, chunk_id, proof_count,
tags, consolidated_at, consolidation_failed_at, edited_at, {curation_cols}
tags, metadata, consolidated_at, consolidation_failed_at, edited_at, {curation_cols}
FROM {source_table}
{where_clause}
ORDER BY mentioned_at DESC NULLS LAST, created_at DESC
@@ -7501,6 +7532,7 @@ class MemoryEngine(MemoryEngineInterface):
"chunk_id": row["chunk_id"] if row["chunk_id"] else None,
"proof_count": row["proof_count"] if row["proof_count"] is not None else 1,
"tags": list(row["tags"]) if row["tags"] else [],
"metadata": conn.parse_json(row["metadata"]) if row["metadata"] is not None else {},
"consolidated_at": row["consolidated_at"].isoformat() if row["consolidated_at"] else None,
"consolidation_failed_at": (
row["consolidation_failed_at"].isoformat() if row["consolidation_failed_at"] else None
@@ -7553,7 +7585,7 @@ class MemoryEngine(MemoryEngineInterface):
# back to the archive (with its invalidation bookkeeping) on a miss.
select_cols = (
"id, text, context, event_date, occurred_start, occurred_end, "
"mentioned_at, fact_type, document_id, chunk_id, tags, source_memory_ids, "
"mentioned_at, fact_type, document_id, chunk_id, tags, metadata, source_memory_ids, "
"observation_scopes, edited_at"
)
row = await conn.fetchrow(
@@ -7597,6 +7629,7 @@ class MemoryEngine(MemoryEngineInterface):
"document_id": row["document_id"] if row["document_id"] else None,
"chunk_id": str(row["chunk_id"]) if row["chunk_id"] else None,
"tags": row["tags"] if row["tags"] else [],
"metadata": conn.parse_json(row["metadata"]) if row["metadata"] is not None else {},
"observation_scopes": row["observation_scopes"] if row["observation_scopes"] else None,
"state": unit_state,
"invalidation_reason": row["invalidation_reason"],
@@ -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(
@@ -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"
@@ -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"
@@ -78,6 +78,39 @@ async def test_patch_invalidate_and_revert_over_http(api_client, memory):
await memory.delete_bank(bank_id, request_context=RequestContext())
@pytest.mark.asyncio
async def test_patch_clears_occurred_dates_with_explicit_null(api_client, memory):
bank_id = f"curation-http-clear-dates-{uuid.uuid4().hex[:8]}"
mem_id = await _insert_fact(memory, bank_id, "Release v1.2 happened on Monday.")
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE memory_units
SET occurred_start = '2024-01-15T10:30:00Z',
occurred_end = '2024-01-15T11:00:00Z'
WHERE id = $1
""",
uuid.UUID(mem_id),
)
resp = await api_client.patch(
f"/v1/default/banks/{bank_id}/memories/{mem_id}",
json={"occurred_start": None, "occurred_end": None},
)
assert resp.status_code == 200, resp.text
assert resp.json()["occurred_start"] is None
assert resp.json()["occurred_end"] is None
resp = await api_client.get(f"/v1/default/banks/{bank_id}/memories/{mem_id}")
assert resp.status_code == 200
assert resp.json()["occurred_start"] is None
assert resp.json()["occurred_end"] is None
await memory.delete_bank(bank_id, request_context=RequestContext())
@pytest.mark.asyncio
async def test_patch_not_found_returns_404(api_client, memory):
bank_id = f"curation-http-404-{uuid.uuid4().hex[:8]}"
@@ -4,8 +4,7 @@ Unit tests that verify the abstraction interfaces work correctly
without requiring a live database connection.
"""
import json
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, patch
import pytest
@@ -748,6 +747,85 @@ class TestOracleOpsInsertFactsBatch:
assert rows_data[0][13] == []
# ---------------------------------------------------------------------------
# PostgreSQL search_vector handling (insert). Since the curation archive drops
# search_vector (#2503), the insert is the single place it is populated, and
# pg_search_vector_expr is its one source of truth (shared with revert recompute).
# ---------------------------------------------------------------------------
class TestPostgreSQLSearchVector:
@staticmethod
def _cfg(ext: str, lang: str = "english"):
from types import SimpleNamespace
return SimpleNamespace(text_search_extension=ext, text_search_extension_native_language=lang)
@pytest.mark.parametrize(
"ext,needle",
[
("native", "to_tsvector('english'::regconfig,"),
("vchord", "::bm25_catalog.bm25vector"),
],
)
def test_expr_builds_vector_for_vector_backends(self, ext, needle):
from hindsight_api.engine.db.ops_postgresql import pg_search_vector_expr
expr = pg_search_vector_expr(self._cfg(ext))
assert expr is not None and needle in expr
# Always built from the same three carried columns.
assert "COALESCE(text, '')" in expr and "COALESCE(text_signals, '')" in expr
@pytest.mark.parametrize("ext", ["pgroonga", "pg_textsearch", "pg_search"])
def test_expr_none_for_base_column_backends(self, ext):
from hindsight_api.engine.db.ops_postgresql import pg_search_vector_expr
# These index the base text columns directly; search_vector stays empty.
assert pg_search_vector_expr(self._cfg(ext)) is None
def test_expr_accepts_custom_column_refs(self):
from hindsight_api.engine.db.ops_postgresql import pg_search_vector_expr
expr = pg_search_vector_expr(self._cfg("native"), text_col="mu.text", context_col="mu.context")
assert "COALESCE(mu.text, '')" in expr and "COALESCE(mu.context, '')" in expr
async def _insert_query(self, ext: str) -> str:
from hindsight_api.engine.db.ops_postgresql import PostgreSQLOps
conn = AsyncMock(spec=DatabaseConnection)
conn.fetch = AsyncMock(return_value=[{"id": "00000000-0000-0000-0000-000000000001"}])
batch = dict(
bank_id="b",
fact_texts=["t"],
embeddings=["[0.1]"],
event_dates=[None],
occurred_starts=[None],
occurred_ends=[None],
mentioned_ats=[None],
contexts=["c"],
fact_types=["world"],
metadata_jsons=["{}"],
chunk_ids=[None],
document_ids=[None],
tags_list=[""],
observation_scopes_list=[None],
text_signals_list=[None],
)
with patch("hindsight_api.config.get_config", return_value=self._cfg(ext)):
await PostgreSQLOps().insert_facts_batch(conn=conn, **batch)
return conn.fetch.call_args.args[0]
@pytest.mark.asyncio
@pytest.mark.parametrize("ext", ["native", "vchord"])
async def test_insert_includes_search_vector_column(self, ext):
assert "search_vector" in await self._insert_query(ext)
@pytest.mark.asyncio
@pytest.mark.parametrize("ext", ["pgroonga", "pg_textsearch", "pg_search"])
async def test_insert_omits_search_vector_column(self, ext):
assert "search_vector" not in await self._insert_query(ext)
# ---------------------------------------------------------------------------
# normalize_schema tests
# ---------------------------------------------------------------------------
@@ -9,12 +9,87 @@ BaseException'), which happened when last_error was only set in the
BadRequestError handler and not for non-dict JSON responses.
"""
import json
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def test_output_retry_split_preserves_conversation_array_boundaries():
"""OutputTooLong retry splitting must keep conversation chunks valid JSON arrays."""
from hindsight_api.engine.retain.fact_extraction import _split_chunk_for_output_retry
turns = [
{"role": "user", "content": "alpha"},
{"role": "assistant", "content": "bravo"},
{"role": "user", "content": "charlie"},
{"role": "assistant", "content": "delta"},
]
split = _split_chunk_for_output_retry(json.dumps(turns))
assert split is not None
first, second = split
assert json.loads(first) == turns[:2]
assert json.loads(second) == turns[2:]
def test_output_retry_split_divides_single_oversized_turn_content():
"""A lone oversized conversation turn is split inside content and rewrapped."""
from hindsight_api.engine.retain.fact_extraction import _split_chunk_for_output_retry
turn = {"role": "user", "content": "abcdefghijklmnopqrstuvwxyz", "name": "casey"}
split = _split_chunk_for_output_retry(json.dumps([turn]))
assert split is not None
first, second = split
first_turn = json.loads(first)[0]
second_turn = json.loads(second)[0]
assert first_turn["role"] == "user"
assert second_turn["role"] == "user"
assert first_turn["name"] == "casey"
assert second_turn["name"] == "casey"
assert first_turn["content"] + second_turn["content"] == turn["content"]
def test_output_retry_split_returns_none_when_no_progress_possible():
"""Pathological tiny chunks should be dropped instead of recursively retried."""
from hindsight_api.engine.retain.fact_extraction import _split_chunk_for_output_retry
assert _split_chunk_for_output_retry("x") is None
assert _split_chunk_for_output_retry(json.dumps([{"role": "user", "content": ""}])) is None
@pytest.mark.asyncio
async def test_output_too_long_drops_unsplittable_subchunk_without_recursing():
"""If a chunk cannot be reduced further, auto-split exits gracefully."""
from hindsight_api.engine.llm_wrapper import OutputTooLongError
from hindsight_api.engine.retain.fact_extraction import _extract_facts_with_auto_split
config = _make_config(llm_max_retries=1)
llm_config = _make_llm_config(mock_response={})
with patch(
"hindsight_api.engine.retain.fact_extraction._extract_facts_from_chunk",
side_effect=OutputTooLongError("too long"),
) as extract:
facts, usage = await _extract_facts_with_auto_split(
chunk="x",
chunk_index=0,
total_chunks=1,
event_date=datetime(2023, 1, 1, tzinfo=timezone.utc),
context="",
llm_config=llm_config,
config=config,
agent_name="agent",
)
assert facts == []
assert extract.call_count == 1
def _make_config(llm_max_retries: int = 3, retain_llm_max_retries: int | None = None):
"""Build a minimal HindsightConfig for fact extraction tests."""
from hindsight_api.config import HindsightConfig
@@ -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]}"
+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
-4
View File
@@ -38,14 +38,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 +53,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.
+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)
@@ -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" />
+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]
+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**
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

+4
View File
@@ -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" />
@@ -1,7 +1,7 @@
{
"name": "hindsight-memory",
"description": "Automatic long-term memory for Claude Code via Hindsight. Recalls relevant memories before each prompt, retains conversation transcripts, and provides knowledge page tools.",
"version": "0.7.2",
"version": "0.7.3",
"author": {"name": "Hindsight Team", "url": "https://vectorize.io/hindsight"},
"license": "MIT",
"keywords": ["memory", "hindsight", "recall", "retain"]
@@ -223,6 +223,7 @@ Auto-recall runs on every user prompt. It queries Hindsight for relevant memorie
| `autoRecall` | `HINDSIGHT_AUTO_RECALL` | `true` | Master switch for auto-recall. Set to `false` to disable memory retrieval entirely. |
| `recallBudget` | `HINDSIGHT_RECALL_BUDGET` | `"mid"` | Controls how hard Hindsight searches for memories. `"low"` = fast, fewer strategies; `"mid"` = balanced; `"high"` = thorough, slower. Affects latency directly. |
| `recallMaxTokens` | `HINDSIGHT_RECALL_MAX_TOKENS` | `1024` | Maximum number of tokens in the recalled memory block. Lower values reduce context usage but may truncate relevant memories. |
| `recallMinScores` | — | `{}` | Optional score floors applied after recall from the main and additional banks, keyed by score field (for example `{"semantic": 0.65, "reranker": 0.2}`). Missing or `null` scores pass so BM25-only and passthrough-reranker hits are not accidentally suppressed. When a cross-encoder reranker is active, the `reranker` floor is the main precision gate; treat reranker scores as query-local and not calibrated across queries. |
| `recallTypes` | — | `["observation"]` | Which memory types to retrieve. `"world"` = general facts; `"experience"` = personal experiences; `"observation"` = consolidated, deduplicated beliefs built from multiple facts. Defaults to observations so the same answer doesn't surface multiple times when many raw memories say the same thing. |
| `recallContextTurns` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | `1` | How many prior conversation turns to include when composing the recall query. `1` = only the latest user message; higher values give more context but may dilute the query. |
| `recallMaxQueryChars` | `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | `800` | Maximum character length of the query sent to Hindsight. Longer queries are truncated. |
@@ -21,6 +21,7 @@ DEFAULTS = {
"recallTagsMatch": "any",
"recallTagGroups": None,
"recallAdditionalBankFilters": {},
"recallMinScores": {},
"recallPromptPreamble": (
"Relevant memories from past conversations (prioritize recent when "
"conflicting). Only use memories that are directly useful to continue "
@@ -41,6 +41,37 @@ from lib.state import write_state
LAST_RECALL_STATE = "last_recall.json"
def filter_by_min_scores(results: list[dict], min_scores: dict, config: dict) -> list[dict]:
"""Drop recall results whose numeric scores are below configured floors."""
if not min_scores:
return results
floors = {}
for field, floor in min_scores.items():
try:
floors[field] = float(floor)
except (TypeError, ValueError):
debug_log(config, f"Ignoring invalid recallMinScores floor for '{field}': {floor!r}")
if not floors:
return results
def passes_floors(result: dict) -> bool:
scores = result.get("scores") or {}
for field, floor in floors.items():
value = scores.get(field)
# Missing/None scores pass (fail-open): BM25-only hits lack semantic
# scores, and passthrough rerankers report null.
if isinstance(value, (int, float)) and value < floor:
return False
return True
before_count = len(results)
filtered = [result for result in results if passes_floors(result)]
dropped_count = before_count - len(filtered)
debug_log(config, f"Score floors dropped {dropped_count}/{before_count} results")
return filtered
def read_transcript_messages(transcript_path: str) -> list:
"""Read messages from a JSONL transcript file for multi-turn context.
@@ -199,6 +230,8 @@ def main():
except Exception as e:
debug_log(config, f"Recall from additional bank '{extra_bank_id}' failed: {e}")
results = filter_by_min_scores(results, config.get("recallMinScores") or {}, config)
if not results:
debug_log(config, "No memories found")
return
@@ -8,6 +8,7 @@
"retainMode": "full-session",
"recallBudget": "mid",
"recallMaxTokens": 1024,
"recallMinScores": {},
"recallTypes": ["observation"],
"recallContextTurns": 1,
"recallMaxQueryChars": 800,
@@ -88,6 +88,50 @@ class TestRecallHook:
assert "Paris is the capital of France" in context
assert "<hindsight_memories>" in context
def test_recall_min_scores_filters_low_scoring_memories(self, monkeypatch, tmp_path):
low_semantic = make_memory("Marginal match")
low_semantic["scores"] = {"semantic": 0.42, "reranker": 0.8}
low_reranker = make_memory("Junk reranker match")
low_reranker["scores"] = {"semantic": 0.9, "reranker": 0.03}
no_scores = make_memory("BM25-only match")
good = make_memory("Relevant match")
good["scores"] = {"semantic": 0.91, "reranker": 0.45}
response = FakeHTTPResponse({"results": [low_semantic, low_reranker, no_scores, good]})
hook_input = make_hook_input(prompt="What deployment rule applies?")
output = _run_hook(
"recall",
hook_input,
monkeypatch,
tmp_path,
urlopen_side_effect=lambda *a, **kw: response,
extra_settings={"recallMinScores": {"semantic": 0.65, "reranker": 0.2}},
)
context = json.loads(output)["hookSpecificOutput"]["additionalContext"]
assert "Marginal match" not in context
assert "Junk reranker match" not in context
assert "BM25-only match" in context
assert "Relevant match" in context
def test_recall_min_scores_ignores_invalid_floor(self, monkeypatch, tmp_path):
memory = make_memory("Relevant match")
memory["scores"] = {"semantic": 0.91}
response = FakeHTTPResponse({"results": [memory]})
hook_input = make_hook_input(prompt="What deployment rule applies?")
output = _run_hook(
"recall",
hook_input,
monkeypatch,
tmp_path,
urlopen_side_effect=lambda *a, **kw: response,
extra_settings={"recallMinScores": {"semantic": None}},
)
context = json.loads(output)["hookSpecificOutput"]["additionalContext"]
assert "Relevant match" in context
def test_no_output_when_no_memories(self, monkeypatch, tmp_path):
hook_input = make_hook_input(prompt="hello there world")
output = _run_hook("recall", hook_input, monkeypatch, tmp_path)
+1
View File
@@ -86,6 +86,7 @@ export ANTHROPIC_API_KEY=your-key
| `retainEveryNTurns` | `10` | Retain every N turns (1 = every turn) |
| `recallBudget` | `"mid"` | Recall depth: `"low"`, `"mid"`, `"high"` |
| `recallMaxTokens` | `1024` | Max tokens for injected memories |
| `recallMinScores` | `{}` | Optional score floors applied after recall, keyed by score field (for example `{"semantic": 0.65, "reranker": 0.2}`). Missing or `null` scores pass so BM25-only and passthrough-reranker hits are not accidentally suppressed. When a cross-encoder reranker is active, the `reranker` floor is the main precision gate; treat reranker scores as query-local and not calibrated across queries. |
| `recallTimeout` | `10` | Timeout in seconds for recall API calls |
| `dynamicBankId` | `false` | Separate bank per project/session |
| `dynamicBankGranularity` | `["agent", "project"]` | Fields for dynamic bank ID |
@@ -18,6 +18,7 @@ DEFAULTS = {
"recallContextTurns": 1,
"recallMaxQueryChars": 800,
"recallRoles": ["user", "assistant"],
"recallMinScores": {},
"recallPromptPreamble": (
"Relevant memories from past conversations (prioritize recent when "
"conflicting). Only use memories that are directly useful to continue "
@@ -41,6 +41,37 @@ from lib.state import write_state
LAST_RECALL_STATE = "last_recall.json"
def filter_by_min_scores(results: list[dict], min_scores: dict, config: dict) -> list[dict]:
"""Drop recall results whose numeric scores are below configured floors."""
if not min_scores:
return results
floors = {}
for field, floor in min_scores.items():
try:
floors[field] = float(floor)
except (TypeError, ValueError):
debug_log(config, f"Ignoring invalid recallMinScores floor for '{field}': {floor!r}")
if not floors:
return results
def passes_floors(result: dict) -> bool:
scores = result.get("scores") or {}
for field, floor in floors.items():
value = scores.get(field)
# Missing/None scores pass (fail-open): BM25-only hits lack semantic
# scores, and passthrough rerankers report null.
if isinstance(value, (int, float)) and value < floor:
return False
return True
before_count = len(results)
filtered = [result for result in results if passes_floors(result)]
dropped_count = before_count - len(filtered)
debug_log(config, f"Score floors dropped {dropped_count}/{before_count} results")
return filtered
def main():
if sys.platform == "win32":
sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8', errors='replace')
@@ -125,6 +156,8 @@ def main():
return
results = response.get("results", [])
results = filter_by_min_scores(results, config.get("recallMinScores") or {}, config)
if not results:
debug_log(config, "No memories found")
return
+2 -1
View File
@@ -1,5 +1,5 @@
{
"version": "0.3.0",
"version": "0.3.1",
"hindsightApiUrl": "",
"bankId": "codex",
"bankMission": "You are a Codex AI coding assistant. Focus on technical decisions, code changes, debugging sessions, and project context relevant to the user's work.",
@@ -9,6 +9,7 @@
"retainMode": "full-session",
"recallBudget": "mid",
"recallMaxTokens": 1024,
"recallMinScores": {},
"recallTimeout": 10,
"recallTypes": ["world", "experience"],
"recallContextTurns": 1,
@@ -86,6 +86,50 @@ class TestRecallHook:
assert "Paris is the capital of France" in context
assert "<hindsight_memories>" in context
def test_recall_min_scores_filters_low_scoring_memories(self, monkeypatch, tmp_path):
low_semantic = make_memory("Marginal match")
low_semantic["scores"] = {"semantic": 0.42, "reranker": 0.8}
low_reranker = make_memory("Junk reranker match")
low_reranker["scores"] = {"semantic": 0.9, "reranker": 0.03}
no_scores = make_memory("BM25-only match")
good = make_memory("Relevant match")
good["scores"] = {"semantic": 0.91, "reranker": 0.45}
response = FakeHTTPResponse({"results": [low_semantic, low_reranker, no_scores, good]})
hook_input = make_hook_input(prompt="What deployment rule applies?")
output = _run_hook(
"recall",
hook_input,
monkeypatch,
tmp_path,
urlopen_side_effect=lambda *a, **kw: response,
user_config={"recallMinScores": {"semantic": 0.65, "reranker": 0.2}},
)
context = json.loads(output)["hookSpecificOutput"]["additionalContext"]
assert "Marginal match" not in context
assert "Junk reranker match" not in context
assert "BM25-only match" in context
assert "Relevant match" in context
def test_recall_min_scores_ignores_invalid_floor(self, monkeypatch, tmp_path):
memory = make_memory("Relevant match")
memory["scores"] = {"semantic": 0.91}
response = FakeHTTPResponse({"results": [memory]})
hook_input = make_hook_input(prompt="What deployment rule applies?")
output = _run_hook(
"recall",
hook_input,
monkeypatch,
tmp_path,
urlopen_side_effect=lambda *a, **kw: response,
user_config={"recallMinScores": {"semantic": None}},
)
context = json.loads(output)["hookSpecificOutput"]["additionalContext"]
assert "Relevant match" in context
def test_no_output_when_no_memories(self, monkeypatch, tmp_path):
hook_input = make_hook_input(prompt="hello there world")
output = _run_hook("recall", hook_input, monkeypatch, tmp_path)
+69 -51
View File
@@ -1,18 +1,19 @@
# Hindsight for Eve
Long-term memory for [Vercel Eve](https://github.com/vercel/eve) agents, powered by
[Hindsight](https://vectorize.io/hindsight). One file gives your agent `retain`, `recall`,
and `reflect` over [Hindsight's MCP server](https://hindsight.vectorize.io) — so it
remembers facts across sessions and deployments instead of starting cold every time.
Automatic long-term memory for [Vercel Eve](https://github.com/vercel/eve) agents, powered by
[Hindsight](https://vectorize.io/hindsight). Two files give your agent memory that **just
works** — relevant memory is injected before every turn, and each exchange is saved after —
**without the model ever choosing to call a tool.**
## How it works
Eve is filesystem-first: an agent gains a capability by dropping a file under
`agent/connections/`. This package wraps eve's `defineMcpClientConnection`, pre-filling the
Hindsight MCP endpoint, a model-facing description, and bearer auth. The model discovers the
tools through `connection__search` and calls them as `connection__hindsight__recall`,
`connection__hindsight__retain`, and `connection__hindsight__reflect`. The connection's URL
and token never reach the model.
Eve is filesystem-first. This package wires two authored files that call Hindsight's REST API
directly, so memory never depends on the LLM deciding to call a tool:
- **`agent/instructions/hindsight.ts`** — a dynamic instructions resolver that, before each
turn, recalls the user's stored memory from Hindsight and injects it as a system message.
- **`agent/hooks/hindsight.ts`** — a hook that, after each turn, retains the user message and
the assistant's answer to Hindsight.
## Install
@@ -24,81 +25,98 @@ 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
import { hindsightMemory } from "@vectorize-io/hindsight-eve";
export default defineHindsightConnection();
export default hindsightMemory();
```
That's it. By default the connection reads:
```ts
// agent/hooks/hindsight.ts
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();
```
That's it. Both read their config from the environment:
| Env var | Purpose |
| ------------------- | ------------------------------------------------------------- |
| `HINDSIGHT_API_KEY` | Bearer token sent as `Authorization: Bearer <key>` |
| `HINDSIGHT_API_URL` | Hindsight REST base (defaults to Hindsight Cloud) |
| `HINDSIGHT_BANK_ID` | Bank to scope memory to (defaults to `default`; auto-created) |
### Hindsight Cloud
Set `HINDSIGHT_API_KEY` to a key from your [Hindsight Cloud](https://hindsight.vectorize.io)
dashboard. The connection defaults to `https://api.hindsight.vectorize.io/mcp`, so no URL is
dashboard. `HINDSIGHT_API_URL` defaults to `https://api.hindsight.vectorize.io`, so no URL is
needed.
### Self-hosted
Point at your own server and (optionally) pick a bank:
```bash
export HINDSIGHT_MCP_URL="http://localhost:8000/mcp"
export HINDSIGHT_MCP_BANK_ID="my-project"
export HINDSIGHT_API_KEY="…" # or omit and pass apiKey: null below for a no-auth server
export HINDSIGHT_API_URL="http://localhost:8000"
export HINDSIGHT_BANK_ID="my-project"
export HINDSIGHT_API_KEY="…" # or pass apiKey: null below for a no-auth server
```
```ts
import { defineHindsightConnection } from "@vectorize-io/hindsight-eve";
import { hindsightMemory } from "@vectorize-io/hindsight-eve";
// A local server with no auth:
export default defineHindsightConnection({
url: "http://localhost:8000/mcp",
apiKey: null,
});
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, // string — MCP endpoint; defaults to HINDSIGHT_MCP_URL, then Cloud
hindsightMemory({
apiUrl, // string — REST base; defaults to HINDSIGHT_API_URL, then Cloud
apiKey, // string | null — bearer token; null = no auth (local dev)
bankId, // string — scope memory to a bank (X-Bank-Id header)
description, // string — override the model-facing description
tools, // { allow } | { block } — narrow which Hindsight tools the model sees
approval, // human-in-the-loop policy, e.g. once() from "eve/tools/approval"
bankId, // string — bank to scope memory to
recallQuery, // string — the broad query used for recall (see below)
budget, // "low" | "mid" | "high" — recall result budget (default "mid")
maxTokens, // number — recall token budget (default 1024)
context, // string — `context` tag written on retained items (default "eve")
includeAssistantReply, // boolean — also retain the assistant's reply (default true)
timeoutMs, // number — HTTP timeout (default 15000)
onError, // (err, phase) => void — failures degrade silently via this (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 deterministic. Tune it with `recallQuery`. (Per-message, query-
specific retrieval inherently needs a tool the model calls; that's out of scope here.)
export default defineHindsightConnection({
tools: { allow: ["recall", "reflect"] },
approval: once(),
});
```
## Notes
- Memory is scoped to a **bank** (one isolated store, e.g. per user). Point both files at the
same `HINDSIGHT_BANK_ID`.
- By default **both** the user's message and the assistant's reply are retained — the
reply is usually where the answer lives. Set `includeAssistantReply: false` to store
only the user's message.
- Retains run asynchronously and never block a turn; failures degrade via `onError`.
- The recall block injected into context is fenced with a sentinel so recalled facts are never
re-retained.
## Verify
With the connection in place, run your agent and ask it something it would need to look up
("what did we decide about X last week?"). Eve's `connection__search` surfaces the Hindsight
tools and the model calls `connection__hindsight__recall`. To seed memory, have the agent
`retain` a fact in one session and `recall` it in the next.
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)
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "@vectorize-io/hindsight-eve",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@vectorize-io/hindsight-eve",
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^22.0.0",
@@ -16,7 +16,7 @@
"vitest": "^4.1.2"
},
"engines": {
"node": ">=22"
"node": ">=24"
},
"peerDependencies": {
"eve": ">=0.11.0"
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@vectorize-io/hindsight-eve",
"version": "0.1.0",
"description": "Hindsight long-term memory for Vercel Eve agents - a one-line MCP connection exposing retain, recall, and reflect",
"version": "0.2.1",
"description": "Automatic long-term memory for Vercel Eve agents — Hindsight memory injected before each turn and retained after, with no model tool-calling",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
@@ -15,7 +15,7 @@
"eve",
"vercel",
"agents",
"mcp",
"hooks",
"memory",
"hindsight",
"llm",
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { hindsightAutoRecall, hindsightRetainHook } from "./auto-memory";
import { SENTINEL_OPEN } from "./client";
const OPTS = { apiUrl: "http://test", apiKey: "k", bankId: "b" };
const CTX = { session: { id: "s1" }, channel: { kind: "web" } } as unknown;
/** Mock fetch, routing by URL; returns recall results or a retain ack. */
function mockFetch(recallResults: unknown[] = []): ReturnType<typeof vi.fn> {
const fn = vi.fn(async (url: string) => ({
ok: true,
status: 200,
json: async () => (url.includes("/recall") ? { results: recallResults } : { success: true }),
text: async () => "",
}));
vi.stubGlobal("fetch", fn);
return fn;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handlers = (def: { events: unknown }): any => def.events;
afterEach(() => vi.unstubAllGlobals());
describe("hindsightRetainHook", () => {
it("retains both the user message and assistant reply on turn.completed (both by default)", async () => {
const fetchFn = mockFetch();
const ev = handlers(hindsightRetainHook(OPTS));
ev["message.received"]({ data: { turnId: "t1", message: "I prefer tabs" } });
ev["message.completed"]({ data: { turnId: "t1", message: "Got it.", finishReason: "stop" } });
await ev["turn.completed"]({ data: { turnId: "t1" } }, CTX);
expect(fetchFn).toHaveBeenCalledTimes(1);
const [url, init] = fetchFn.mock.calls[0];
expect(url).toBe("http://test/v1/default/banks/b/memories");
const body = JSON.parse(init.body);
expect(body.async).toBe(true);
expect(body.items[0].content).toBe("User: I prefer tabs\n\nAssistant: Got it.");
expect(body.items[0].context).toBe("eve");
expect(body.items[0].metadata).toMatchObject({ sessionId: "s1", turnId: "t1", channel: "web" });
});
it("stores only the user message when includeAssistantReply is false", async () => {
const fetchFn = mockFetch();
const ev = handlers(hindsightRetainHook({ ...OPTS, includeAssistantReply: false }));
ev["message.received"]({ data: { turnId: "t1", message: "I prefer tabs" } });
ev["message.completed"]({ data: { turnId: "t1", message: "Got it.", finishReason: "stop" } });
await ev["turn.completed"]({ data: { turnId: "t1" } }, CTX);
expect(JSON.parse(fetchFn.mock.calls[0][1].body).items[0].content).toBe("User: I prefer tabs");
});
it("ignores non-terminal assistant steps (finishReason !== 'stop')", async () => {
const fetchFn = mockFetch();
const ev = handlers(hindsightRetainHook(OPTS));
ev["message.received"]({ data: { turnId: "t1", message: "hi" } });
ev["message.completed"]({
data: { turnId: "t1", message: "calling tool", finishReason: "tool-calls" },
});
await ev["turn.completed"]({ data: { turnId: "t1" } }, CTX);
// still retains (user text present), but content has no assistant half
expect(JSON.parse(fetchFn.mock.calls[0][1].body).items[0].content).toBe("User: hi");
});
it("does not retain a turn with no user message", async () => {
const fetchFn = mockFetch();
const ev = handlers(hindsightRetainHook(OPTS));
ev["message.completed"]({ data: { turnId: "t1", message: "orphan", finishReason: "stop" } });
await ev["turn.completed"]({ data: { turnId: "t1" } }, CTX);
expect(fetchFn).not.toHaveBeenCalled();
});
it("never throws on a retain failure (degrades via onError)", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: false,
status: 500,
json: async () => ({}),
text: async () => "boom",
}))
);
const onError = vi.fn();
const ev = handlers(hindsightRetainHook({ ...OPTS, onError }));
ev["message.received"]({ data: { turnId: "t1", message: "x" } });
await expect(ev["turn.completed"]({ data: { turnId: "t1" } }, CTX)).resolves.toBeUndefined();
expect(onError).toHaveBeenCalledWith(expect.anything(), "retain");
});
});
describe("hindsightAutoRecall", () => {
it("recalls and returns injected instructions containing the memories", async () => {
const fetchFn = mockFetch([{ id: "1", text: "prefers Python" }]);
const ev = handlers(hindsightAutoRecall(OPTS));
const result = await ev["turn.started"]({ data: { turnId: "t1" } }, CTX);
const [url, init] = fetchFn.mock.calls[0];
expect(url).toBe("http://test/v1/default/banks/b/memories/recall");
expect(JSON.parse(init.body).query).toBe("user preferences, identity, and working context");
expect(result.markdown).toContain(SENTINEL_OPEN);
expect(result.markdown).toContain("- prefers Python");
});
it("returns undefined when there is nothing to recall", async () => {
mockFetch([]);
const ev = handlers(hindsightAutoRecall(OPTS));
expect(await ev["turn.started"]({ data: { turnId: "t1" } }, CTX)).toBeUndefined();
});
it("returns undefined and reports onError on a recall failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: false,
status: 500,
json: async () => ({}),
text: async () => "boom",
}))
);
const onError = vi.fn();
const ev = handlers(hindsightAutoRecall({ ...OPTS, onError }));
expect(await ev["turn.started"]({ data: { turnId: "t1" } }, CTX)).toBeUndefined();
expect(onError).toHaveBeenCalledWith(expect.anything(), "recall");
});
});
@@ -0,0 +1,118 @@
/**
* Automatic, no-tool long-term memory for Vercel Eve agents, backed by
* Hindsight's REST API. Two authored files give an agent memory that works
* without the model ever choosing to call a tool:
*
* ```ts
* // agent/instructions/hindsight.ts — recall: inject memory before each turn
* import { hindsightMemory } from "@vectorize-io/hindsight-eve";
* export default hindsightMemory();
*
* // agent/hooks/hindsight.ts — retain: save each exchange after the turn
* import { hindsightRetainHook } from "@vectorize-io/hindsight-eve";
* export default hindsightRetainHook();
* ```
*
* This module is the only one that imports `eve`. The HTTP client and config
* resolution are kept pure (in `./client` and `./config`) so they unit-test
* without the framework.
*/
import { defineHook, type HookDefinition } from "eve/hooks";
import { defineDynamic, defineInstructions, type DynamicSentinel } from "eve/instructions";
import { HindsightRestClient, buildRecallMarkdown } from "./client.js";
import {
buildRetainContent,
recordAssistantMessage,
recordUserMessage,
resolveAutoMemory,
takeTurn,
type AutoMemoryOptions,
type TurnBuffer,
} from "./config.js";
export type { AutoMemoryOptions } from "./config.js";
/**
* Inject the user's stored memory as a system message before each turn.
* Drop the returned value as the default export of `agent/instructions/hindsight.ts`.
*
* Recall uses a fixed broad query (not the live message eve's instruction
* resolver can't see it), which surfaces the user's ambient profile/context.
* Tune it with `recallQuery`.
*/
export function hindsightAutoRecall(options: AutoMemoryOptions = {}): DynamicSentinel {
const cfg = resolveAutoMemory(options);
const client = new HindsightRestClient(cfg.apiUrl, cfg.apiKey, cfg.timeoutMs);
return defineDynamic({
events: {
"turn.started": async (): Promise<unknown> => {
try {
const { results } = await client.recall(cfg.bankId, cfg.recallQuery, {
budget: cfg.budget,
maxTokens: cfg.maxTokens,
});
if (results.length === 0) return undefined;
return defineInstructions({ markdown: buildRecallMarkdown(results) });
} catch (error) {
cfg.onError(error, "recall");
return undefined;
}
},
},
});
}
/** Primary name for {@link hindsightAutoRecall} — the memory-injection half. */
export const hindsightMemory = hindsightAutoRecall;
/**
* Retain each completed exchange to Hindsight. Drop the returned value as the
* default export of `agent/hooks/hindsight.ts`.
*
* Pairs the user message (`message.received`) with the final assistant answer
* (`message.completed` where `finishReason === "stop"`) by `turnId`, then
* retains on `turn.completed`. All side effects are guarded a failure warns
* via `onError` and never breaks the turn.
*/
export function hindsightRetainHook(options: AutoMemoryOptions = {}): HookDefinition {
const cfg = resolveAutoMemory(options);
const client = new HindsightRestClient(cfg.apiUrl, cfg.apiKey, cfg.timeoutMs);
const buffer: TurnBuffer = new Map();
return defineHook({
events: {
"message.received": (event) => {
recordUserMessage(buffer, event.data.turnId, event.data.message);
},
"message.completed": (event) => {
// Only the terminal assistant text; intermediate steps end in "tool-calls".
if (event.data.finishReason === "stop" && event.data.message) {
recordAssistantMessage(buffer, event.data.turnId, event.data.message);
}
},
"turn.completed": async (event, ctx) => {
try {
const content = buildRetainContent(
takeTurn(buffer, event.data.turnId),
cfg.includeAssistantReply
);
if (content === null) return;
const metadata: Record<string, string> = {
sessionId: ctx.session.id,
turnId: event.data.turnId,
};
if (ctx.channel.kind) metadata.channel = ctx.channel.kind;
await client.retain(
cfg.bankId,
[{ content, context: cfg.context, metadata, timestamp: new Date().toISOString() }],
{ async: true }
);
} catch (error) {
cfg.onError(error, "retain");
}
},
},
});
}
@@ -0,0 +1,103 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
HindsightRestClient,
SENTINEL_OPEN,
SENTINEL_CLOSE,
buildRecallMarkdown,
stripSentinelBlocks,
} from "./client";
function mockFetchOnce(status: number, json: unknown): ReturnType<typeof vi.fn> {
const fn = vi.fn(async () => ({
ok: status >= 200 && status < 300,
status,
json: async () => json,
text: async () => JSON.stringify(json),
}));
vi.stubGlobal("fetch", fn);
return fn;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("HindsightRestClient.recall", () => {
it("POSTs to the recall path with query/budget/max_tokens and bearer auth", async () => {
const fetchFn = mockFetchOnce(200, {
results: [{ id: "1", text: "likes tabs", type: "world" }],
});
const client = new HindsightRestClient("https://api.hindsight.vectorize.io", "hsk_k");
const res = await client.recall("bank-1", "preferences", { budget: "low", maxTokens: 512 });
expect(res.results[0].text).toBe("likes tabs");
const [url, init] = fetchFn.mock.calls[0];
expect(url).toBe("https://api.hindsight.vectorize.io/v1/default/banks/bank-1/memories/recall");
expect(init.method).toBe("POST");
expect(init.headers["Authorization"]).toBe("Bearer hsk_k");
expect(JSON.parse(init.body)).toEqual({ query: "preferences", budget: "low", max_tokens: 512 });
});
it("defaults budget=mid and max_tokens=1024, omits auth header when no token", async () => {
const fetchFn = mockFetchOnce(200, { results: [] });
const client = new HindsightRestClient("http://localhost:8000", null);
await client.recall("b", "q");
const init = fetchFn.mock.calls[0][1];
expect(init.headers["Authorization"]).toBeUndefined();
expect(JSON.parse(init.body)).toEqual({ query: "q", budget: "mid", max_tokens: 1024 });
});
});
describe("HindsightRestClient.retain", () => {
it("POSTs items with async=true to the memories path", async () => {
const fetchFn = mockFetchOnce(200, { success: true });
const client = new HindsightRestClient("https://api.hindsight.vectorize.io/", "hsk_k");
await client.retain("my bank", [{ content: "fact", context: "eve" }]);
const [url, init] = fetchFn.mock.calls[0];
// trailing slash on baseUrl is normalized; bank is URL-encoded
expect(url).toBe("https://api.hindsight.vectorize.io/v1/default/banks/my%20bank/memories");
expect(JSON.parse(init.body)).toEqual({
items: [{ content: "fact", context: "eve" }],
async: true,
});
});
});
describe("HindsightRestClient error handling", () => {
it("throws on a non-2xx response", async () => {
mockFetchOnce(401, { detail: "unauthorized" });
const client = new HindsightRestClient("https://api.hindsight.vectorize.io", "bad");
await expect(client.recall("b", "q")).rejects.toThrow(/HTTP 401/);
});
it("requires a non-empty base URL", () => {
expect(() => new HindsightRestClient(" ")).toThrow(/API URL is required/);
});
});
describe("buildRecallMarkdown / stripSentinelBlocks", () => {
it("returns empty string for no results", () => {
expect(buildRecallMarkdown([])).toBe("");
});
it("wraps results in sentinel markers as a bulleted list", () => {
const md = buildRecallMarkdown([
{ id: "1", text: "prefers Python" },
{ id: "2", text: "no comments" },
]);
expect(md.startsWith(SENTINEL_OPEN)).toBe(true);
expect(md.trimEnd().endsWith(SENTINEL_CLOSE)).toBe(true);
expect(md).toContain("- prefers Python");
expect(md).toContain("- no comments");
});
it("strips a fenced recalled-context block out of text", () => {
const md = buildRecallMarkdown([{ id: "1", text: "secret" }]);
const polluted = `Here is my answer.\n${md}\nDone.`;
const cleaned = stripSentinelBlocks(polluted);
expect(cleaned).not.toContain("secret");
expect(cleaned).toContain("Here is my answer.");
expect(cleaned).toContain("Done.");
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* Minimal Hindsight REST client + memory-formatting helpers. Native `fetch`,
* zero dependencies. Pure (no `eve` import) so it can be unit-tested with a
* mocked `fetch`.
*
* Endpoints (tenant is the literal `default`, bank is in the path):
* recall: POST /v1/default/banks/{bank}/memories/recall
* retain: POST /v1/default/banks/{bank}/memories
*/
export type RecallBudget = "low" | "mid" | "high";
/** One memory returned by recall. The content lives in `text`. */
export interface RecallResult {
id: string;
text: string;
type?: string;
context?: string;
tags?: string[];
}
export interface RecallResponse {
results: RecallResult[];
}
/** One item to retain. `content` is the only required field. */
export interface RetainItem {
content: string;
context?: string;
metadata?: Record<string, string>;
/** ISO-8601, `"unset"`, or null (= now). */
timestamp?: string | null;
}
export interface RecallOptions {
budget?: RecallBudget;
maxTokens?: number;
types?: Array<"world" | "experience" | "observation">;
}
/**
* Markers that fence the recalled-context block injected as a system message.
* Used by {@link buildRecallMarkdown} (to wrap) and {@link stripSentinelBlocks}
* (to ensure recalled facts are never re-retained).
*/
export const SENTINEL_OPEN = "<!-- hindsight:recalled-context -->";
export const SENTINEL_CLOSE = "<!-- /hindsight:recalled-context -->";
const SENTINEL_RE = new RegExp(`${SENTINEL_OPEN}[\\s\\S]*?${SENTINEL_CLOSE}`, "g");
/** Remove any injected recalled-context block from text (defensive de-dup guard). */
export function stripSentinelBlocks(text: string): string {
return text.replace(SENTINEL_RE, "").trim();
}
/**
* Render recalled memories as a system-message markdown block, fenced with the
* sentinel markers so the retain side can recognize and exclude it.
*/
export function buildRecallMarkdown(results: readonly RecallResult[]): string {
if (results.length === 0) return "";
const lines = results.map((r) => `- ${r.text}`).join("\n");
return [
SENTINEL_OPEN,
"## What you already know about this user (from long-term memory)",
"Use this context to tailor your response. Do not repeat it back verbatim.",
"",
lines,
SENTINEL_CLOSE,
].join("\n");
}
/** Thin HTTP client for Hindsight's memory REST API. */
export class HindsightRestClient {
private readonly baseUrl: string;
private readonly token: string | null;
private readonly timeoutMs: number;
constructor(baseUrl: string, token?: string | null, timeoutMs = 15_000) {
const url = (baseUrl ?? "").trim();
if (!url) throw new Error("Hindsight API URL is required");
this.baseUrl = url.replace(/\/$/, "");
this.token = token ?? null;
this.timeoutMs = timeoutMs;
}
private headers(): Record<string, string> {
const h: Record<string, string> = { "Content-Type": "application/json" };
if (this.token) h["Authorization"] = `Bearer ${this.token}`;
return h;
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const resp = await fetch(`${this.baseUrl}${path}`, {
method,
headers: this.headers(),
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(`Hindsight HTTP ${resp.status} from ${path}: ${text}`);
}
return (await resp.json()) as T;
} finally {
clearTimeout(timer);
}
}
/** Recall memories for a bank. `query` is required by the API. */
async recall(bankId: string, query: string, opts: RecallOptions = {}): Promise<RecallResponse> {
const path = `/v1/default/banks/${encodeURIComponent(bankId)}/memories/recall`;
const body: Record<string, unknown> = {
query,
budget: opts.budget ?? "mid",
max_tokens: opts.maxTokens ?? 1024,
};
if (opts.types) body["types"] = opts.types;
return this.request<RecallResponse>("POST", path, body);
}
/** Retain items into a bank. The bank is auto-created on first retain. */
async retain(
bankId: string,
items: readonly RetainItem[],
opts: { async?: boolean } = {}
): Promise<void> {
const path = `/v1/default/banks/${encodeURIComponent(bankId)}/memories`;
await this.request("POST", path, { items, async: opts.async ?? true });
}
}
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import {
HINDSIGHT_CLOUD_API_URL,
DEFAULT_RECALL_QUERY,
buildRetainContent,
recordAssistantMessage,
recordUserMessage,
resolveAutoMemory,
takeTurn,
type TurnBuffer,
} from "./config";
import { buildRecallMarkdown } from "./client";
const EMPTY_ENV = {} as NodeJS.ProcessEnv;
describe("resolveAutoMemory", () => {
it("defaults to Hindsight Cloud + the broad recall query", () => {
const r = resolveAutoMemory({ apiKey: "hsk_k" }, EMPTY_ENV);
expect(r.apiUrl).toBe(HINDSIGHT_CLOUD_API_URL);
expect(r.bankId).toBe("default");
expect(r.recallQuery).toBe(DEFAULT_RECALL_QUERY);
expect(r.budget).toBe("mid");
});
it("reads url/key/bank from the environment", () => {
const r = resolveAutoMemory({}, {
HINDSIGHT_API_URL: "http://localhost:8000",
HINDSIGHT_API_KEY: "env_key",
HINDSIGHT_BANK_ID: "project-x",
} as NodeJS.ProcessEnv);
expect(r.apiUrl).toBe("http://localhost:8000");
expect(r.apiKey).toBe("env_key");
expect(r.bankId).toBe("project-x");
});
it("prefers explicit options over the environment", () => {
const r = resolveAutoMemory({ apiUrl: "http://opt", apiKey: "opt_key", bankId: "opt_bank" }, {
HINDSIGHT_API_URL: "http://env",
HINDSIGHT_API_KEY: "env_key",
HINDSIGHT_BANK_ID: "env_bank",
} as NodeJS.ProcessEnv);
expect(r.apiUrl).toBe("http://opt");
expect(r.apiKey).toBe("opt_key");
expect(r.bankId).toBe("opt_bank");
});
it("treats apiKey: null as a no-auth opt-out", () => {
const r = resolveAutoMemory({ apiUrl: "http://localhost:8000", apiKey: null }, EMPTY_ENV);
expect(r.apiKey).toBeNull();
});
it("throws when targeting Hindsight Cloud without a key", () => {
expect(() => resolveAutoMemory({}, EMPTY_ENV)).toThrow(/API key/);
});
it("allows a self-hosted url with no auth", () => {
const r = resolveAutoMemory({ apiUrl: "http://localhost:8000", apiKey: null }, EMPTY_ENV);
expect(r.apiUrl).toBe("http://localhost:8000");
});
});
describe("turn pairing buffer", () => {
it("stores both the user message and assistant reply by default", () => {
const buf: TurnBuffer = new Map();
recordUserMessage(buf, "t1", "I prefer tabs");
recordAssistantMessage(buf, "t1", "Noted.");
const pair = takeTurn(buf, "t1");
expect(buf.has("t1")).toBe(false); // taken
expect(buildRetainContent(pair)).toBe("User: I prefer tabs\n\nAssistant: Noted.");
});
it("drops the assistant reply when includeAssistant is false", () => {
const buf: TurnBuffer = new Map();
recordUserMessage(buf, "t1", "I prefer tabs");
recordAssistantMessage(buf, "t1", "Noted.");
expect(buildRetainContent(takeTurn(buf, "t1"), false)).toBe("User: I prefer tabs");
});
it("skips a turn with no user text", () => {
const buf: TurnBuffer = new Map();
recordAssistantMessage(buf, "t1", "hello");
expect(buildRetainContent(takeTurn(buf, "t1"))).toBeNull();
expect(buildRetainContent(undefined)).toBeNull();
});
it("keeps just the user text when there is no assistant answer", () => {
const buf: TurnBuffer = new Map();
recordUserMessage(buf, "t1", "remember this");
expect(buildRetainContent(takeTurn(buf, "t1"))).toBe("User: remember this");
});
it("strips injected recalled-context from the assistant half when included", () => {
const buf: TurnBuffer = new Map();
const recalled = buildRecallMarkdown([{ id: "1", text: "user is vegan" }]);
recordUserMessage(buf, "t1", "what's for dinner?");
recordAssistantMessage(buf, "t1", `${recalled}\nHow about pasta?`);
const content = buildRetainContent(takeTurn(buf, "t1"), true);
expect(content).toContain("what's for dinner?");
expect(content).toContain("How about pasta?");
expect(content).not.toContain("user is vegan");
});
});
+170
View File
@@ -0,0 +1,170 @@
/**
* Pure config resolution + turn-pairing helpers for auto-memory. No `eve`
* import, so precedence rules and the retain buffer are unit-testable without
* the framework.
*/
import { stripSentinelBlocks, type RecallBudget } from "./client.js";
/** Hindsight Cloud REST base, used when no API URL is configured. */
export const HINDSIGHT_CLOUD_API_URL = "https://api.hindsight.vectorize.io";
/** Default broad recall query — surfaces the user's ambient profile/context. */
export const DEFAULT_RECALL_QUERY = "user preferences, identity, and working context";
/** Default bank when none is configured (Hindsight auto-creates it). */
export const DEFAULT_BANK_ID = "default";
export interface AutoMemoryOptions {
/** Hindsight REST base URL. Defaults to `HINDSIGHT_API_URL`, then Cloud. */
apiUrl?: string;
/**
* API key sent as `Authorization: Bearer <key>`. Defaults to `HINDSIGHT_API_KEY`.
* Pass `null` for a no-auth self-hosted server.
*/
apiKey?: string | null;
/** Bank to scope memory to (REST path). Defaults to `HINDSIGHT_BANK_ID`, then `"default"`. */
bankId?: string;
/** Broad query used for each turn's recall injection. */
recallQuery?: string;
/** Recall result budget. Defaults to `"mid"`. */
budget?: RecallBudget;
/** Recall token budget. Defaults to `1024`. */
maxTokens?: number;
/** `context` tag written on retained items. Defaults to `"eve"`. */
context?: string;
/**
* Also store the assistant's reply, not just the user's message. On by
* default the assistant's reply is usually where the answer lives (the
* decision, the solution, the code it wrote), so both halves of the turn are
* worth remembering. Set to `false` to retain only the user's message.
*/
includeAssistantReply?: boolean;
/** HTTP timeout in ms. Defaults to `15000`. */
timeoutMs?: number;
/** Called when a recall/retain HTTP call fails. Defaults to `console.warn`. */
onError?: (error: unknown, phase: "recall" | "retain") => void;
}
export interface ResolvedAutoMemory {
apiUrl: string;
apiKey: string | null;
bankId: string;
recallQuery: string;
budget: RecallBudget;
maxTokens: number;
context: string;
includeAssistantReply: boolean;
timeoutMs: number;
onError: (error: unknown, phase: "recall" | "retain") => void;
}
/** First non-empty string among the candidates, or `null`. */
function firstNonEmpty(...values: Array<string | null | undefined>): string | null {
for (const value of values) {
if (typeof value === "string" && value.length > 0) return value;
}
return null;
}
/**
* Whether a URL points at Hindsight Cloud, matched on host (so a trailing slash
* or regional subdomain still triggers the missing-key guard). The dot boundary
* avoids matching look-alikes like `nothindsight.vectorize.io`.
*/
export function isHindsightCloudUrl(url: string): boolean {
try {
const host = new URL(url).hostname;
return host === "hindsight.vectorize.io" || host.endsWith(".hindsight.vectorize.io");
} catch {
return false;
}
}
/** Resolve options against env defaults. Pure; throws on Cloud + no key. */
export function resolveAutoMemory(
options: AutoMemoryOptions = {},
env: NodeJS.ProcessEnv = process.env
): ResolvedAutoMemory {
const apiUrl = options.apiUrl ?? firstNonEmpty(env.HINDSIGHT_API_URL) ?? HINDSIGHT_CLOUD_API_URL;
// `apiKey: null` is an explicit no-auth opt-out; `undefined` falls back to the env var.
const apiKey =
options.apiKey === undefined ? firstNonEmpty(env.HINDSIGHT_API_KEY) : options.apiKey;
if (isHindsightCloudUrl(apiUrl) && !apiKey) {
throw new Error(
"Hindsight Cloud requires an API key. Set HINDSIGHT_API_KEY, pass `apiKey`, or point " +
"`apiUrl`/HINDSIGHT_API_URL at a self-hosted server (use `apiKey: null` for a no-auth server)."
);
}
return {
apiUrl,
apiKey,
bankId: options.bankId ?? firstNonEmpty(env.HINDSIGHT_BANK_ID) ?? DEFAULT_BANK_ID,
recallQuery: options.recallQuery ?? DEFAULT_RECALL_QUERY,
budget: options.budget ?? "mid",
maxTokens: options.maxTokens ?? 1024,
context: options.context ?? "eve",
includeAssistantReply: options.includeAssistantReply ?? true,
timeoutMs: options.timeoutMs ?? 15_000,
// eslint-disable-next-line no-console
onError: options.onError ?? ((error) => console.warn("[hindsight-eve] memory error:", error)),
};
}
// ---------------------------------------------------------------------------
// Turn pairing buffer — collects the user message and assistant answer for a
// turn so they can be retained together once the turn completes.
// ---------------------------------------------------------------------------
export interface TurnPair {
user?: string;
assistant?: string;
}
export type TurnBuffer = Map<string, TurnPair>;
/** Hard cap so a long-lived worker never leaks turns whose flush was missed. */
const MAX_BUFFERED_TURNS = 256;
function upsert(buffer: TurnBuffer, turnId: string, patch: TurnPair): void {
const existing = buffer.get(turnId) ?? {};
buffer.set(turnId, { ...existing, ...patch });
if (buffer.size > MAX_BUFFERED_TURNS) {
const oldest = buffer.keys().next().value;
if (oldest !== undefined) buffer.delete(oldest);
}
}
export function recordUserMessage(buffer: TurnBuffer, turnId: string, text: string): void {
upsert(buffer, turnId, { user: text });
}
export function recordAssistantMessage(buffer: TurnBuffer, turnId: string, text: string): void {
upsert(buffer, turnId, { assistant: text });
}
/** Remove and return a turn's buffered pair (used at flush time). */
export function takeTurn(buffer: TurnBuffer, turnId: string): TurnPair | undefined {
const pair = buffer.get(turnId);
buffer.delete(turnId);
return pair;
}
/**
* Build the retain `content` for a turn, or `null` to skip. Skips turns with no
* user text. By default appends the assistant reply (that is usually where the
* answer lives) with any injected recalled-context block stripped out so
* recalled facts are never re-retained; pass `includeAssistant: false` to store
* only the user's message.
*/
export function buildRetainContent(
pair: TurnPair | undefined,
includeAssistant = true
): string | null {
const user = (pair?.user ?? "").trim();
if (!user) return null;
if (!includeAssistant) return `User: ${user}`;
const assistant = stripSentinelBlocks(pair?.assistant ?? "").trim();
return assistant ? `User: ${user}\n\nAssistant: ${assistant}` : `User: ${user}`;
}
@@ -1,154 +0,0 @@
import { describe, it, expect } from "vitest";
import { once } from "eve/tools/approval";
import {
resolveHindsightConnection,
buildHindsightConnectionDefinition,
defineHindsightConnection,
HINDSIGHT_CLOUD_MCP_URL,
DEFAULT_DESCRIPTION,
} from "./index";
const EMPTY_ENV = {} as NodeJS.ProcessEnv;
describe("resolveHindsightConnection", () => {
it("defaults to Hindsight Cloud with the default description", () => {
const resolved = resolveHindsightConnection({ apiKey: "hsk_test" }, EMPTY_ENV);
expect(resolved.url).toBe(HINDSIGHT_CLOUD_MCP_URL);
expect(resolved.description).toBe(DEFAULT_DESCRIPTION);
expect(resolved.apiKey).toBe("hsk_test");
expect(resolved.bankId).toBeNull();
});
it("reads url, key, and bank from the environment", () => {
const resolved = resolveHindsightConnection({}, {
HINDSIGHT_MCP_URL: "http://localhost:8000/mcp",
HINDSIGHT_API_KEY: "env_key",
HINDSIGHT_MCP_BANK_ID: "project-x",
} as NodeJS.ProcessEnv);
expect(resolved.url).toBe("http://localhost:8000/mcp");
expect(resolved.apiKey).toBe("env_key");
expect(resolved.bankId).toBe("project-x");
});
it("prefers explicit options over the environment", () => {
const resolved = resolveHindsightConnection(
{ url: "http://opt/mcp", apiKey: "opt_key", bankId: "opt_bank" },
{
HINDSIGHT_MCP_URL: "http://env/mcp",
HINDSIGHT_API_KEY: "env_key",
HINDSIGHT_MCP_BANK_ID: "env_bank",
} as NodeJS.ProcessEnv
);
expect(resolved.url).toBe("http://opt/mcp");
expect(resolved.apiKey).toBe("opt_key");
expect(resolved.bankId).toBe("opt_bank");
});
it("treats apiKey: null as an explicit no-auth opt-out", () => {
const resolved = resolveHindsightConnection(
{ url: "http://localhost:8000/mcp", apiKey: null },
{ HINDSIGHT_API_KEY: "env_key" } as NodeJS.ProcessEnv
);
expect(resolved.apiKey).toBeNull();
});
it("throws when targeting Hindsight Cloud without a key", () => {
expect(() => resolveHindsightConnection({}, EMPTY_ENV)).toThrow(/API key/);
});
it("throws for a Cloud URL with a trailing slash and no key", () => {
expect(() =>
resolveHindsightConnection({ url: "https://api.hindsight.vectorize.io/mcp/" }, EMPTY_ENV)
).toThrow(/API key/);
});
it("throws for a regional Cloud subdomain with no key", () => {
expect(() =>
resolveHindsightConnection({ url: "https://api.eu.hindsight.vectorize.io/mcp" }, EMPTY_ENV)
).toThrow(/API key/);
});
it("does not treat a look-alike host as Cloud", () => {
// `nothindsight.vectorize.io` must not match the Cloud guard, so a no-auth
// self-hosted server on a similar domain is allowed.
const resolved = resolveHindsightConnection(
{ url: "https://nothindsight.vectorize.io/mcp", apiKey: null },
EMPTY_ENV
);
expect(resolved.apiKey).toBeNull();
});
it("allows a self-hosted url with no auth", () => {
const resolved = resolveHindsightConnection(
{ url: "http://localhost:8000/mcp", apiKey: null },
EMPTY_ENV
);
expect(resolved.url).toBe("http://localhost:8000/mcp");
expect(resolved.apiKey).toBeNull();
});
it("ignores empty-string environment values", () => {
const resolved = resolveHindsightConnection({ apiKey: "k" }, {
HINDSIGHT_MCP_URL: "",
} as NodeJS.ProcessEnv);
expect(resolved.url).toBe(HINDSIGHT_CLOUD_MCP_URL);
});
it("passes tool filters through unchanged", () => {
const resolved = resolveHindsightConnection(
{ apiKey: "k", tools: { allow: ["recall", "retain"] } },
EMPTY_ENV
);
expect(resolved.tools).toEqual({ allow: ["recall", "retain"] });
});
});
describe("buildHindsightConnectionDefinition", () => {
it("wires bearer auth whose getToken returns the configured key", async () => {
const definition = buildHindsightConnectionDefinition(
resolveHindsightConnection({ url: "http://localhost:8000/mcp", apiKey: "k" }, EMPTY_ENV)
);
expect(definition.url).toBe("http://localhost:8000/mcp");
const auth = definition.auth as { getToken: () => Promise<{ token: string }> };
expect(await auth.getToken()).toEqual({ token: "k" });
});
it("emits no auth when the key is null", () => {
const definition = buildHindsightConnectionDefinition(
resolveHindsightConnection({ url: "http://localhost:8000/mcp", apiKey: null }, EMPTY_ENV)
);
expect(definition.auth).toBeUndefined();
});
it("sets the X-Bank-Id header when a bank is configured", () => {
const definition = buildHindsightConnectionDefinition(
resolveHindsightConnection({ apiKey: "k", bankId: "project-x" }, EMPTY_ENV)
);
expect(definition.headers).toEqual({ "X-Bank-Id": "project-x" });
});
it("passes the approval policy through unchanged", () => {
const approval = once();
const definition = buildHindsightConnectionDefinition(
resolveHindsightConnection({ apiKey: "k", approval }, EMPTY_ENV)
);
expect(definition.approval).toBe(approval);
});
it("omits approval when none is configured", () => {
const definition = buildHindsightConnectionDefinition(
resolveHindsightConnection({ apiKey: "k" }, EMPTY_ENV)
);
expect(definition.approval).toBeUndefined();
});
});
describe("defineHindsightConnection", () => {
it("builds a connection via the real eve framework without throwing", () => {
const connection = defineHindsightConnection({
url: "http://localhost:8000/mcp",
apiKey: "k",
});
expect(connection).toBeDefined();
});
});
+38 -141
View File
@@ -1,149 +1,46 @@
/**
* Hindsight long-term memory for Vercel Eve agents.
* Hindsight long-term memory for Vercel Eve agents automatic, no-tool memory.
*
* Wraps eve's `defineMcpClientConnection` so an agent gains persistent memory by
* dropping a single file under `agent/connections/`. The helper fills in the
* Hindsight MCP endpoint, a model-facing description, and bearer auth, reading
* sensible defaults from the environment:
* Two authored files give an Eve agent memory that just works, without the model
* ever deciding to call a tool: relevant memory is injected before each turn, and
* the exchange is retained after.
*
* ```ts
* // agent/connections/hindsight.ts
* import { defineHindsightConnection } from "@vectorize-io/hindsight-eve";
* export default defineHindsightConnection(); // HINDSIGHT_MCP_URL + HINDSIGHT_API_KEY
* // agent/instructions/hindsight.ts
* import { hindsightMemory } from "@vectorize-io/hindsight-eve";
* export default hindsightMemory();
*
* // agent/hooks/hindsight.ts
* import { hindsightRetainHook } from "@vectorize-io/hindsight-eve";
* export default hindsightRetainHook();
* ```
*
* Configure via env: `HINDSIGHT_API_KEY`, `HINDSIGHT_API_URL` (defaults to
* Hindsight Cloud), `HINDSIGHT_BANK_ID`.
*/
import { defineMcpClientConnection } from "eve/connections";
export {
hindsightMemory,
hindsightAutoRecall,
hindsightRetainHook,
type AutoMemoryOptions,
} from "./auto-memory.js";
/** The argument eve's connection factory accepts; options pass straight through. */
type McpConnectionInput = Parameters<typeof defineMcpClientConnection>[0];
export {
resolveAutoMemory,
isHindsightCloudUrl,
HINDSIGHT_CLOUD_API_URL,
DEFAULT_RECALL_QUERY,
DEFAULT_BANK_ID,
type ResolvedAutoMemory,
} from "./config.js";
/** Hindsight Cloud MCP endpoint, used when no URL is configured. */
export const HINDSIGHT_CLOUD_MCP_URL = "https://api.hindsight.vectorize.io/mcp";
/**
* Default model-facing description written into the generated connection. Eve
* surfaces it when the agent discovers this connection's tools
* (`connection__hindsight__retain` / `recall` / `reflect`).
*/
export const DEFAULT_DESCRIPTION =
"Hindsight long-term memory: retain facts from this session, recall relevant history " +
"from past sessions, and reflect over consolidated mental models.";
export interface HindsightConnectionOptions {
/** Hindsight MCP endpoint. Defaults to `HINDSIGHT_MCP_URL`, then Hindsight Cloud. */
url?: string;
/**
* API key sent as `Authorization: Bearer <key>`. Defaults to `HINDSIGHT_API_KEY`.
* Pass `null` to emit a no-auth connection (local/self-hosted dev only).
*/
apiKey?: string | null;
/** Bank to scope memory to; sent as the `X-Bank-Id` header. Defaults to `HINDSIGHT_MCP_BANK_ID`. */
bankId?: string;
/** Override the model-facing description. */
description?: string;
/** Restrict which Hindsight tools the model can see. */
tools?: McpConnectionInput["tools"];
/** Human-in-the-loop approval policy (e.g. `once()` from `eve/tools/approval`). */
approval?: McpConnectionInput["approval"];
}
/** Fully-resolved connection settings, after applying options and environment defaults. */
export interface ResolvedHindsightConnection {
url: string;
description: string;
apiKey: string | null;
bankId: string | null;
tools?: McpConnectionInput["tools"];
approval?: McpConnectionInput["approval"];
}
/** First non-empty string among the candidates, or `null`. */
function firstNonEmpty(...values: Array<string | null | undefined>): string | null {
for (const value of values) {
if (typeof value === "string" && value.length > 0) return value;
}
return null;
}
/**
* Whether a URL points at Hindsight Cloud. Matched on host (not exact string)
* so a trailing slash, `http`/`https`, or a regional subdomain still triggers
* the missing-key guard below instead of letting the request fail with a raw
* 401. The dot boundary keeps it from matching look-alike hosts like
* `nothindsight.vectorize.io`.
*/
function isHindsightCloudUrl(url: string): boolean {
try {
const host = new URL(url).hostname;
return host === "hindsight.vectorize.io" || host.endsWith(".hindsight.vectorize.io");
} catch {
return false;
}
}
/**
* Resolve options against environment defaults. Pure and side-effect free so the
* precedence rules can be unit-tested without constructing a live connection.
*/
export function resolveHindsightConnection(
options: HindsightConnectionOptions = {},
env: NodeJS.ProcessEnv = process.env
): ResolvedHindsightConnection {
const url = options.url ?? firstNonEmpty(env.HINDSIGHT_MCP_URL) ?? HINDSIGHT_CLOUD_MCP_URL;
// `apiKey: null` is an explicit no-auth opt-out; `undefined` falls back to the env var.
const apiKey =
options.apiKey === undefined ? firstNonEmpty(env.HINDSIGHT_API_KEY) : options.apiKey;
const bankId = options.bankId ?? firstNonEmpty(env.HINDSIGHT_MCP_BANK_ID);
if (isHindsightCloudUrl(url) && !apiKey) {
throw new Error(
"Hindsight Cloud requires an API key. Set HINDSIGHT_API_KEY, pass `apiKey`, or point " +
"`url`/HINDSIGHT_MCP_URL at a self-hosted server (use `apiKey: null` for a no-auth server)."
);
}
return {
url,
description: options.description ?? DEFAULT_DESCRIPTION,
apiKey,
bankId,
tools: options.tools,
approval: options.approval,
};
}
/**
* Build the plain definition object handed to eve. Kept separate from
* {@link defineHindsightConnection} so the auth/header wiring is testable without
* depending on the shape of eve's returned connection.
*/
export function buildHindsightConnectionDefinition(
resolved: ResolvedHindsightConnection
): McpConnectionInput {
return {
url: resolved.url,
description: resolved.description,
// `{ token }` is eve's TokenResult shape (sent as `Authorization: Bearer`).
// It rides on eve 0.11's auth contract, which the pinned peer/dev dep covers.
...(resolved.apiKey
? { auth: { getToken: async () => ({ token: resolved.apiKey as string }) } }
: {}),
...(resolved.bankId ? { headers: { "X-Bank-Id": resolved.bankId } } : {}),
...(resolved.tools ? { tools: resolved.tools } : {}),
...(resolved.approval ? { approval: resolved.approval } : {}),
};
}
/**
* Define an eve MCP connection to a Hindsight memory server. Export the result as
* the default from `agent/connections/hindsight.ts`.
*/
export function defineHindsightConnection(options: HindsightConnectionOptions = {}) {
return defineMcpClientConnection(
buildHindsightConnectionDefinition(resolveHindsightConnection(options))
);
}
export default defineHindsightConnection;
export {
HindsightRestClient,
buildRecallMarkdown,
stripSentinelBlocks,
type RecallResult,
type RecallResponse,
type RetainItem,
type RecallBudget,
type RecallOptions,
} from "./client.js";
+1 -7
View File
@@ -9,10 +9,10 @@
"version": "0.2.6",
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "^1.3.13",
"@vectorize-io/hindsight-client": "^0.4.19"
},
"devDependencies": {
"@opencode-ai/plugin": "^1.3.13",
"@types/node": "^22.0.0",
"tsup": "^8.5.1",
"typescript": "^5.7.0",
@@ -20,9 +20,6 @@
},
"engines": {
"node": ">=22"
},
"peerDependencies": {
"@opencode-ai/plugin": ">=1.0.0"
}
},
"node_modules/@emnapi/core": {
@@ -563,7 +560,6 @@
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.13.tgz",
"integrity": "sha512-zHgtWfdDz8Wu8srE8f8HUtPT9i6c3jTmgQKoFZUZ+RR5CMQF1kAlb1cxeEe9Xm2DRNFVJog9Cv/G1iUHYgXSUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.3.13",
@@ -586,7 +582,6 @@
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.13.tgz",
"integrity": "sha512-/M6HlNnba+xf1EId6qFb2tG0cvq0db3PCQDug1glrf8wYOU57LYNF8WvHX9zoDKPTMv0F+O4pcP/8J+WvDaxHA==",
"dev": true,
"license": "MIT"
},
"node_modules/@oxc-project/types": {
@@ -2675,7 +2670,6 @@
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
"integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
+2 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/opencode-hindsight",
"version": "0.2.6",
"version": "0.2.7",
"description": "Hindsight memory plugin for OpenCode - Give your AI coding agent persistent long-term memory",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -41,14 +41,11 @@
"test:e2e": "HINDSIGHT_LIVE_E2E=1 vitest run",
"prepublishOnly": "npm run clean && npm run build"
},
"peerDependencies": {
"@opencode-ai/plugin": ">=1.0.0"
},
"dependencies": {
"@opencode-ai/plugin": "^1.3.13",
"@vectorize-io/hindsight-client": "^0.4.19"
},
"devDependencies": {
"@opencode-ai/plugin": "^1.3.13",
"@types/node": "^22.0.0",
"tsup": "^8.5.1",
"typescript": "^5.7.0",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@vectorize-io/hindsight-agent-sdk",
"version": "0.1.0",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@vectorize-io/hindsight-agent-sdk",
"version": "0.1.0",
"version": "0.1.1",
"license": "MIT",
"dependencies": {
"@vectorize-io/hindsight-client": "^0.6.2"
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-agent-sdk",
"version": "0.1.0",
"version": "0.1.1",
"description": "Agent knowledge tools powered by Hindsight — harness-agnostic SDK",
"type": "module",
"main": "dist/index.js",
-43
View File
@@ -369,14 +369,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",
@@ -386,8 +384,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",
@@ -8933,39 +8929,6 @@
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
"integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
@@ -10913,12 +10876,6 @@
"@types/node": "*"
}
},
"node_modules/@types/cytoscape": {
"version": "3.21.9",
"resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.9.tgz",
"integrity": "sha512-JyrG4tllI6jvuISPjHK9j2Xv/LTbnLekLke5otGStjFluIyA9JjgnvgZrSBsp8cEDpiTjwgZUZwpPv8TSBcoLw==",
"license": "MIT"
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -8,6 +8,13 @@ import PageHero from '@site/src/components/PageHero';
← Claude Code integration
## [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"}}>@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"}}>@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"}}>@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"}}>@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](../index.md)
## [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"}}>@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"}}>@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](../index.md)
## [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"}}>@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"}}>@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"}}>@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**
@@ -17,7 +17,7 @@ A **memory unit** is the atomic fact Hindsight extracts and stores. This page co
## 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=`.
### Python
@@ -57,6 +57,8 @@ curl -s "$HINDSIGHT_URL/v1/default/banks/$BANK_ID/memories/list?state=invalidate
## Fetch a single memory unit
Fetch a memory unit by ID, including its content, metadata, entities, timestamps, tags, and curation state.
### Python
```python
@@ -66,7 +68,7 @@ curl -s "$HINDSIGHT_URL/v1/default/banks/$BANK_ID/memories/list?state=invalidate
### Node.js
```javascript
// 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();
@@ -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"
}