Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59d825dfca | ||
|
|
ae7099fd02 | ||
|
|
56db6d7cf6 | ||
|
|
0eb52762ae | ||
|
|
e93c560288 | ||
|
|
1f213d00f7 | ||
|
|
8f51f99dde | ||
|
|
5cc1482a72 | ||
|
|
639d84ad32 | ||
|
|
1c74f795a6 | ||
|
|
b4f9fbe1b5 | ||
|
|
b992ba996d | ||
|
|
f00d3c7f66 | ||
|
|
e97b615547 | ||
|
|
29cc1d7fdc | ||
|
|
016b5f0363 | ||
|
|
dd7e252452 | ||
|
|
fda1a77f70 | ||
|
|
0accef8e98 | ||
|
|
c77e2368de | ||
|
|
38ef0247c2 | ||
|
|
a158b819f3 | ||
|
|
381963c28a | ||
|
|
ba158c9cdb | ||
|
|
767a2c0061 | ||
|
|
36334f27a1 | ||
|
|
6a479dddb9 | ||
|
|
7058d1aad7 |
@@ -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)
|
||||
@@ -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]}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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](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
|
||||
---
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
## 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?"
|
||||
|
||||

|
||||
|
||||
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
|
||||
---
|
||||
|
||||

|
||||
|
||||
[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.
|
||||
|
||||

|
||||
|
||||
## 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."
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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']}")
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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 assistant’s 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**
|
||||
|
||||
|
After Width: | Height: | Size: 612 KiB |
|
After Width: | Height: | Size: 589 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 610 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 assistant’s 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"
|
||||
}
|
||||
|
||||