Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e065f7516 | ||
|
|
0338ab4d73 | ||
|
|
f4106fff55 | ||
|
|
d4f700ed22 | ||
|
|
041f0f13f4 | ||
|
|
9dee1c594b | ||
|
|
a1ebb2d9c6 | ||
|
|
06ddf041e5 | ||
|
|
d251fcb7d2 | ||
|
|
e839c65537 | ||
|
|
0cde79b831 | ||
|
|
8767a518db | ||
|
|
10ed288d80 | ||
|
|
7143684a81 | ||
|
|
11da432db2 | ||
|
|
73d3231bbd | ||
|
|
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"
|
||||
|
||||
@@ -115,6 +115,11 @@ HINDSIGHT_API_LOG_LEVEL=info
|
||||
# chinese_lindera/lindera(chinese), japanese_lindera/lindera(japanese),
|
||||
# korean_lindera/lindera(korean), ngram(min,max), edge_ngram(min,max)
|
||||
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER=
|
||||
# Optional cap on the number of terms in the native PostgreSQL BM25 tsquery.
|
||||
# Long queries OR-join every normalized token, which can match too much of a
|
||||
# large bank. 0 (default) keeps the historical uncapped behavior; a positive
|
||||
# value bounds only the native backend (other BM25 backends get the raw query).
|
||||
# HINDSIGHT_API_BM25_MAX_QUERY_TERMS=0
|
||||
|
||||
# File Parser (Optional - uses markitdown by default)
|
||||
# HINDSIGHT_API_FILE_PARSER=markitdown
|
||||
|
||||
@@ -520,22 +520,17 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install package and pytest
|
||||
working-directory: ./hindsight-integrations/zed
|
||||
# Installs the package (incl. the zstandard runtime dep) so the threads.db
|
||||
# reader tests can decompress Zed's zstd blobs.
|
||||
run: pip install -e . pytest
|
||||
node-version: '22'
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/zed
|
||||
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
|
||||
# (requires_real_llm) needs a live Hindsight server and runs separately.
|
||||
run: python -m pytest tests/ -v -m "not requires_real_llm"
|
||||
# Config-only integration with no dependencies — it uses Node's built-in
|
||||
# test runner. The runtime MCP bridge is `npx mcp-remote` (Node), so this
|
||||
# integration requires only Node.js (no Python).
|
||||
run: npm test
|
||||
|
||||
test-omo-integration:
|
||||
needs: [detect-changes]
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Drop the search_vector column from the curation archive (invalidated_memory_units).
|
||||
|
||||
The archive is cold storage, never a recall surface, and carries no text-search
|
||||
index. Like ``embedding`` (dropped in d4f6a8c2e1b3), ``search_vector`` is a
|
||||
recall-surface column whose type follows the configured text-search backend, so
|
||||
it has no business living on the archive. Earlier curation code copied the live
|
||||
row's ``search_vector`` into ``invalidated_memory_units`` on invalidate; the
|
||||
engine now leaves it out on invalidate and recomputes it on revert, so the
|
||||
column is dead weight.
|
||||
|
||||
Dropping it removes a latent failure mode (#2503): under a non-native backend
|
||||
(pgroonga / pg_textsearch / pg_search / vchord) ``ensure_text_search_extension``
|
||||
reconciles ``memory_units.search_vector`` to ``text`` / ``bm25vector`` but never
|
||||
touched the archive, which the ``LIKE memory_units`` clone (c9a1b2d3e4f5) created
|
||||
as ``tsvector``. The type mismatch then broke the curation INSERT … SELECT
|
||||
round-trip:
|
||||
|
||||
column "search_vector" is of type tsvector but expression is of type text
|
||||
|
||||
With no column at all, there is nothing to mismatch. Unlike ``embedding`` (whose
|
||||
creation sites already omit it), the ``LIKE`` clone still adds ``search_vector``,
|
||||
so this migration does real work on both fresh and existing PostgreSQL databases.
|
||||
|
||||
DROP COLUMN is a metadata-only operation on both PostgreSQL and Oracle 23ai (no
|
||||
table rewrite), so it is cheap even across many tenant schemas. The downgrade
|
||||
re-adds an empty ``tsvector`` column (its original creation type).
|
||||
|
||||
Revision ID: e7c3a9f1b2d5
|
||||
Revises: b57a7c9e0d13
|
||||
Create Date: 2026-07-02
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
from hindsight_api.alembic._dialect import run_for_dialect
|
||||
|
||||
revision: str = "e7c3a9f1b2d5"
|
||||
down_revision: str | Sequence[str] | None = "b57a7c9e0d13"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _pg_schema_prefix() -> str:
|
||||
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def _pg_upgrade() -> None:
|
||||
schema = _pg_schema_prefix()
|
||||
op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS search_vector")
|
||||
|
||||
|
||||
def _pg_downgrade() -> None:
|
||||
schema = _pg_schema_prefix()
|
||||
# Re-add as the original tsvector creation type; comes back empty regardless.
|
||||
op.execute(f"ALTER TABLE {schema}invalidated_memory_units ADD COLUMN IF NOT EXISTS search_vector tsvector")
|
||||
|
||||
|
||||
def _oracle_upgrade() -> None:
|
||||
# Oracle has no `DROP COLUMN IF EXISTS`; swallow ORA-00904 (column does not
|
||||
# exist) so the migration is idempotent and safe on a schema whose baseline
|
||||
# may already omit the column.
|
||||
op.execute(
|
||||
"""
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units DROP COLUMN search_vector';
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
IF SQLCODE != -904 THEN RAISE; END IF;
|
||||
END;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _oracle_downgrade() -> None:
|
||||
# Swallow ORA-01430 (column already exists) for idempotency. Oracle stores
|
||||
# search_vector as CLOB (see the Oracle baseline), so re-add it as CLOB.
|
||||
op.execute(
|
||||
"""
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units ADD (search_vector CLOB)';
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
IF SQLCODE != -1430 THEN RAISE; END IF;
|
||||
END;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
|
||||
@@ -52,6 +52,7 @@ from fastapi.routing import APIRoute
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from hindsight_api import MemoryEngine
|
||||
from hindsight_api.config import RETAIN_EXTRACTION_MODES
|
||||
|
||||
|
||||
def _annotation_is_nullable(annotation: Any) -> bool:
|
||||
@@ -1245,7 +1246,7 @@ class CreateBankRequest(BaseModel):
|
||||
)
|
||||
retain_extraction_mode: str | None = Field(
|
||||
default=None,
|
||||
description="Fact extraction mode: 'concise' (default), 'verbose', or 'custom'.",
|
||||
description="Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'.",
|
||||
)
|
||||
retain_custom_instructions: str | None = Field(
|
||||
default=None,
|
||||
@@ -1433,6 +1434,7 @@ class ListMemoryUnitsResponse(BaseModel):
|
||||
"date": "2024-01-15T10:30:00Z",
|
||||
"type": "world",
|
||||
"entities": "Alice (PERSON), Google (ORGANIZATION)",
|
||||
"metadata": {"source": "slack", "channel": "engineering"},
|
||||
}
|
||||
],
|
||||
"total": 150,
|
||||
@@ -1666,8 +1668,8 @@ class UpdateMemoryRequest(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_an_edit(self) -> "UpdateMemoryRequest":
|
||||
if all(
|
||||
v is None
|
||||
has_value_edit = any(
|
||||
v is not None
|
||||
for v in (
|
||||
self.text,
|
||||
self.context,
|
||||
@@ -1677,7 +1679,9 @@ class UpdateMemoryRequest(BaseModel):
|
||||
self.entities,
|
||||
self.state,
|
||||
)
|
||||
):
|
||||
)
|
||||
has_date_clear = bool({"occurred_start", "occurred_end"} & self.model_fields_set)
|
||||
if not has_value_edit and not has_date_clear:
|
||||
raise ValueError("Provide at least one field to update.")
|
||||
if self.state is not None and self.state not in ("valid", "invalidated"):
|
||||
raise ValueError("state must be 'valid' or 'invalidated'.")
|
||||
@@ -2203,7 +2207,8 @@ class BankTemplateConfig(BaseModel):
|
||||
reflect_mission: str | None = Field(default=None, description="Mission/context for Reflect operations")
|
||||
retain_mission: str | None = Field(default=None, description="Steers what gets extracted during retain")
|
||||
retain_extraction_mode: str | None = Field(
|
||||
default=None, description="Fact extraction mode: 'concise' (default), 'verbose', or 'custom'"
|
||||
default=None,
|
||||
description="Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'",
|
||||
)
|
||||
retain_custom_instructions: str | None = Field(
|
||||
default=None, description="Custom extraction prompt (when mode='custom')"
|
||||
@@ -2429,10 +2434,10 @@ def validate_bank_template(manifest: "BankTemplateManifest") -> list[str]:
|
||||
if manifest.bank:
|
||||
bank = manifest.bank
|
||||
if bank.retain_extraction_mode is not None:
|
||||
valid_modes = ("concise", "verbose", "custom", "chunks")
|
||||
if bank.retain_extraction_mode not in valid_modes:
|
||||
if bank.retain_extraction_mode not in RETAIN_EXTRACTION_MODES:
|
||||
errors.append(
|
||||
f"bank.retain_extraction_mode: must be one of {valid_modes}, got '{bank.retain_extraction_mode}'"
|
||||
"bank.retain_extraction_mode: "
|
||||
f"must be one of {RETAIN_EXTRACTION_MODES}, got '{bank.retain_extraction_mode}'"
|
||||
)
|
||||
if bank.retain_custom_instructions and bank.retain_extraction_mode != "custom":
|
||||
errors.append("bank.retain_custom_instructions: requires retain_extraction_mode='custom'")
|
||||
@@ -3750,13 +3755,23 @@ def _register_routes(app: FastAPI):
|
||||
):
|
||||
"""Curate a single memory unit (edit text / invalidate / revert)."""
|
||||
try:
|
||||
occurred_start = (
|
||||
""
|
||||
if "occurred_start" in request.model_fields_set and request.occurred_start is None
|
||||
else request.occurred_start
|
||||
)
|
||||
occurred_end = (
|
||||
""
|
||||
if "occurred_end" in request.model_fields_set and request.occurred_end is None
|
||||
else request.occurred_end
|
||||
)
|
||||
data = await app.state.memory.update_memory_unit(
|
||||
bank_id=bank_id,
|
||||
memory_id=memory_id,
|
||||
text=request.text,
|
||||
context=request.context,
|
||||
occurred_start=request.occurred_start,
|
||||
occurred_end=request.occurred_end,
|
||||
occurred_start=occurred_start,
|
||||
occurred_end=occurred_end,
|
||||
new_fact_type=request.fact_type,
|
||||
entities=request.entities,
|
||||
state=request.state,
|
||||
|
||||
@@ -638,6 +638,7 @@ ENV_RECALL_BUDGET_MAX = "HINDSIGHT_API_RECALL_BUDGET_MAX"
|
||||
|
||||
# Recall candidate gating (per-source cap + BM25 score floor)
|
||||
ENV_BM25_MIN_SCORE = "HINDSIGHT_API_BM25_MIN_SCORE"
|
||||
ENV_BM25_MAX_QUERY_TERMS = "HINDSIGHT_API_BM25_MAX_QUERY_TERMS"
|
||||
ENV_RECALL_MAX_CANDIDATES_PER_SOURCE = "HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE"
|
||||
# Per-strategy recall boost. Prioritises specific retrieval arms (semantic,
|
||||
# bm25, graph, temporal) on recall via a human priority level — e.g.
|
||||
@@ -789,6 +790,9 @@ DEFAULT_SEMANTIC_MIN_SIMILARITY = 0.3
|
||||
# zero-score (non-matching) rows on backends — notably VectorChord — whose
|
||||
# operator ranks every document rather than pre-filtering to term matches.
|
||||
DEFAULT_BM25_MIN_SCORE = 0.0
|
||||
# Native tsvector BM25 can optionally cap the OR tsquery built from normalized
|
||||
# query tokens. 0 preserves the historical uncapped behavior.
|
||||
DEFAULT_BM25_MAX_QUERY_TERMS = 0
|
||||
# Per-source candidate cap applied to each retrieval arm (semantic, BM25, graph,
|
||||
# temporal) before RRF, so a single over-expanding backend cannot fill the
|
||||
# reranker's global candidate budget on its own. 0 disables the cap.
|
||||
@@ -1209,6 +1213,19 @@ def _parse_positive_int(name: str, raw: str | None, default: int) -> int:
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_non_negative_int(name: str, raw: str | None, default: int) -> int:
|
||||
"""Parse an env var that must be an integer >= 0."""
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
try:
|
||||
parsed = int(raw)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"{name} must be an integer, got {raw!r}") from e
|
||||
if parsed < 0:
|
||||
raise ValueError(f"{name} must be >= 0, got {parsed}")
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_optional_positive_int(name: str, raw: str | None) -> int | None:
|
||||
"""Parse an optional env var that must be a positive integer when set."""
|
||||
if raw is None or raw == "":
|
||||
@@ -1979,6 +1996,7 @@ class HindsightConfig:
|
||||
reflect_llm_strategy: LLMStrategyConfig | None = None
|
||||
consolidation_llm_members: list[LLMMemberConfig] = field(default_factory=list)
|
||||
consolidation_llm_strategy: LLMStrategyConfig | None = None
|
||||
bm25_max_query_terms: int = DEFAULT_BM25_MAX_QUERY_TERMS
|
||||
|
||||
# Class-level sets for configuration categorization
|
||||
|
||||
@@ -2179,6 +2197,9 @@ class HindsightConfig:
|
||||
f"Invalid semantic_min_similarity: {self.semantic_min_similarity}. Must be between 0.0 and 1.0"
|
||||
)
|
||||
|
||||
if self.bm25_max_query_terms < 0:
|
||||
raise ValueError(f"Invalid bm25_max_query_terms: {self.bm25_max_query_terms}. Must be >= 0")
|
||||
|
||||
# Validate bedrock_service_tier
|
||||
valid_bedrock_tiers = (None, "flex", "priority", "reserved")
|
||||
if self.llm_bedrock_service_tier not in valid_bedrock_tiers:
|
||||
@@ -2608,6 +2629,11 @@ class HindsightConfig:
|
||||
reranker_max_candidates=int(os.getenv(ENV_RERANKER_MAX_CANDIDATES, str(DEFAULT_RERANKER_MAX_CANDIDATES))),
|
||||
semantic_min_similarity=float(os.getenv(ENV_SEMANTIC_MIN_SIMILARITY, str(DEFAULT_SEMANTIC_MIN_SIMILARITY))),
|
||||
bm25_min_score=float(os.getenv(ENV_BM25_MIN_SCORE, str(DEFAULT_BM25_MIN_SCORE))),
|
||||
bm25_max_query_terms=_parse_non_negative_int(
|
||||
ENV_BM25_MAX_QUERY_TERMS,
|
||||
os.getenv(ENV_BM25_MAX_QUERY_TERMS),
|
||||
DEFAULT_BM25_MAX_QUERY_TERMS,
|
||||
),
|
||||
recall_max_candidates_per_source=int(
|
||||
os.getenv(ENV_RECALL_MAX_CANDIDATES_PER_SOURCE, str(DEFAULT_RECALL_MAX_CANDIDATES_PER_SOURCE))
|
||||
),
|
||||
|
||||
@@ -102,6 +102,15 @@ class _DedupDecision(BaseModel):
|
||||
text: str = "" # the synthesized merged observation (when action == "merge")
|
||||
reason: str = ""
|
||||
|
||||
@field_validator("action", mode="before")
|
||||
@classmethod
|
||||
def _normalize_action(cls, value: object) -> str:
|
||||
if isinstance(value, str) and value in {"merge", "keep"}:
|
||||
return value
|
||||
|
||||
logger.warning("Invalid consolidation dedup action %r; defaulting to keep", value)
|
||||
return "keep"
|
||||
|
||||
|
||||
_DEDUP_PROMPT = """You reconcile long-term memory observations. A NEW observation is about to be \
|
||||
stored, and it is highly similar to an EXISTING one:
|
||||
|
||||
@@ -9,6 +9,33 @@ from .ops import DataAccessOps, TagListingParts
|
||||
from .result import ResultRow
|
||||
|
||||
|
||||
def pg_search_vector_expr(
|
||||
config,
|
||||
*,
|
||||
text_col: str = "text",
|
||||
context_col: str = "context",
|
||||
signals_col: str = "text_signals",
|
||||
) -> str | None:
|
||||
"""SQL expression that builds ``search_vector`` for the configured PG text-search backend.
|
||||
|
||||
Single source of truth shared by the batch insert (over the ``input_data``
|
||||
CTE columns) and the curation revert recompute (over a ``memory_units`` row),
|
||||
so the two can never drift. Returns ``None`` for backends that leave
|
||||
``search_vector`` unpopulated — pgroonga / pg_textsearch / pg_search index the
|
||||
base text columns directly and keep only a dummy column, so there is nothing
|
||||
to build.
|
||||
|
||||
``text_search_extension_native_language`` is validated as a PG identifier in
|
||||
``HindsightConfig.validate()``, so embedding it as a SQL literal is safe.
|
||||
"""
|
||||
combined = f"COALESCE({text_col}, '') || ' ' || COALESCE({context_col}, '') || ' ' || COALESCE({signals_col}, '')"
|
||||
if config.text_search_extension == "vchord":
|
||||
return f"tokenize({combined}, 'llmlingua2')::bm25_catalog.bm25vector"
|
||||
if config.text_search_extension == "native":
|
||||
return f"to_tsvector('{config.text_search_extension_native_language}'::regconfig, {combined})"
|
||||
return None
|
||||
|
||||
|
||||
class PostgreSQLOps(DataAccessOps):
|
||||
"""PostgreSQL-specific data access operations using unnest and LATERAL."""
|
||||
|
||||
@@ -93,101 +120,39 @@ class PostgreSQLOps(DataAccessOps):
|
||||
config = get_config()
|
||||
table = self._get_mu_table()
|
||||
|
||||
if config.text_search_extension == "vchord":
|
||||
query = f"""
|
||||
WITH input_data AS (
|
||||
SELECT * FROM unnest(
|
||||
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
|
||||
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
|
||||
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id, tags_json,
|
||||
observation_scopes_json, text_signals)
|
||||
)
|
||||
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id, tags,
|
||||
observation_scopes, text_signals, search_vector)
|
||||
SELECT
|
||||
$1,
|
||||
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id,
|
||||
COALESCE(
|
||||
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
|
||||
'{{}}'::varchar[]
|
||||
),
|
||||
observation_scopes_json,
|
||||
text_signals,
|
||||
tokenize(
|
||||
COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, ''),
|
||||
'llmlingua2'
|
||||
)::bm25_catalog.bm25vector
|
||||
FROM input_data
|
||||
RETURNING id
|
||||
"""
|
||||
elif config.text_search_extension == "native":
|
||||
# search_vector is a regular tsvector column populated here using the
|
||||
# configured native dictionary. It used to be GENERATED ALWAYS with
|
||||
# a hardcoded 'english', which prevented per-deployment language
|
||||
# configuration. text_search_extension_native_language is validated
|
||||
# in HindsightConfig.validate() as a PG identifier, so embedding it
|
||||
# as a SQL literal is safe.
|
||||
query = f"""
|
||||
WITH input_data AS (
|
||||
SELECT * FROM unnest(
|
||||
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
|
||||
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
|
||||
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id, tags_json,
|
||||
observation_scopes_json, text_signals)
|
||||
)
|
||||
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id, tags,
|
||||
observation_scopes, text_signals, search_vector)
|
||||
SELECT
|
||||
$1,
|
||||
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id,
|
||||
COALESCE(
|
||||
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
|
||||
'{{}}'::varchar[]
|
||||
),
|
||||
observation_scopes_json,
|
||||
text_signals,
|
||||
to_tsvector(
|
||||
'{config.text_search_extension_native_language}'::regconfig,
|
||||
COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, '')
|
||||
)
|
||||
FROM input_data
|
||||
RETURNING id
|
||||
"""
|
||||
else:
|
||||
# pg_textsearch, pgroonga, and pg_search: search_vector is a dummy
|
||||
# TEXT column; the actual full-text index operates on the base text
|
||||
# columns directly, so we don't populate search_vector at insert time.
|
||||
query = f"""
|
||||
WITH input_data AS (
|
||||
SELECT * FROM unnest(
|
||||
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
|
||||
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
|
||||
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id, tags_json,
|
||||
observation_scopes_json, text_signals)
|
||||
)
|
||||
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id, tags,
|
||||
observation_scopes, text_signals)
|
||||
SELECT
|
||||
$1,
|
||||
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id,
|
||||
COALESCE(
|
||||
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
|
||||
'{{}}'::varchar[]
|
||||
),
|
||||
observation_scopes_json,
|
||||
text_signals
|
||||
FROM input_data
|
||||
RETURNING id
|
||||
"""
|
||||
# search_vector is populated inline for backends that store a real vector
|
||||
# (native tsvector, vchord bm25vector). pgroonga / pg_textsearch / pg_search
|
||||
# index the base text columns directly and keep only a dummy column, so the
|
||||
# expression is None and the column is left out of the insert entirely.
|
||||
# Same expression is reused by curation revert (see pg_search_vector_expr).
|
||||
sv_expr = pg_search_vector_expr(config)
|
||||
sv_insert_col = ", search_vector" if sv_expr else ""
|
||||
sv_select_val = f",\n {sv_expr}" if sv_expr else ""
|
||||
query = f"""
|
||||
WITH input_data AS (
|
||||
SELECT * FROM unnest(
|
||||
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
|
||||
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
|
||||
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id, tags_json,
|
||||
observation_scopes_json, text_signals)
|
||||
)
|
||||
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id, tags,
|
||||
observation_scopes, text_signals{sv_insert_col})
|
||||
SELECT
|
||||
$1,
|
||||
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id,
|
||||
COALESCE(
|
||||
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
|
||||
'{{}}'::varchar[]
|
||||
),
|
||||
observation_scopes_json,
|
||||
text_signals{sv_select_val}
|
||||
FROM input_data
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
results = await conn.fetch(
|
||||
query,
|
||||
|
||||
@@ -1297,7 +1297,10 @@ class OracleBackend(DatabaseBackend):
|
||||
if schema and schema != "public":
|
||||
cursor = conn.cursor()
|
||||
await cursor.execute(f'ALTER SESSION SET CURRENT_SCHEMA = "{schema}"')
|
||||
await cursor.close()
|
||||
# oracledb's AsyncCursor.close() is synchronous (not a coroutine);
|
||||
# awaiting it raises "object NoneType can't be used in 'await'
|
||||
# expression" and aborts every acquire() under a non-public schema.
|
||||
cursor.close()
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(self) -> AsyncIterator[OracleConnection]:
|
||||
|
||||
@@ -6570,13 +6570,18 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
)
|
||||
|
||||
collist = await self._memory_unit_columns(conn)
|
||||
# The archive is cold storage, never a recall surface, so the schema gives it
|
||||
# no `embedding` column at all (dropped in d4f6a8c2e1b3). The move in/out is
|
||||
# therefore over every memory_units column EXCEPT embedding; on revert the
|
||||
# embedding is recomputed from the unit's text/dates/entities below. This makes
|
||||
# a model switch (which re-dimensions memory_units) structurally unable to trip
|
||||
# a vector-dimension mismatch on the INSERT … SELECT round-trip (#2209).
|
||||
arch_cols = ", ".join(c for c in (s.strip() for s in collist.split(",")) if c != '"embedding"')
|
||||
# The archive is cold storage, never a recall surface and carries no index,
|
||||
# so the schema gives it neither the `embedding` (dropped in d4f6a8c2e1b3)
|
||||
# nor the `search_vector` column (dropped in e7c3a9f1b2d5). Both are
|
||||
# recall-surface columns whose type/shape follows server
|
||||
# config, so the move in/out is over every memory_units column EXCEPT those
|
||||
# two; on revert each is recomputed from the unit's text/dates/entities below.
|
||||
# This makes a model switch (which re-dimensions memory_units) structurally
|
||||
# unable to trip a vector-dimension mismatch (#2209), and a text-search backend
|
||||
# switch unable to trip a search_vector type mismatch (#2503), on the
|
||||
# INSERT … SELECT round-trip.
|
||||
_archive_omitted = ('"embedding"', '"search_vector"')
|
||||
arch_cols = ", ".join(c for c in (s.strip() for s in collist.split(",")) if c not in _archive_omitted)
|
||||
|
||||
# --- Edit fields (live rows only): text / context / dates / fact_type / entities ---
|
||||
doing_edit = any(
|
||||
@@ -6634,6 +6639,17 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
mentioned_at=live["mentioned_at"],
|
||||
entities=[r["canonical_name"] for r in ent_rows],
|
||||
)
|
||||
# Keep the stored text-search vector in sync with curated
|
||||
# text/context edits. Use the incoming parameters here:
|
||||
# PostgreSQL evaluates UPDATE RHS expressions before the
|
||||
# sibling SET assignments take effect, so column references
|
||||
# would see the pre-edit text/context.
|
||||
from .db.ops_postgresql import pg_search_vector_expr
|
||||
|
||||
sv_expr = pg_search_vector_expr(get_config(), text_col="$3", context_col="$4")
|
||||
search_vector_clause = (
|
||||
f",\n search_vector = {sv_expr}" if sv_expr else ""
|
||||
)
|
||||
await enqueue_relink_victims(conn, bank_id, [memory_id], ops=backend.ops)
|
||||
await conn.execute(
|
||||
f"""
|
||||
@@ -6641,7 +6657,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
SET text = $3, context = $4, fact_type = $5, occurred_start = $6,
|
||||
occurred_end = $7, event_date = $8, embedding = $9::vector,
|
||||
consolidated_at = NULL, consolidation_failed_at = NULL,
|
||||
edited_at = now(), updated_at = now()
|
||||
edited_at = now(), updated_at = now(){search_vector_clause}
|
||||
WHERE id = $1 AND bank_id = $2
|
||||
""",
|
||||
str(memory_uuid),
|
||||
@@ -6695,14 +6711,29 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
arch_row = await conn.fetchrow(
|
||||
f"SELECT entity_ids FROM {arch} WHERE id = $1 AND bank_id = $2", str(memory_uuid), bank_id
|
||||
)
|
||||
# The archive has no embedding column (see arch_cols above), so the live
|
||||
# row's embedding defaults to NULL on the way back and is recomputed below
|
||||
# once entities are restored.
|
||||
# The archive keeps neither embedding nor search_vector (see arch_cols
|
||||
# above), so both default to NULL on the way back and are recomputed here:
|
||||
# the embedding below once entities are restored, the search_vector now
|
||||
# from the row's own text/context/text_signals.
|
||||
await conn.execute(
|
||||
f"INSERT INTO {mu} ({arch_cols}) SELECT {arch_cols} FROM {arch} WHERE id = $1 AND bank_id = $2",
|
||||
str(memory_uuid),
|
||||
bank_id,
|
||||
)
|
||||
# Rebuild search_vector using the *current* text-search backend, so the
|
||||
# reverted unit is keyword-searchable again (more correct than carrying a
|
||||
# verbatim copy that could be stale/wrong-type if the backend changed while
|
||||
# the fact sat archived). None = pgroonga/pg_textsearch/pg_search, which
|
||||
# index base columns directly and leave search_vector empty (#2503).
|
||||
from .db.ops_postgresql import pg_search_vector_expr
|
||||
|
||||
sv_expr = pg_search_vector_expr(get_config())
|
||||
if sv_expr is not None:
|
||||
await conn.execute(
|
||||
f"UPDATE {mu} SET search_vector = {sv_expr} WHERE id = $1 AND bank_id = $2",
|
||||
str(memory_uuid),
|
||||
bank_id,
|
||||
)
|
||||
# Re-consolidate from scratch; links are rebuilt by graph maintenance.
|
||||
await conn.execute(
|
||||
f"UPDATE {mu} SET consolidated_at = NULL, consolidation_failed_at = NULL, updated_at = now() "
|
||||
@@ -7446,7 +7477,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
f"""
|
||||
SELECT id, text, event_date, context, fact_type, document_id,
|
||||
mentioned_at, occurred_start, occurred_end, chunk_id, proof_count,
|
||||
tags, consolidated_at, consolidation_failed_at, edited_at, {curation_cols}
|
||||
tags, metadata, consolidated_at, consolidation_failed_at, edited_at, {curation_cols}
|
||||
FROM {source_table}
|
||||
{where_clause}
|
||||
ORDER BY mentioned_at DESC NULLS LAST, created_at DESC
|
||||
@@ -7501,6 +7532,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
"chunk_id": row["chunk_id"] if row["chunk_id"] else None,
|
||||
"proof_count": row["proof_count"] if row["proof_count"] is not None else 1,
|
||||
"tags": list(row["tags"]) if row["tags"] else [],
|
||||
"metadata": conn.parse_json(row["metadata"]) if row["metadata"] is not None else {},
|
||||
"consolidated_at": row["consolidated_at"].isoformat() if row["consolidated_at"] else None,
|
||||
"consolidation_failed_at": (
|
||||
row["consolidation_failed_at"].isoformat() if row["consolidation_failed_at"] else None
|
||||
@@ -7553,7 +7585,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# back to the archive (with its invalidation bookkeeping) on a miss.
|
||||
select_cols = (
|
||||
"id, text, context, event_date, occurred_start, occurred_end, "
|
||||
"mentioned_at, fact_type, document_id, chunk_id, tags, source_memory_ids, "
|
||||
"mentioned_at, fact_type, document_id, chunk_id, tags, metadata, source_memory_ids, "
|
||||
"observation_scopes, edited_at"
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
@@ -7597,6 +7629,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
"document_id": row["document_id"] if row["document_id"] else None,
|
||||
"chunk_id": str(row["chunk_id"]) if row["chunk_id"] else None,
|
||||
"tags": row["tags"] if row["tags"] else [],
|
||||
"metadata": conn.parse_json(row["metadata"]) if row["metadata"] is not None else {},
|
||||
"observation_scopes": row["observation_scopes"] if row["observation_scopes"] else None,
|
||||
"state": unit_state,
|
||||
"invalidation_reason": row["invalidation_reason"],
|
||||
|
||||
@@ -186,7 +186,11 @@ class MarkitdownParser(FileParser):
|
||||
if Path(filename).suffix.lower() not in _TEXT_EXTENSIONS:
|
||||
return None
|
||||
try:
|
||||
file_data.decode("utf-8")
|
||||
# file_data may arrive as a non-``bytes`` buffer (e.g. a memoryview or
|
||||
# a native/Rust-backed buffer object) that has no ``.decode``; coerce
|
||||
# through the buffer protocol before the UTF-8 probe. The ``tmp.write``
|
||||
# in the caller already relies only on the same buffer protocol.
|
||||
bytes(file_data).decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
from markitdown import StreamInfo
|
||||
|
||||
@@ -53,6 +53,53 @@ __all__ = [
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Name of the single forced function tool used to carry structured output when
|
||||
# strict_schema is on. The Codex backend speaks the OpenAI Responses API, so a
|
||||
# forced function call gives us constrained decoding straight into the response
|
||||
# schema — no prompt-injected schema, no raw json.loads on free-form model text,
|
||||
# no invalid-\escape retry storm (issue #2504, same class as #1002 / #2339).
|
||||
_STRUCTURED_TOOL_NAME = "structured_response"
|
||||
|
||||
# Valid JSON string escape characters (the char that may follow a backslash).
|
||||
_VALID_JSON_ESCAPE_CHARS = set('"\\/bfnrtu')
|
||||
|
||||
|
||||
def _repair_invalid_json_escapes(text: str) -> str:
|
||||
"""Best-effort repair of invalid ``\\escape`` sequences in a JSON string.
|
||||
|
||||
Escape-heavy content (code, serial/CLI commands, Windows paths, regexes)
|
||||
makes weaker models emit backslashes that aren't valid JSON escapes (e.g.
|
||||
``\\d``, ``\\s``, ``C:\\Users``), so ``json.loads`` fails deterministically
|
||||
and every retry re-fails the same way (issue #2504). This doubles any
|
||||
backslash that isn't part of a valid escape so the payload parses. It is a
|
||||
lenient fallback only — the strict_schema forced-tool path is the real fix.
|
||||
"""
|
||||
result: list[str] = []
|
||||
i = 0
|
||||
n = len(text)
|
||||
while i < n:
|
||||
ch = text[i]
|
||||
if ch == "\\" and i + 1 < n:
|
||||
nxt = text[i + 1]
|
||||
if nxt in _VALID_JSON_ESCAPE_CHARS:
|
||||
# Preserve the valid escape (both chars) verbatim.
|
||||
result.append(ch)
|
||||
result.append(nxt)
|
||||
i += 2
|
||||
continue
|
||||
# Invalid escape: escape the lone backslash so JSON parses.
|
||||
result.append("\\\\")
|
||||
i += 1
|
||||
continue
|
||||
if ch == "\\" and i + 1 == n:
|
||||
# Trailing lone backslash — escape it.
|
||||
result.append("\\\\")
|
||||
i += 1
|
||||
continue
|
||||
result.append(ch)
|
||||
i += 1
|
||||
return "".join(result)
|
||||
|
||||
|
||||
class CodexLLM(LLMInterface):
|
||||
"""
|
||||
@@ -336,7 +383,18 @@ class CodexLLM(LLMInterface):
|
||||
strict_schema: bool = False,
|
||||
return_usage: bool = False,
|
||||
) -> Any:
|
||||
"""Make API call to Codex backend with SSE streaming."""
|
||||
"""Make API call to Codex backend with SSE streaming.
|
||||
|
||||
Args:
|
||||
strict_schema: Route structured output through a single forced
|
||||
function tool (constrained decoding) instead of prompt-injecting
|
||||
the schema and parsing free-form text. The Codex backend speaks
|
||||
the OpenAI Responses API, so the forced function call emits the
|
||||
response schema directly as tool arguments — eliminating the
|
||||
invalid-``\\escape`` retry storm (issue #2504). When False, falls
|
||||
back to schema-in-prompt + JSON parse, now hardened with a lenient
|
||||
invalid-escape repair before giving up.
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Proactively refresh the OAuth access_token if it's near expiry.
|
||||
@@ -361,11 +419,22 @@ class CodexLLM(LLMInterface):
|
||||
else:
|
||||
user_messages.append(msg)
|
||||
|
||||
# Add JSON schema instruction if response_format is provided
|
||||
# Structured output: prefer a single forced function tool (constrained
|
||||
# decoding) over text-injecting the schema and parsing the reply. The
|
||||
# forced tool guarantees schema-shaped JSON in the tool arguments,
|
||||
# eliminating the invalid-\escape retry storm (issue #2504). When
|
||||
# strict_schema is off we keep the schema-in-prompt + json.loads
|
||||
# fallback (now hardened with a lenient escape repair) for callers that
|
||||
# can't force tools.
|
||||
schema = None
|
||||
use_forced_tool = False
|
||||
if response_format is not None and hasattr(response_format, "model_json_schema"):
|
||||
schema = response_format.model_json_schema()
|
||||
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
|
||||
system_instruction += schema_msg
|
||||
if strict_schema:
|
||||
use_forced_tool = True
|
||||
else:
|
||||
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
|
||||
system_instruction += schema_msg
|
||||
|
||||
# gpt-5.2-codex only supports "detailed" reasoning summary
|
||||
reasoning_summary = "detailed" if "5.2" in self.model else self.reasoning_summary
|
||||
@@ -392,6 +461,20 @@ class CodexLLM(LLMInterface):
|
||||
"prompt_cache_key": str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
if use_forced_tool and schema is not None:
|
||||
# Single function tool whose parameters ARE the response schema;
|
||||
# force it via tool_choice so the backend does constrained decoding.
|
||||
payload["tools"] = [
|
||||
{
|
||||
"type": "function",
|
||||
"name": _STRUCTURED_TOOL_NAME,
|
||||
"description": "Return the structured response.",
|
||||
"parameters": schema,
|
||||
}
|
||||
]
|
||||
payload["tool_choice"] = {"type": "function", "name": _STRUCTURED_TOOL_NAME}
|
||||
payload["parallel_tool_calls"] = False
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.access_token}",
|
||||
"Content-Type": "application/json",
|
||||
@@ -412,8 +495,15 @@ class CodexLLM(LLMInterface):
|
||||
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse SSE stream
|
||||
content = await self._parse_sse_stream(response)
|
||||
# Forced-tool path: read structured output from the function-call
|
||||
# arguments (already a JSON string in a dedicated channel) rather
|
||||
# than from free-form assistant text.
|
||||
if use_forced_tool:
|
||||
text_content, tool_calls = await self._parse_sse_tool_stream(response)
|
||||
content = text_content or ""
|
||||
else:
|
||||
tool_calls = []
|
||||
content = await self._parse_sse_stream(response)
|
||||
|
||||
# Codex SSE carries no usage block; stash the same char/4 estimate
|
||||
# the success path traces so a later parse/validate failure records
|
||||
@@ -426,7 +516,28 @@ class CodexLLM(LLMInterface):
|
||||
)
|
||||
|
||||
# Handle structured output
|
||||
if response_format is not None:
|
||||
if use_forced_tool:
|
||||
tool_input = None
|
||||
for tc in tool_calls:
|
||||
if tc.name == _STRUCTURED_TOOL_NAME:
|
||||
tool_input = tc.arguments if isinstance(tc.arguments, dict) else None
|
||||
break
|
||||
if tool_input is None:
|
||||
# Model ignored the forced tool (rare — e.g. a gateway that
|
||||
# drops tool_choice). Retry so we don't hard-fail.
|
||||
logger.warning(
|
||||
f"Codex forced structured tool missing from response "
|
||||
f"(attempt {attempt + 1}/{max_retries + 1})"
|
||||
)
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
attempt += 1
|
||||
continue
|
||||
raise RuntimeError("Codex did not return the forced structured_response tool call")
|
||||
content = json.dumps(tool_input)
|
||||
result = tool_input if skip_validation else response_format.model_validate(tool_input)
|
||||
elif response_format is not None:
|
||||
# Models may wrap JSON in markdown
|
||||
clean_content = content
|
||||
if "```json" in content:
|
||||
@@ -437,13 +548,20 @@ class CodexLLM(LLMInterface):
|
||||
try:
|
||||
json_data = json.loads(clean_content)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Codex JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {e}")
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
attempt += 1
|
||||
continue
|
||||
raise
|
||||
# Escape-heavy content deterministically re-fails every
|
||||
# retry (issue #2504). Try a lenient invalid-escape repair
|
||||
# before burning a retry / re-raising.
|
||||
try:
|
||||
json_data = json.loads(_repair_invalid_json_escapes(clean_content))
|
||||
logger.info("Codex JSON parsed after repairing invalid escape sequences")
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Codex JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {e}")
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
attempt += 1
|
||||
continue
|
||||
raise
|
||||
|
||||
if skip_validation:
|
||||
result = json_data
|
||||
@@ -872,8 +990,13 @@ class CodexLLM(LLMInterface):
|
||||
try:
|
||||
arguments = json.loads(arguments_str)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Failed to parse tool arguments: {arguments_str}")
|
||||
arguments = {}
|
||||
# Escape-heavy content can emit invalid \escape
|
||||
# sequences (issue #2504); repair before giving up.
|
||||
try:
|
||||
arguments = json.loads(_repair_invalid_json_escapes(arguments_str))
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Failed to parse tool arguments: {arguments_str}")
|
||||
arguments = {}
|
||||
|
||||
tool_calls.append(
|
||||
LLMToolCall(
|
||||
|
||||
@@ -67,23 +67,68 @@ class ProviderResponseError(RuntimeError):
|
||||
self.retryable = retryable
|
||||
|
||||
|
||||
def _is_json(text: str) -> bool:
|
||||
"""True if ``text`` parses as a JSON value."""
|
||||
try:
|
||||
json.loads(text)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _outer_json_span(content: str) -> str | None:
|
||||
"""Return the outermost ``{...}`` / ``[...]`` span if it parses as JSON, else None.
|
||||
|
||||
Fallback for responses where fences are partial/absent or the model wrapped
|
||||
the JSON in surrounding prose. Only returned when it is valid JSON so callers
|
||||
never receive a worse candidate than the raw content.
|
||||
"""
|
||||
starts = [i for i in (content.find("{"), content.find("[")) if i >= 0]
|
||||
ends = [i for i in (content.rfind("}"), content.rfind("]")) if i >= 0]
|
||||
if not starts or not ends:
|
||||
return None
|
||||
start, end = min(starts), max(ends)
|
||||
if end <= start:
|
||||
return None
|
||||
candidate = content[start : end + 1].strip()
|
||||
return candidate if _is_json(candidate) else None
|
||||
|
||||
|
||||
def _strip_code_fences(content: str) -> str:
|
||||
"""Strip markdown code fences from LLM response if present.
|
||||
|
||||
Many LLM providers (MiniMax, some Ollama models, Claude via proxies)
|
||||
wrap JSON responses in ```json ... ``` fences even when json_object
|
||||
response format is requested. This strips the fences while preserving
|
||||
the JSON content inside. Returns the original content unchanged if
|
||||
no fences are detected.
|
||||
response format is requested. Fences are detected by line (a closing
|
||||
``` must sit alone on its line) so triple-backticks *inside* JSON string
|
||||
values do not truncate the payload. When the stripped candidate is not
|
||||
valid JSON (partial fence, prose-wrapped output, truncated response), fall
|
||||
back to the outermost parseable JSON span. Returns the original content
|
||||
unchanged if no better candidate is found.
|
||||
"""
|
||||
if "```" not in content:
|
||||
return content
|
||||
try:
|
||||
if "```json" in content:
|
||||
return content.split("```json")[1].split("```")[0].strip()
|
||||
return content.split("```")[1].split("```")[0].strip()
|
||||
except (IndexError, ValueError):
|
||||
return content
|
||||
candidate = content
|
||||
if "```" in content:
|
||||
lines = content.split("\n")
|
||||
# Find first line that starts a code fence (``` optionally followed by language)
|
||||
fence_start = next((i for i, line in enumerate(lines) if line.startswith("```")), None)
|
||||
if fence_start is not None:
|
||||
# Find matching closing fence (``` alone or with trailing whitespace)
|
||||
fence_end = next(
|
||||
(j for j in range(fence_start + 1, len(lines)) if lines[j].strip() == "```"),
|
||||
None,
|
||||
)
|
||||
if fence_end is not None:
|
||||
candidate = "\n".join(lines[fence_start + 1 : fence_end]).strip()
|
||||
|
||||
if _is_json(candidate):
|
||||
return candidate
|
||||
|
||||
# Fence stripping did not yield valid JSON — try to recover the outer JSON span.
|
||||
span = _outer_json_span(content)
|
||||
if span is not None:
|
||||
return span
|
||||
|
||||
return candidate
|
||||
|
||||
|
||||
# Reasoning/thinking tags emitted by extended-thinking models. Some providers
|
||||
@@ -644,6 +689,11 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
# use the widely-supported max_tokens
|
||||
return "max_tokens"
|
||||
|
||||
def _apply_provider_extra_body_defaults(self, extra_body: dict[str, Any]) -> None:
|
||||
"""Apply provider-specific extra_body defaults while preserving user overrides."""
|
||||
if self.provider == "minimax":
|
||||
extra_body.setdefault("thinking", {"type": "disabled"})
|
||||
|
||||
async def call(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
@@ -731,6 +781,7 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
|
||||
# Provider-specific parameters
|
||||
extra_body: dict[str, Any] = {**self._config_extra_body}
|
||||
self._apply_provider_extra_body_defaults(extra_body)
|
||||
if self.provider == "groq":
|
||||
call_params["seed"] = DEFAULT_LLM_SEED
|
||||
# Add service_tier if configured
|
||||
@@ -1149,6 +1200,7 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
|
||||
# Provider-specific parameters
|
||||
extra_body: dict[str, Any] = {**self._config_extra_body}
|
||||
self._apply_provider_extra_body_defaults(extra_body)
|
||||
if self.provider == "groq":
|
||||
call_params["seed"] = DEFAULT_LLM_SEED
|
||||
if extra_body:
|
||||
|
||||
@@ -64,8 +64,53 @@ async def delete_chunks_by_ids(conn, chunk_ids: list[str]) -> None:
|
||||
"""
|
||||
if not chunk_ids:
|
||||
return
|
||||
|
||||
# PostgreSQL's FK cascade deletes child memory_links in executor-chosen
|
||||
# order. Concurrent chunk deletes for the same bank can then lock overlapping
|
||||
# memory_links in opposite orders and deadlock. Delete links explicitly in a
|
||||
# total order before deleting chunks so every writer takes row locks the same
|
||||
# way; the FK cascade still handles anything inserted later in this txn.
|
||||
await conn.execute(
|
||||
f"DELETE FROM {fq_table('chunks')} WHERE chunk_id = ANY($1::text[])",
|
||||
f"""
|
||||
WITH target_units AS MATERIALIZED (
|
||||
SELECT id
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE chunk_id = ANY($1::text[])
|
||||
),
|
||||
ordered_links AS MATERIALIZED (
|
||||
SELECT ml.ctid
|
||||
FROM {fq_table("memory_links")} ml
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM target_units tu
|
||||
WHERE tu.id = ml.from_unit_id OR tu.id = ml.to_unit_id
|
||||
)
|
||||
ORDER BY
|
||||
LEAST(ml.from_unit_id, ml.to_unit_id),
|
||||
GREATEST(ml.from_unit_id, ml.to_unit_id),
|
||||
ml.link_type,
|
||||
COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
FOR UPDATE OF ml
|
||||
)
|
||||
DELETE FROM {fq_table("memory_links")} ml
|
||||
USING ordered_links ol
|
||||
WHERE ml.ctid = ol.ctid
|
||||
""",
|
||||
chunk_ids,
|
||||
)
|
||||
await conn.execute(
|
||||
f"""
|
||||
WITH ordered_chunks AS MATERIALIZED (
|
||||
SELECT chunk_id
|
||||
FROM {fq_table("chunks")}
|
||||
WHERE chunk_id = ANY($1::text[])
|
||||
ORDER BY chunk_id
|
||||
FOR UPDATE
|
||||
)
|
||||
DELETE FROM {fq_table("chunks")} c
|
||||
USING ordered_chunks oc
|
||||
WHERE c.chunk_id = oc.chunk_id
|
||||
""",
|
||||
chunk_ids,
|
||||
)
|
||||
|
||||
|
||||
@@ -232,6 +232,55 @@ class FactExtractionResponse(BaseModel):
|
||||
facts: list[ExtractedFact] = Field(description="List of extracted factual statements")
|
||||
|
||||
|
||||
def _split_chunk_for_output_retry(chunk: str) -> tuple[str, str] | None:
|
||||
"""Split an oversized extraction chunk without corrupting structured input."""
|
||||
stripped = chunk.strip()
|
||||
if len(stripped) <= 1:
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
parsed = None
|
||||
|
||||
if isinstance(parsed, list):
|
||||
if len(parsed) >= 2:
|
||||
mid = len(parsed) // 2
|
||||
return json.dumps(parsed[:mid]), json.dumps(parsed[mid:])
|
||||
|
||||
if len(parsed) == 1 and isinstance(parsed[0], dict):
|
||||
turn = parsed[0]
|
||||
content = turn.get("content")
|
||||
if isinstance(content, str) and len(content) > 1:
|
||||
cut = len(content) // 2
|
||||
first_turn = dict(turn)
|
||||
second_turn = dict(turn)
|
||||
first_turn["content"] = content[:cut]
|
||||
second_turn["content"] = content[cut:]
|
||||
return json.dumps([first_turn]), json.dumps([second_turn])
|
||||
|
||||
return None
|
||||
|
||||
# Split plain text at the midpoint, preferring sentence boundaries nearby.
|
||||
mid_point = len(stripped) // 2
|
||||
search_range = int(len(stripped) * 0.2)
|
||||
search_start = max(0, mid_point - search_range)
|
||||
search_end = min(len(stripped), mid_point + search_range)
|
||||
|
||||
best_split = mid_point
|
||||
for ending in [". ", "! ", "? ", "\n\n"]:
|
||||
pos = stripped.rfind(ending, search_start, search_end)
|
||||
if pos != -1:
|
||||
best_split = pos + len(ending)
|
||||
break
|
||||
|
||||
first_half = stripped[:best_split].strip()
|
||||
second_half = stripped[best_split:].strip()
|
||||
if not first_half or not second_half or first_half == stripped or second_half == stripped:
|
||||
return None
|
||||
return first_half, second_half
|
||||
|
||||
|
||||
class ExtractedFactVerbose(BaseModel):
|
||||
"""A single extracted fact with verbose field descriptions for detailed extraction."""
|
||||
|
||||
@@ -1664,33 +1713,22 @@ async def _extract_facts_with_auto_split(
|
||||
metadata=metadata,
|
||||
)
|
||||
except OutputTooLongError:
|
||||
# Output exceeded token limits - split the chunk in half and retry
|
||||
# Output exceeded token limits - split the chunk and retry. Conversation
|
||||
# chunks are JSON arrays, so preserve array/turn boundaries when possible.
|
||||
logger.warning(
|
||||
f"Output too long for chunk {chunk_index + 1}/{total_chunks} "
|
||||
f"({len(chunk)} chars). Splitting in half and retrying..."
|
||||
f"({len(chunk)} chars). Splitting and retrying..."
|
||||
)
|
||||
|
||||
# Split at the midpoint, preferring sentence boundaries
|
||||
mid_point = len(chunk) // 2
|
||||
split_chunks = _split_chunk_for_output_retry(chunk)
|
||||
if split_chunks is None:
|
||||
logger.warning(
|
||||
f"Cannot make progress splitting chunk {chunk_index + 1}/{total_chunks} "
|
||||
f"({len(chunk)} chars); dropping this sub-chunk."
|
||||
)
|
||||
return [], TokenUsage()
|
||||
|
||||
# Try to find a sentence boundary near the midpoint
|
||||
# Look for ". ", "! ", "? " within 20% of midpoint
|
||||
search_range = int(len(chunk) * 0.2)
|
||||
search_start = max(0, mid_point - search_range)
|
||||
search_end = min(len(chunk), mid_point + search_range)
|
||||
|
||||
sentence_endings = [". ", "! ", "? ", "\n\n"]
|
||||
best_split = mid_point
|
||||
|
||||
for ending in sentence_endings:
|
||||
pos = chunk.rfind(ending, search_start, search_end)
|
||||
if pos != -1:
|
||||
best_split = pos + len(ending)
|
||||
break
|
||||
|
||||
# Split the chunk
|
||||
first_half = chunk[:best_split].strip()
|
||||
second_half = chunk[best_split:].strip()
|
||||
first_half, second_half = split_chunks
|
||||
|
||||
logger.info(
|
||||
f"Split chunk {chunk_index + 1} into two sub-chunks: {len(first_half)} chars and {len(second_half)} chars"
|
||||
|
||||
@@ -15,7 +15,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from ...config import get_config
|
||||
from ...config import DEFAULT_BM25_MAX_QUERY_TERMS, get_config
|
||||
from ..db_utils import acquire_with_retry
|
||||
from ..memory_engine import fq_table
|
||||
from ..sql import create_sql_dialect
|
||||
@@ -222,7 +222,12 @@ async def retrieve_semantic_bm25_combined(
|
||||
# --- BM25 UNION ALL arms (one per fact_type, only when tokens present) ---
|
||||
if _include_bm25:
|
||||
text_ext = config.text_search_extension
|
||||
bm25_text_param: str = dialect.prepare_bm25_text(tokens, query_text, text_search_extension=text_ext)
|
||||
bm25_text_param: str = dialect.prepare_bm25_text(
|
||||
tokens,
|
||||
query_text,
|
||||
text_search_extension=text_ext,
|
||||
max_query_terms=getattr(config, "bm25_max_query_terms", DEFAULT_BM25_MAX_QUERY_TERMS),
|
||||
)
|
||||
for i, ft in enumerate(fact_types):
|
||||
arms.append(
|
||||
dialect.build_bm25_arm(
|
||||
|
||||
@@ -449,6 +449,7 @@ class SQLDialect(ABC):
|
||||
query_text: str,
|
||||
*,
|
||||
text_search_extension: str = "native",
|
||||
max_query_terms: int | None = None,
|
||||
) -> str:
|
||||
"""Prepare the text parameter value for BM25 search.
|
||||
|
||||
@@ -459,6 +460,8 @@ class SQLDialect(ABC):
|
||||
tokens: Tokenized query words.
|
||||
query_text: Original query text.
|
||||
text_search_extension: Full-text search backend variant.
|
||||
max_query_terms: Optional backend-specific token cap. 0 or None
|
||||
leaves query terms uncapped.
|
||||
|
||||
Returns:
|
||||
Prepared text string to bind as the BM25 text parameter.
|
||||
|
||||
@@ -303,6 +303,7 @@ class OracleDialect(SQLDialect):
|
||||
query_text: str,
|
||||
*,
|
||||
text_search_extension: str = "native",
|
||||
max_query_terms: int | None = None,
|
||||
) -> str:
|
||||
# Oracle Text: filter tokens with special chars, escape reserved words
|
||||
# with curly braces (e.g. "about" → "{about}"), and join with OR.
|
||||
|
||||
@@ -254,8 +254,11 @@ class PostgreSQLDialect(SQLDialect):
|
||||
query_text: str,
|
||||
*,
|
||||
text_search_extension: str = "native",
|
||||
max_query_terms: int | None = None,
|
||||
) -> str:
|
||||
if text_search_extension in ("vchord", "pg_textsearch", "pgroonga", "pg_search"):
|
||||
return query_text
|
||||
if max_query_terms is not None and max_query_terms > 0:
|
||||
tokens = tokens[:max_query_terms]
|
||||
# native tsvector: join tokens with OR operator
|
||||
return " | ".join(tokens)
|
||||
|
||||
@@ -5,6 +5,7 @@ import pytest_asyncio
|
||||
import httpx
|
||||
from datetime import datetime
|
||||
from hindsight_api.api import create_app
|
||||
from hindsight_api.api.http import BankTemplateManifest, validate_bank_template
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@@ -81,6 +82,17 @@ class TestImportValidation:
|
||||
assert set(data["mental_models_created"]) == {"test-model-one", "test-model-two"}
|
||||
assert set(data["directives_created"]) == {"Be concise", "Use examples"}
|
||||
|
||||
def test_verbatim_extraction_mode_is_valid(self):
|
||||
"""verbatim is a valid retain extraction mode in bank manifests."""
|
||||
manifest = BankTemplateManifest.model_validate(
|
||||
{
|
||||
"version": "1",
|
||||
"bank": {"retain_extraction_mode": "verbatim"},
|
||||
}
|
||||
)
|
||||
|
||||
assert validate_bank_template(manifest) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_invalid_version(self, api_client, bank_id):
|
||||
"""Reject manifest with unsupported version."""
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Regression coverage for deterministic chunk deletion ordering."""
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.retain import chunk_storage
|
||||
|
||||
|
||||
class RecordingConn:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, tuple[object, ...]]] = []
|
||||
|
||||
async def execute(self, sql: str, *args: object) -> None:
|
||||
self.calls.append((sql, args))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_chunks_by_ids_predeletes_links_before_chunks():
|
||||
conn = RecordingConn()
|
||||
chunk_ids = ["chunk-b", "chunk-a"]
|
||||
|
||||
await chunk_storage.delete_chunks_by_ids(conn, chunk_ids)
|
||||
|
||||
assert len(conn.calls) == 2
|
||||
link_sql, link_args = conn.calls[0]
|
||||
chunk_sql, chunk_args = conn.calls[1]
|
||||
|
||||
assert link_args == (chunk_ids,)
|
||||
assert chunk_args == (chunk_ids,)
|
||||
|
||||
assert "DELETE FROM" in link_sql
|
||||
assert "memory_links" in link_sql
|
||||
assert "target_units AS MATERIALIZED" in link_sql
|
||||
assert "ordered_links AS MATERIALIZED" in link_sql
|
||||
assert "ORDER BY" in link_sql
|
||||
assert "FOR UPDATE OF ml" in link_sql
|
||||
|
||||
assert "DELETE FROM" in chunk_sql
|
||||
assert "chunks" in chunk_sql
|
||||
assert "ordered_chunks AS MATERIALIZED" in chunk_sql
|
||||
assert "ORDER BY chunk_id" in chunk_sql
|
||||
assert "FOR UPDATE" in chunk_sql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_chunks_by_ids_noops_without_chunks():
|
||||
conn = RecordingConn()
|
||||
|
||||
await chunk_storage.delete_chunks_by_ids(conn, [])
|
||||
|
||||
assert conn.calls == []
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Regression tests for Codex structured output (issue #2504).
|
||||
|
||||
Before the fix, ``CodexLLM.call(strict_schema=True)`` was a dead no-op: structured
|
||||
output always went through prompt-injected schema + raw ``json.loads`` on the
|
||||
model's free-form text. Escape-heavy content (code, serial/CLI commands, Windows
|
||||
paths, regexes) makes weaker models emit invalid ``\\escape`` sequences, so every
|
||||
parse attempt fails and retain/consolidation burn all retries and fail.
|
||||
|
||||
The fix:
|
||||
- ``strict_schema=True`` routes structured output through a single forced function
|
||||
tool (constrained decoding into the response schema).
|
||||
- The non-strict fallback now repairs invalid ``\\escape`` sequences before giving up.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from hindsight_api.engine.providers.codex_llm import (
|
||||
CodexLLM,
|
||||
_repair_invalid_json_escapes,
|
||||
)
|
||||
from hindsight_api.engine.response_models import LLMToolCall
|
||||
|
||||
|
||||
class _Fact(BaseModel):
|
||||
fact: str
|
||||
|
||||
|
||||
def build_llm() -> CodexLLM:
|
||||
with patch.object(CodexLLM, "_load_codex_auth", return_value=("token", "account")):
|
||||
return CodexLLM(
|
||||
provider="openai-codex",
|
||||
api_key="ignored",
|
||||
base_url="https://chatgpt.com/backend-api",
|
||||
model="gpt-5.4-mini",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _repair_invalid_json_escapes — pure unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_repair_fixes_invalid_escape_in_json():
|
||||
# `\d` and `\s` are not valid JSON escapes; raw json.loads fails.
|
||||
broken = r'{"fact": "regex \d+\s matches digits"}'
|
||||
import json
|
||||
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
json.loads(broken)
|
||||
repaired = _repair_invalid_json_escapes(broken)
|
||||
assert json.loads(repaired) == {"fact": r"regex \d+\s matches digits"}
|
||||
|
||||
|
||||
def test_repair_preserves_valid_escapes():
|
||||
import json
|
||||
|
||||
valid = r'{"fact": "line1\nline2\ttab \"quoted\" \\backslash é"}'
|
||||
# Already valid — repair must not corrupt it.
|
||||
assert json.loads(_repair_invalid_json_escapes(valid)) == json.loads(valid)
|
||||
|
||||
|
||||
def test_repair_handles_windows_paths():
|
||||
import json
|
||||
|
||||
# Uses path segments whose first char isn't a valid JSON escape letter
|
||||
# (b/f/n/r/t/u), where the repair is unambiguous.
|
||||
broken = r'{"path": "C:\Windows\System32\app.exe"}'
|
||||
assert json.loads(_repair_invalid_json_escapes(broken)) == {"path": r"C:\Windows\System32\app.exe"}
|
||||
|
||||
|
||||
def test_repair_handles_trailing_backslash():
|
||||
# A lone trailing backslash must be escaped, not dropped.
|
||||
assert _repair_invalid_json_escapes("abc\\") == "abc\\\\"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# strict_schema forced-tool path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strict_schema_uses_forced_function_tool():
|
||||
llm = build_llm()
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
tool_call = LLMToolCall(id="call-1", name="structured_response", arguments={"fact": "the sky is blue"})
|
||||
|
||||
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = response
|
||||
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
|
||||
mock_parse.return_value = (None, [tool_call])
|
||||
result = await llm.call(
|
||||
messages=[{"role": "user", "content": "The sky is blue"}],
|
||||
response_format=_Fact,
|
||||
strict_schema=True,
|
||||
max_retries=0,
|
||||
)
|
||||
sent_payload = mock_post.call_args.kwargs["json"]
|
||||
|
||||
# Forced tool wired into the request payload.
|
||||
assert sent_payload["tool_choice"] == {"type": "function", "name": "structured_response"}
|
||||
assert len(sent_payload["tools"]) == 1
|
||||
assert sent_payload["tools"][0]["name"] == "structured_response"
|
||||
assert sent_payload["parallel_tool_calls"] is False
|
||||
# No prompt-injected schema in the instructions.
|
||||
assert "You must respond with valid JSON" not in sent_payload["instructions"]
|
||||
|
||||
assert isinstance(result, _Fact)
|
||||
assert result.fact == "the sky is blue"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strict_schema_skip_validation_returns_dict():
|
||||
llm = build_llm()
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
tool_call = LLMToolCall(id="c", name="structured_response", arguments={"fact": "x"})
|
||||
|
||||
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = response
|
||||
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
|
||||
mock_parse.return_value = (None, [tool_call])
|
||||
result = await llm.call(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
response_format=_Fact,
|
||||
strict_schema=True,
|
||||
skip_validation=True,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
assert result == {"fact": "x"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strict_schema_retries_when_forced_tool_missing():
|
||||
llm = build_llm()
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
|
||||
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = response
|
||||
# Model returns no tool call at all — should raise after retries exhausted.
|
||||
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
|
||||
mock_parse.return_value = ("some prose", [])
|
||||
with pytest.raises(RuntimeError, match="structured_response"):
|
||||
await llm.call(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
response_format=_Fact,
|
||||
strict_schema=True,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-strict fallback: escape repair keeps the retry storm from happening
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_strict_repairs_invalid_escapes_without_retrying():
|
||||
llm = build_llm()
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
# Escape-heavy content the model would emit as invalid JSON.
|
||||
escape_heavy = r'{"fact": "run rig-control \d serial \s command"}'
|
||||
|
||||
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = response
|
||||
with patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock) as mock_parse:
|
||||
mock_parse.return_value = escape_heavy
|
||||
result = await llm.call(
|
||||
messages=[{"role": "user", "content": "coding transcript"}],
|
||||
response_format=_Fact,
|
||||
strict_schema=False,
|
||||
max_retries=3,
|
||||
)
|
||||
|
||||
# Parsed on the first attempt (no retry storm): the SSE stream was read once.
|
||||
assert mock_post.await_count == 1
|
||||
assert isinstance(result, _Fact)
|
||||
assert result.fact == r"run rig-control \d serial \s command"
|
||||
@@ -5,6 +5,7 @@ guard the fix in CI — unlike the real-LLM integration test, which only trigger
|
||||
the path stochastically.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import types
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
@@ -142,6 +143,38 @@ async def test_dedup_llm_missing_action_defaults_to_keep() -> None:
|
||||
conn.execute.assert_not_called() # missing action is a conservative no-merge
|
||||
|
||||
|
||||
def test_dedup_decision_accepts_exact_valid_actions() -> None:
|
||||
assert _DedupDecision(action="merge").action == "merge"
|
||||
assert _DedupDecision(action="keep").action == "keep"
|
||||
|
||||
|
||||
def test_dedup_decision_invalid_action_defaults_to_keep(caplog) -> None:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
decision = _DedupDecision(action="need_input", reason="model asked for more context")
|
||||
|
||||
assert decision.action == "keep"
|
||||
assert "need_input" in caplog.text
|
||||
assert "defaulting to keep" in caplog.text
|
||||
|
||||
|
||||
def test_dedup_decision_near_miss_merge_defaults_to_keep(caplog) -> None:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
decision = _DedupDecision(action="Merge")
|
||||
|
||||
assert decision.action == "keep"
|
||||
assert "Merge" in caplog.text
|
||||
|
||||
|
||||
def test_dedup_decision_non_scalar_action_defaults_to_keep(caplog) -> None:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
list_decision = _DedupDecision(action=[])
|
||||
dict_decision = _DedupDecision(action={"value": "merge"})
|
||||
|
||||
assert list_decision.action == "keep"
|
||||
assert dict_decision.action == "keep"
|
||||
assert "defaulting to keep" in caplog.text
|
||||
|
||||
|
||||
async def test_dedup_llm_merge_folds_into_twin() -> None:
|
||||
kwargs, conn, llm = _ctx()
|
||||
kwargs["create_source_ids"] = [uuid.uuid4(), uuid.uuid4()]
|
||||
|
||||
@@ -78,6 +78,39 @@ async def test_patch_invalidate_and_revert_over_http(api_client, memory):
|
||||
await memory.delete_bank(bank_id, request_context=RequestContext())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_clears_occurred_dates_with_explicit_null(api_client, memory):
|
||||
bank_id = f"curation-http-clear-dates-{uuid.uuid4().hex[:8]}"
|
||||
mem_id = await _insert_fact(memory, bank_id, "Release v1.2 happened on Monday.")
|
||||
pool = await memory._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE memory_units
|
||||
SET occurred_start = '2024-01-15T10:30:00Z',
|
||||
occurred_end = '2024-01-15T11:00:00Z'
|
||||
WHERE id = $1
|
||||
""",
|
||||
uuid.UUID(mem_id),
|
||||
)
|
||||
|
||||
resp = await api_client.patch(
|
||||
f"/v1/default/banks/{bank_id}/memories/{mem_id}",
|
||||
json={"occurred_start": None, "occurred_end": None},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["occurred_start"] is None
|
||||
assert resp.json()["occurred_end"] is None
|
||||
|
||||
resp = await api_client.get(f"/v1/default/banks/{bank_id}/memories/{mem_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["occurred_start"] is None
|
||||
assert resp.json()["occurred_end"] is None
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=RequestContext())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_not_found_returns_404(api_client, memory):
|
||||
bank_id = f"curation-http-404-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
@@ -4,8 +4,7 @@ Unit tests that verify the abstraction interfaces work correctly
|
||||
without requiring a live database connection.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -748,6 +747,85 @@ class TestOracleOpsInsertFactsBatch:
|
||||
assert rows_data[0][13] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PostgreSQL search_vector handling (insert). Since the curation archive drops
|
||||
# search_vector (#2503), the insert is the single place it is populated, and
|
||||
# pg_search_vector_expr is its one source of truth (shared with revert recompute).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPostgreSQLSearchVector:
|
||||
@staticmethod
|
||||
def _cfg(ext: str, lang: str = "english"):
|
||||
from types import SimpleNamespace
|
||||
|
||||
return SimpleNamespace(text_search_extension=ext, text_search_extension_native_language=lang)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ext,needle",
|
||||
[
|
||||
("native", "to_tsvector('english'::regconfig,"),
|
||||
("vchord", "::bm25_catalog.bm25vector"),
|
||||
],
|
||||
)
|
||||
def test_expr_builds_vector_for_vector_backends(self, ext, needle):
|
||||
from hindsight_api.engine.db.ops_postgresql import pg_search_vector_expr
|
||||
|
||||
expr = pg_search_vector_expr(self._cfg(ext))
|
||||
assert expr is not None and needle in expr
|
||||
# Always built from the same three carried columns.
|
||||
assert "COALESCE(text, '')" in expr and "COALESCE(text_signals, '')" in expr
|
||||
|
||||
@pytest.mark.parametrize("ext", ["pgroonga", "pg_textsearch", "pg_search"])
|
||||
def test_expr_none_for_base_column_backends(self, ext):
|
||||
from hindsight_api.engine.db.ops_postgresql import pg_search_vector_expr
|
||||
|
||||
# These index the base text columns directly; search_vector stays empty.
|
||||
assert pg_search_vector_expr(self._cfg(ext)) is None
|
||||
|
||||
def test_expr_accepts_custom_column_refs(self):
|
||||
from hindsight_api.engine.db.ops_postgresql import pg_search_vector_expr
|
||||
|
||||
expr = pg_search_vector_expr(self._cfg("native"), text_col="mu.text", context_col="mu.context")
|
||||
assert "COALESCE(mu.text, '')" in expr and "COALESCE(mu.context, '')" in expr
|
||||
|
||||
async def _insert_query(self, ext: str) -> str:
|
||||
from hindsight_api.engine.db.ops_postgresql import PostgreSQLOps
|
||||
|
||||
conn = AsyncMock(spec=DatabaseConnection)
|
||||
conn.fetch = AsyncMock(return_value=[{"id": "00000000-0000-0000-0000-000000000001"}])
|
||||
batch = dict(
|
||||
bank_id="b",
|
||||
fact_texts=["t"],
|
||||
embeddings=["[0.1]"],
|
||||
event_dates=[None],
|
||||
occurred_starts=[None],
|
||||
occurred_ends=[None],
|
||||
mentioned_ats=[None],
|
||||
contexts=["c"],
|
||||
fact_types=["world"],
|
||||
metadata_jsons=["{}"],
|
||||
chunk_ids=[None],
|
||||
document_ids=[None],
|
||||
tags_list=[""],
|
||||
observation_scopes_list=[None],
|
||||
text_signals_list=[None],
|
||||
)
|
||||
with patch("hindsight_api.config.get_config", return_value=self._cfg(ext)):
|
||||
await PostgreSQLOps().insert_facts_batch(conn=conn, **batch)
|
||||
return conn.fetch.call_args.args[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("ext", ["native", "vchord"])
|
||||
async def test_insert_includes_search_vector_column(self, ext):
|
||||
assert "search_vector" in await self._insert_query(ext)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("ext", ["pgroonga", "pg_textsearch", "pg_search"])
|
||||
async def test_insert_omits_search_vector_column(self, ext):
|
||||
assert "search_vector" not in await self._insert_query(ext)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_schema tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -769,3 +847,68 @@ class TestNormalizeSchema:
|
||||
assert backend.normalize_schema("public") is None
|
||||
assert backend.normalize_schema("tenant_abc") == "tenant_abc"
|
||||
assert backend.normalize_schema(None) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OracleBackend._set_session_schema regression
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOracleSetSessionSchema:
|
||||
"""Regression coverage for _set_session_schema (no live Oracle required)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_await_synchronous_cursor_close(self):
|
||||
"""A non-public schema is applied without awaiting the sync cursor.close().
|
||||
|
||||
oracledb's AsyncCursor.close() is synchronous (returns None), so
|
||||
``await cursor.close()`` raised "object NoneType can't be used in
|
||||
'await' expression" on every acquire() under a non-public schema —
|
||||
breaking the DB health check and all memory operations on Oracle.
|
||||
Reproduced with a fake cursor whose close() is synchronous, exactly
|
||||
like oracledb: this test fails (TypeError) against the buggy code and
|
||||
passes once the erroneous await is removed.
|
||||
"""
|
||||
from hindsight_api.engine import memory_engine
|
||||
from hindsight_api.engine.db.oracle import OracleBackend
|
||||
|
||||
executed: list[str] = []
|
||||
closed = {"count": 0}
|
||||
|
||||
class _FakeAsyncCursor:
|
||||
async def execute(self, sql: str) -> None:
|
||||
executed.append(sql)
|
||||
|
||||
def close(self) -> None: # synchronous, like oracledb.AsyncCursor.close
|
||||
closed["count"] += 1
|
||||
|
||||
class _FakeConn:
|
||||
def cursor(self) -> "_FakeAsyncCursor":
|
||||
return _FakeAsyncCursor()
|
||||
|
||||
backend = OracleBackend()
|
||||
token = memory_engine._current_schema.set("TENANT_X")
|
||||
try:
|
||||
await backend._set_session_schema(_FakeConn())
|
||||
finally:
|
||||
memory_engine._current_schema.reset(token)
|
||||
|
||||
assert closed["count"] == 1
|
||||
assert any('ALTER SESSION SET CURRENT_SCHEMA = "TENANT_X"' in s for s in executed)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_schema_is_a_noop(self):
|
||||
"""The default ``public`` schema does no session work on Oracle."""
|
||||
from hindsight_api.engine import memory_engine
|
||||
from hindsight_api.engine.db.oracle import OracleBackend
|
||||
|
||||
class _FakeConn:
|
||||
def cursor(self):
|
||||
raise AssertionError("cursor() must not be called for the public schema")
|
||||
|
||||
backend = OracleBackend()
|
||||
token = memory_engine._current_schema.set("public")
|
||||
try:
|
||||
await backend._set_session_schema(_FakeConn())
|
||||
finally:
|
||||
memory_engine._current_schema.reset(token)
|
||||
|
||||
@@ -9,12 +9,87 @@ BaseException'), which happened when last_error was only set in the
|
||||
BadRequestError handler and not for non-dict JSON responses.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_output_retry_split_preserves_conversation_array_boundaries():
|
||||
"""OutputTooLong retry splitting must keep conversation chunks valid JSON arrays."""
|
||||
from hindsight_api.engine.retain.fact_extraction import _split_chunk_for_output_retry
|
||||
|
||||
turns = [
|
||||
{"role": "user", "content": "alpha"},
|
||||
{"role": "assistant", "content": "bravo"},
|
||||
{"role": "user", "content": "charlie"},
|
||||
{"role": "assistant", "content": "delta"},
|
||||
]
|
||||
|
||||
split = _split_chunk_for_output_retry(json.dumps(turns))
|
||||
|
||||
assert split is not None
|
||||
first, second = split
|
||||
assert json.loads(first) == turns[:2]
|
||||
assert json.loads(second) == turns[2:]
|
||||
|
||||
|
||||
def test_output_retry_split_divides_single_oversized_turn_content():
|
||||
"""A lone oversized conversation turn is split inside content and rewrapped."""
|
||||
from hindsight_api.engine.retain.fact_extraction import _split_chunk_for_output_retry
|
||||
|
||||
turn = {"role": "user", "content": "abcdefghijklmnopqrstuvwxyz", "name": "casey"}
|
||||
|
||||
split = _split_chunk_for_output_retry(json.dumps([turn]))
|
||||
|
||||
assert split is not None
|
||||
first, second = split
|
||||
first_turn = json.loads(first)[0]
|
||||
second_turn = json.loads(second)[0]
|
||||
assert first_turn["role"] == "user"
|
||||
assert second_turn["role"] == "user"
|
||||
assert first_turn["name"] == "casey"
|
||||
assert second_turn["name"] == "casey"
|
||||
assert first_turn["content"] + second_turn["content"] == turn["content"]
|
||||
|
||||
|
||||
def test_output_retry_split_returns_none_when_no_progress_possible():
|
||||
"""Pathological tiny chunks should be dropped instead of recursively retried."""
|
||||
from hindsight_api.engine.retain.fact_extraction import _split_chunk_for_output_retry
|
||||
|
||||
assert _split_chunk_for_output_retry("x") is None
|
||||
assert _split_chunk_for_output_retry(json.dumps([{"role": "user", "content": ""}])) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_output_too_long_drops_unsplittable_subchunk_without_recursing():
|
||||
"""If a chunk cannot be reduced further, auto-split exits gracefully."""
|
||||
from hindsight_api.engine.llm_wrapper import OutputTooLongError
|
||||
from hindsight_api.engine.retain.fact_extraction import _extract_facts_with_auto_split
|
||||
|
||||
config = _make_config(llm_max_retries=1)
|
||||
llm_config = _make_llm_config(mock_response={})
|
||||
|
||||
with patch(
|
||||
"hindsight_api.engine.retain.fact_extraction._extract_facts_from_chunk",
|
||||
side_effect=OutputTooLongError("too long"),
|
||||
) as extract:
|
||||
facts, usage = await _extract_facts_with_auto_split(
|
||||
chunk="x",
|
||||
chunk_index=0,
|
||||
total_chunks=1,
|
||||
event_date=datetime(2023, 1, 1, tzinfo=timezone.utc),
|
||||
context="",
|
||||
llm_config=llm_config,
|
||||
config=config,
|
||||
agent_name="agent",
|
||||
)
|
||||
|
||||
assert facts == []
|
||||
assert extract.call_count == 1
|
||||
|
||||
|
||||
def _make_config(llm_max_retries: int = 3, retain_llm_max_retries: int | None = None):
|
||||
"""Build a minimal HindsightConfig for fact extraction tests."""
|
||||
from hindsight_api.config import HindsightConfig
|
||||
|
||||
@@ -182,7 +182,7 @@ def test_batch_request_body_strict_follows_config(strict):
|
||||
from hindsight_api.engine.retain.fact_extraction import _build_request_body
|
||||
|
||||
llm_config = SimpleNamespace(model="gpt-4o-mini", provider="openai", _provider_impl=SimpleNamespace())
|
||||
config = SimpleNamespace(retain_max_completion_tokens=None, llm_strict_schema=strict)
|
||||
config = SimpleNamespace(retain_max_completion_tokens=None, llm_strict_schema=strict, llm_temperature_retain=None)
|
||||
# provider != "openai" service-tier branch skipped via _provider_impl without attr
|
||||
llm_config._provider_impl.openai_service_tier = None
|
||||
|
||||
|
||||
@@ -63,3 +63,23 @@ def test_utf8_stream_info_skips_non_utf8_text():
|
||||
latin1 = "café".encode("latin-1") # 0xe9, invalid as standalone UTF-8
|
||||
|
||||
assert MarkitdownParser._utf8_stream_info(latin1, "a.txt") is None
|
||||
|
||||
|
||||
def test_utf8_stream_info_accepts_non_bytes_buffer():
|
||||
"""file_data may arrive as a buffer-protocol object that is not a Python
|
||||
``bytes`` (e.g. a memoryview or a native/Rust-backed buffer) and therefore
|
||||
has no ``.decode``. The UTF-8 probe must coerce via ``bytes()`` instead of
|
||||
assuming concrete ``bytes``, else every text file fails to parse with
|
||||
``'...' object has no attribute 'decode'``.
|
||||
"""
|
||||
# memoryview is a buffer-protocol object with no ``.decode`` and, unlike a
|
||||
# PEP 688 ``__buffer__`` class, ``bytes(memoryview)`` works on every
|
||||
# supported Python version — a portable stand-in for the native buffer the
|
||||
# storage layer returns.
|
||||
buf = memoryview("über".encode("utf-8"))
|
||||
assert not hasattr(buf, "decode") # precondition: would hit the original AttributeError
|
||||
|
||||
info = MarkitdownParser._utf8_stream_info(buf, "a.txt")
|
||||
|
||||
assert info is not None
|
||||
assert info.charset == "utf-8"
|
||||
|
||||
@@ -6,6 +6,7 @@ These tests cover the move semantics, lossless revert (incl. entity
|
||||
associations), edit, the guards, listing, and recall exclusion.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@@ -21,21 +22,29 @@ from hindsight_api.engine.retain import embedding_processing
|
||||
|
||||
|
||||
async def _insert_memory(
|
||||
conn, memory: MemoryEngine, bank_id: str, text: str, fact_type: str = "experience"
|
||||
conn,
|
||||
memory: MemoryEngine,
|
||||
bank_id: str,
|
||||
text: str,
|
||||
fact_type: str = "experience",
|
||||
metadata: dict | None = None,
|
||||
) -> uuid.UUID:
|
||||
"""Insert a live memory unit with a real embedding, bypassing the LLM pipeline."""
|
||||
mem_id = uuid.uuid4()
|
||||
emb = await embedding_processing.generate_embeddings_batch(memory.embeddings, [text])
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO memory_units (id, bank_id, text, fact_type, embedding, event_date, created_at, updated_at, consolidated_at)
|
||||
VALUES ($1, $2, $3, $4, $5::vector, NOW(), NOW(), NOW(), NOW())
|
||||
INSERT INTO memory_units (
|
||||
id, bank_id, text, fact_type, embedding, event_date, metadata, created_at, updated_at, consolidated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5::vector, NOW(), $6::jsonb, NOW(), NOW(), NOW())
|
||||
""",
|
||||
mem_id,
|
||||
bank_id,
|
||||
text,
|
||||
fact_type,
|
||||
str(emb[0]),
|
||||
json.dumps(metadata or {}),
|
||||
)
|
||||
return mem_id
|
||||
|
||||
@@ -101,11 +110,12 @@ async def _archive_row(conn, mem_id: uuid.UUID) -> dict | None:
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def _archive_has_embedding_column(conn) -> bool:
|
||||
async def _archive_has_column(conn, column: str) -> bool:
|
||||
return bool(
|
||||
await conn.fetchval(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = 'invalidated_memory_units' AND column_name = 'embedding'"
|
||||
"WHERE table_name = 'invalidated_memory_units' AND column_name = $1",
|
||||
column,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -174,9 +184,12 @@ class TestInvalidate:
|
||||
arch = await _archive_row(conn, m1)
|
||||
assert arch is not None, "row must be in the archive"
|
||||
assert arch["invalidation_reason"] == "decommissioned"
|
||||
assert not await _archive_has_embedding_column(conn), (
|
||||
assert not await _archive_has_column(conn, "embedding"), (
|
||||
"archive is cold storage; the schema drops the embedding column (#2209)"
|
||||
)
|
||||
assert not await _archive_has_column(conn, "search_vector"), (
|
||||
"archive is cold storage with no index; the schema drops search_vector (#2503)"
|
||||
)
|
||||
assert await _link_count(conn, m1) == 0, "links cascade-pruned on move"
|
||||
assert str(obs_id) not in await _obs_ids(conn, bank_id), "derived observation removed"
|
||||
assert await _consolidated_at(conn, m2) is None, "surviving source reset for re-consolidation"
|
||||
@@ -216,6 +229,10 @@ class TestInvalidate:
|
||||
assert e1 in await _entity_ids_for(conn, m1), "entity associations restored on revert"
|
||||
reverted_emb = await conn.fetchval("SELECT embedding FROM memory_units WHERE id = $1", m1)
|
||||
assert reverted_emb is not None, "embedding recomputed on revert (archive keeps none)"
|
||||
# Native backend (test default) stores a real tsvector; it must be rebuilt on
|
||||
# revert so the reverted fact is keyword-searchable again (archive keeps none, #2503).
|
||||
reverted_sv = await conn.fetchval("SELECT search_vector FROM memory_units WHERE id = $1", m1)
|
||||
assert reverted_sv is not None, "search_vector recomputed on revert (archive keeps none)"
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@@ -261,6 +278,10 @@ class TestEdit:
|
||||
pool = await memory._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
m1 = await _insert_memory(conn, memory, bank_id, "The assistant visited Paris in 2023.")
|
||||
await conn.execute(
|
||||
"UPDATE memory_units SET search_vector = to_tsvector('english'::regconfig, text) WHERE id = $1",
|
||||
m1,
|
||||
)
|
||||
obs_id = await _insert_observation(conn, bank_id, "The assistant went to Paris.", [m1])
|
||||
|
||||
with (
|
||||
@@ -279,9 +300,17 @@ class TestEdit:
|
||||
assert result["state"] == "valid"
|
||||
async with pool.acquire() as conn:
|
||||
assert await _in_live(conn, m1), "edited row stays live"
|
||||
row = dict(await conn.fetchrow("SELECT text, consolidated_at FROM memory_units WHERE id = $1", m1))
|
||||
row = dict(
|
||||
await conn.fetchrow(
|
||||
"SELECT text, consolidated_at, search_vector::text AS search_vector "
|
||||
"FROM memory_units WHERE id = $1",
|
||||
m1,
|
||||
)
|
||||
)
|
||||
assert row["text"] == "The user visited Paris in 2023."
|
||||
assert row["consolidated_at"] is None, "edited memory re-consolidates"
|
||||
assert "'assist'" not in row["search_vector"], "old text must not stay in native FTS search_vector"
|
||||
assert "'user'" in row["search_vector"], "new text must refresh native FTS search_vector"
|
||||
assert str(obs_id) not in await _obs_ids(conn, bank_id), "stale observation re-derived"
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
@@ -472,6 +501,47 @@ class TestGuardsAndListing:
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_and_get_memory_units_include_metadata(
|
||||
self, memory: MemoryEngine, request_context: RequestContext
|
||||
):
|
||||
bank_id = f"test-curation-metadata-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(memory, bank_id, request_context)
|
||||
metadata = {"source": "slack", "channel": "engineering", "thread_id": "T123"}
|
||||
|
||||
pool = await memory._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
mem_id = await _insert_memory(conn, memory, bank_id, "Fact with metadata.", metadata=metadata)
|
||||
|
||||
live = (await memory.list_memory_units(bank_id, request_context=request_context))["items"]
|
||||
live_item = next(item for item in live if item["id"] == str(mem_id))
|
||||
assert live_item["metadata"] == metadata
|
||||
|
||||
detail = await memory.get_memory_unit(bank_id, str(mem_id), request_context=request_context)
|
||||
assert detail is not None
|
||||
assert detail["metadata"] == metadata
|
||||
|
||||
with (
|
||||
patch.object(memory, "submit_async_consolidation", new=AsyncMock()),
|
||||
patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()),
|
||||
):
|
||||
await memory.update_memory_unit(
|
||||
bank_id, str(mem_id), state="invalidated", reason="stale", request_context=request_context
|
||||
)
|
||||
|
||||
invalid = (await memory.list_memory_units(bank_id, state="invalidated", request_context=request_context))[
|
||||
"items"
|
||||
]
|
||||
assert invalid[0]["id"] == str(mem_id)
|
||||
assert invalid[0]["metadata"] == metadata
|
||||
|
||||
invalid_detail = await memory.get_memory_unit(bank_id, str(mem_id), request_context=request_context)
|
||||
assert invalid_detail is not None
|
||||
assert invalid_detail["state"] == "invalidated"
|
||||
assert invalid_detail["metadata"] == metadata
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_filters_by_document(self, memory: MemoryEngine, request_context: RequestContext):
|
||||
bank_id = f"test-curation-doc-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
|
||||
|
||||
|
||||
def _make_minimax(extra_body=None) -> OpenAICompatibleLLM:
|
||||
return OpenAICompatibleLLM(
|
||||
provider="minimax",
|
||||
api_key="test-key",
|
||||
base_url="",
|
||||
model="MiniMax-M3",
|
||||
extra_body=extra_body,
|
||||
)
|
||||
|
||||
|
||||
def _text_response(content: str = "ok"):
|
||||
return SimpleNamespace(
|
||||
error=None,
|
||||
usage=None,
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
finish_reason="stop",
|
||||
message=SimpleNamespace(content=content, tool_calls=None, refusal=None),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _tool_response():
|
||||
tool_call = SimpleNamespace(
|
||||
id="call_minimax_123",
|
||||
function=SimpleNamespace(name="recall", arguments='{"query": "Project Rin"}'),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
error=None,
|
||||
usage=None,
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
finish_reason="tool_calls",
|
||||
message=SimpleNamespace(content=None, tool_calls=[tool_call], refusal=None),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_minimax_call_disables_thinking_by_default():
|
||||
llm = _make_minimax()
|
||||
llm._client.chat.completions.create = AsyncMock(return_value=_text_response())
|
||||
|
||||
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
|
||||
await llm.call(messages=[{"role": "user", "content": "hi"}], max_retries=0)
|
||||
|
||||
assert llm._client.chat.completions.create.call_args.kwargs["extra_body"] == {"thinking": {"type": "disabled"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_minimax_call_preserves_configured_thinking_extra_body():
|
||||
llm = _make_minimax(extra_body={"thinking": {"type": "enabled"}, "reasoning_split": True})
|
||||
llm._client.chat.completions.create = AsyncMock(return_value=_text_response())
|
||||
|
||||
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
|
||||
await llm.call(messages=[{"role": "user", "content": "hi"}], max_retries=0)
|
||||
|
||||
assert llm._client.chat.completions.create.call_args.kwargs["extra_body"] == {
|
||||
"thinking": {"type": "enabled"},
|
||||
"reasoning_split": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_minimax_tool_call_disables_thinking_by_default():
|
||||
llm = _make_minimax()
|
||||
llm._client.chat.completions.create = AsyncMock(return_value=_tool_response())
|
||||
|
||||
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
|
||||
await llm.call_with_tools(
|
||||
messages=[{"role": "user", "content": "Search memory."}],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "recall",
|
||||
"description": "Recall memories",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
assert llm._client.chat.completions.create.call_args.kwargs["extra_body"] == {"thinking": {"type": "disabled"}}
|
||||
@@ -10,12 +10,18 @@ Covers:
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.consolidation.prompts import build_batch_consolidation_prompt
|
||||
from hindsight_api.engine.prompt_utils import output_language_directive
|
||||
from hindsight_api.engine.reflect.prompts import build_final_system_prompt
|
||||
from hindsight_api.engine.retain.fact_extraction import _build_extraction_prompt_and_schema
|
||||
from hindsight_api.engine.search import retrieval as retrieval_mod
|
||||
from hindsight_api.engine.search.retrieval import tokenize_query
|
||||
from hindsight_api.engine.sql.postgresql import PostgreSQLDialect
|
||||
|
||||
|
||||
def _baseline_config() -> MagicMock:
|
||||
@@ -165,3 +171,75 @@ def test_configurable_bm25_language_migration_chains_off_head():
|
||||
src = target.read_text()
|
||||
assert 'revision: str = "p4q5r6s7t8u9"' in src
|
||||
assert 'down_revision: str | Sequence[str] | None = "86f7a033d372"' in src
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BM25 query term cap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_postgresql_native_bm25_caps_raw_terms_preserving_order():
|
||||
query = "Alpha beta alpha, gamma delta beta epsilon"
|
||||
tokens = tokenize_query(query)
|
||||
|
||||
assert PostgreSQLDialect().prepare_bm25_text(tokens, query, max_query_terms=3) == "alpha | beta | alpha"
|
||||
|
||||
|
||||
def test_postgresql_native_bm25_zero_cap_keeps_existing_unlimited_behavior():
|
||||
query = "Alpha beta alpha"
|
||||
tokens = tokenize_query(query)
|
||||
|
||||
assert PostgreSQLDialect().prepare_bm25_text(tokens, query, max_query_terms=0) == "alpha | beta | alpha"
|
||||
|
||||
|
||||
def test_postgresql_extension_bm25_keeps_raw_query_text():
|
||||
query = "Alpha beta alpha, gamma delta beta epsilon"
|
||||
tokens = tokenize_query(query)
|
||||
|
||||
assert (
|
||||
PostgreSQLDialect().prepare_bm25_text(tokens, query, text_search_extension="vchord", max_query_terms=3) == query
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_combined_retrieval_uses_default_bm25_cap_for_legacy_config(monkeypatch):
|
||||
class FakeDialect:
|
||||
max_query_terms: int | None = None
|
||||
|
||||
def build_semantic_arm(self, **kwargs):
|
||||
return "SELECT 'semantic' AS source"
|
||||
|
||||
def build_bm25_arm(self, **kwargs):
|
||||
return "SELECT 'bm25' AS source"
|
||||
|
||||
def prepare_bm25_text(self, tokens, query_text, *, text_search_extension="native", max_query_terms=None):
|
||||
self.max_query_terms = max_query_terms
|
||||
return " | ".join(tokens)
|
||||
|
||||
class FakeConn:
|
||||
backend_type = "postgresql"
|
||||
|
||||
async def fetch(self, query, *params):
|
||||
return []
|
||||
|
||||
fake_dialect = FakeDialect()
|
||||
legacy_config = SimpleNamespace(
|
||||
semantic_min_similarity=0.0,
|
||||
bm25_min_score=0.0,
|
||||
text_search_extension="native",
|
||||
text_search_extension_native_language="english",
|
||||
)
|
||||
monkeypatch.setattr(retrieval_mod, "get_config", lambda: legacy_config)
|
||||
monkeypatch.setattr(retrieval_mod, "create_sql_dialect", lambda backend: fake_dialect)
|
||||
|
||||
result = await retrieval_mod.retrieve_semantic_bm25_combined(
|
||||
FakeConn(),
|
||||
"[0.0]",
|
||||
"alpha beta",
|
||||
"bank-1",
|
||||
["observation"],
|
||||
5,
|
||||
)
|
||||
|
||||
assert result == {"observation": ([], [])}
|
||||
assert fake_dialect.max_query_terms == 0
|
||||
|
||||
@@ -65,12 +65,14 @@ class TestRecallConfigFields:
|
||||
"""Hierarchical config fields for internal recall."""
|
||||
|
||||
def test_fields_exist_on_dataclass(self):
|
||||
from hindsight_api.config import HindsightConfig
|
||||
from hindsight_api.config import DEFAULT_BM25_MAX_QUERY_TERMS, HindsightConfig
|
||||
|
||||
names = {f.name for f in dataclasses.fields(HindsightConfig)}
|
||||
assert "recall_include_chunks" in names
|
||||
assert "recall_max_tokens" in names
|
||||
assert "recall_chunks_max_tokens" in names
|
||||
assert "bm25_max_query_terms" in names
|
||||
assert HindsightConfig.__dataclass_fields__["bm25_max_query_terms"].default == DEFAULT_BM25_MAX_QUERY_TERMS
|
||||
|
||||
def test_fields_are_configurable(self):
|
||||
from hindsight_api.config import HindsightConfig
|
||||
@@ -82,6 +84,7 @@ class TestRecallConfigFields:
|
||||
|
||||
def test_default_values(self):
|
||||
from hindsight_api.config import (
|
||||
DEFAULT_BM25_MAX_QUERY_TERMS,
|
||||
DEFAULT_RECALL_CHUNKS_MAX_TOKENS,
|
||||
DEFAULT_RECALL_INCLUDE_CHUNKS,
|
||||
DEFAULT_RECALL_MAX_TOKENS,
|
||||
@@ -90,9 +93,11 @@ class TestRecallConfigFields:
|
||||
assert DEFAULT_RECALL_INCLUDE_CHUNKS is True
|
||||
assert DEFAULT_RECALL_MAX_TOKENS == 2048
|
||||
assert DEFAULT_RECALL_CHUNKS_MAX_TOKENS == 1000
|
||||
assert DEFAULT_BM25_MAX_QUERY_TERMS == 0
|
||||
|
||||
def test_env_var_constants(self):
|
||||
from hindsight_api.config import (
|
||||
ENV_BM25_MAX_QUERY_TERMS,
|
||||
ENV_RECALL_CHUNKS_MAX_TOKENS,
|
||||
ENV_RECALL_INCLUDE_CHUNKS,
|
||||
ENV_RECALL_MAX_TOKENS,
|
||||
@@ -101,6 +106,7 @@ class TestRecallConfigFields:
|
||||
assert ENV_RECALL_INCLUDE_CHUNKS == "HINDSIGHT_API_RECALL_INCLUDE_CHUNKS"
|
||||
assert ENV_RECALL_MAX_TOKENS == "HINDSIGHT_API_RECALL_MAX_TOKENS"
|
||||
assert ENV_RECALL_CHUNKS_MAX_TOKENS == "HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS"
|
||||
assert ENV_BM25_MAX_QUERY_TERMS == "HINDSIGHT_API_BM25_MAX_QUERY_TERMS"
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
@@ -108,6 +114,7 @@ class TestRecallConfigFields:
|
||||
"HINDSIGHT_API_RECALL_INCLUDE_CHUNKS": "false",
|
||||
"HINDSIGHT_API_RECALL_MAX_TOKENS": "777",
|
||||
"HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS": "333",
|
||||
"HINDSIGHT_API_BM25_MAX_QUERY_TERMS": "24",
|
||||
},
|
||||
)
|
||||
def test_from_env_reads_overrides(self):
|
||||
@@ -117,6 +124,14 @@ class TestRecallConfigFields:
|
||||
assert config.recall_include_chunks is False
|
||||
assert config.recall_max_tokens == 777
|
||||
assert config.recall_chunks_max_tokens == 333
|
||||
assert config.bm25_max_query_terms == 24
|
||||
|
||||
@patch.dict("os.environ", {"HINDSIGHT_API_BM25_MAX_QUERY_TERMS": "-1"})
|
||||
def test_from_env_rejects_negative_bm25_max_query_terms(self):
|
||||
from hindsight_api.config import HindsightConfig
|
||||
|
||||
with pytest.raises(ValueError, match="HINDSIGHT_API_BM25_MAX_QUERY_TERMS must be >= 0"):
|
||||
HindsightConfig.from_env()
|
||||
|
||||
|
||||
class TestMentalModelTriggerRecallFields:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for _strip_code_fences helper in OpenAI-compatible LLM provider."""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
|
||||
from hindsight_api.engine.providers.openai_compatible_llm import _strip_code_fences
|
||||
|
||||
@@ -36,6 +36,22 @@ class TestStripCodeFences:
|
||||
result = _strip_code_fences(content)
|
||||
assert '{"facts": []}' in result
|
||||
|
||||
def test_inner_backticks_preserved(self):
|
||||
"""Inner triple-backticks inside a JSON string value must not truncate the JSON.
|
||||
|
||||
Regression for the fact-extraction case where an extracted fact describes
|
||||
code-fence behavior, so the JSON payload itself contains a literal
|
||||
```` ```json ```` — the old split-based stripper matched that inner
|
||||
occurrence and cut the JSON mid-string.
|
||||
"""
|
||||
import json
|
||||
|
||||
content = '```json\n{"facts": [{"what": "the model wraps output in ```json fences"}]}\n```'
|
||||
result = _strip_code_fences(content)
|
||||
assert result == '{"facts": [{"what": "the model wraps output in ```json fences"}]}'
|
||||
parsed = json.loads(result)
|
||||
assert parsed["facts"][0]["what"] == "the model wraps output in ```json fences"
|
||||
|
||||
def test_no_fences_no_change(self):
|
||||
"""Content without any backticks passes through."""
|
||||
content = "Just some text without fences"
|
||||
@@ -53,12 +69,25 @@ class TestStripCodeFences:
|
||||
assert '"line2"' in result
|
||||
assert "```" not in result
|
||||
|
||||
def test_malformed_fence_returns_original(self):
|
||||
"""Malformed fences (missing closing) return something parseable."""
|
||||
def test_missing_closing_fence_recovers_json(self):
|
||||
"""A fence with no closing ``` still recovers the JSON via the outer-span fallback."""
|
||||
content = '```json\n{"facts": []}'
|
||||
result = _strip_code_fences(content)
|
||||
# Should attempt to strip and return best effort
|
||||
assert json.loads(result) == {"facts": []}
|
||||
|
||||
def test_prose_wrapped_json_recovered(self):
|
||||
"""JSON surrounded by prose (no usable fence) is recovered by the fallback."""
|
||||
content = 'Sure! Here is the result:\n{"facts": [{"what": "x"}]}\nLet me know if that helps.'
|
||||
result = _strip_code_fences(content)
|
||||
assert json.loads(result) == {"facts": [{"what": "x"}]}
|
||||
|
||||
def test_non_json_fence_left_for_retry(self):
|
||||
"""A fenced block that is not JSON yields no valid candidate; content is returned unchanged."""
|
||||
content = "```\nnot json at all\n```"
|
||||
result = _strip_code_fences(content)
|
||||
# No parseable JSON anywhere -> caller sees the stripped body (still a str), never crashes.
|
||||
assert isinstance(result, str)
|
||||
assert "not json at all" in result
|
||||
|
||||
def test_minimax_style_response(self):
|
||||
"""Real-world MiniMax response format."""
|
||||
|
||||
@@ -10,6 +10,26 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const DEFAULT_CLI_USER_AGENT: &str = concat!("hindsight-cli/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
fn default_headers(api_key: Option<&str>) -> Result<reqwest::header::HeaderMap> {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::USER_AGENT,
|
||||
reqwest::header::HeaderValue::from_static(DEFAULT_CLI_USER_AGENT),
|
||||
);
|
||||
|
||||
if let Some(key) = api_key {
|
||||
let auth_value = format!("Bearer {}", key);
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
reqwest::header::HeaderValue::from_str(&auth_value)?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
/// Convert a progenitor client error into an anyhow error that includes the
|
||||
/// HTTP response body. Without this, errors render as
|
||||
/// "Unexpected Response: Response { ... }" with no body, hiding validation
|
||||
@@ -112,15 +132,7 @@ impl ApiClient {
|
||||
let mut client_builder =
|
||||
reqwest::Client::builder().timeout(std::time::Duration::from_secs(120));
|
||||
|
||||
if let Some(key) = api_key {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
let auth_value = format!("Bearer {}", key);
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
reqwest::header::HeaderValue::from_str(&auth_value)?,
|
||||
);
|
||||
client_builder = client_builder.default_headers(headers);
|
||||
}
|
||||
client_builder = client_builder.default_headers(default_headers(api_key.as_deref())?);
|
||||
|
||||
let http_client = client_builder.build()?;
|
||||
|
||||
@@ -1309,6 +1321,31 @@ pub use types::{
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_headers_set_cli_user_agent_without_api_key() {
|
||||
let headers = default_headers(None).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
headers.get(reqwest::header::USER_AGENT).unwrap(),
|
||||
DEFAULT_CLI_USER_AGENT,
|
||||
);
|
||||
assert!(!headers.contains_key(reqwest::header::AUTHORIZATION));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_headers_keep_authorization_with_cli_user_agent() {
|
||||
let headers = default_headers(Some("hsk_test")).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
headers.get(reqwest::header::USER_AGENT).unwrap(),
|
||||
DEFAULT_CLI_USER_AGENT,
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get(reqwest::header::AUTHORIZATION).unwrap(),
|
||||
"Bearer hsk_test",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_operation_deserialize() {
|
||||
let json = r#"{
|
||||
|
||||
@@ -6339,6 +6339,9 @@ components:
|
||||
date: 2024-01-15T10:30:00Z
|
||||
entities: "Alice (PERSON), Google (ORGANIZATION)"
|
||||
id: 550e8400-e29b-41d4-a716-446655440000
|
||||
metadata:
|
||||
channel: engineering
|
||||
source: slack
|
||||
text: Alice works at Google on the AI team
|
||||
type: world
|
||||
limit: 100
|
||||
|
||||
@@ -592,7 +592,7 @@ class Hindsight:
|
||||
disposition_empathy: Deprecated. Use update_bank_config(disposition_empathy=...) instead.
|
||||
disposition: Deprecated. Use update_bank_config(disposition_skepticism=...) instead.
|
||||
retain_mission: Steers what gets extracted during retain(). Injected alongside built-in rules.
|
||||
retain_extraction_mode: Fact extraction mode: 'concise' (default), 'verbose', or 'custom'.
|
||||
retain_extraction_mode: Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'.
|
||||
retain_custom_instructions: Custom extraction prompt (only active when mode is 'custom').
|
||||
retain_chunk_size: Target maximum characters for each content chunk during retain.
|
||||
retain_structured_chunk_size: Maximum characters for a single JSONL line or conversation
|
||||
@@ -731,7 +731,7 @@ class Hindsight:
|
||||
disposition_empathy: Deprecated. Use update_bank_config(disposition_empathy=...) instead.
|
||||
disposition: Deprecated. Use update_bank_config(disposition_skepticism=...) instead.
|
||||
retain_mission: Steers what gets extracted during retain(). Injected alongside built-in rules.
|
||||
retain_extraction_mode: Fact extraction mode: 'concise' (default), 'verbose', or 'custom'.
|
||||
retain_extraction_mode: Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'.
|
||||
retain_custom_instructions: Custom extraction prompt (only active when mode is 'custom').
|
||||
retain_chunk_size: Target maximum characters for each content chunk during retain.
|
||||
retain_structured_chunk_size: Maximum characters for a single JSONL line or conversation
|
||||
|
||||
@@ -453,7 +453,7 @@ export type BankTemplateConfig = {
|
||||
/**
|
||||
* Retain Extraction Mode
|
||||
*
|
||||
* Fact extraction mode: 'concise' (default), 'verbose', or 'custom'
|
||||
* Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'
|
||||
*/
|
||||
retain_extraction_mode?: string | null;
|
||||
/**
|
||||
@@ -1080,7 +1080,7 @@ export type CreateBankRequest = {
|
||||
/**
|
||||
* Retain Extraction Mode
|
||||
*
|
||||
* Fact extraction mode: 'concise' (default), 'verbose', or 'custom'.
|
||||
* Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'.
|
||||
*/
|
||||
retain_extraction_mode?: string | null;
|
||||
/**
|
||||
|
||||
@@ -500,7 +500,7 @@ export class HindsightClient {
|
||||
dispositionEmpathy?: number;
|
||||
/** Steers what gets extracted during retain(). Injected alongside built-in rules. */
|
||||
retainMission?: string;
|
||||
/** Fact extraction mode: 'concise' (default), 'verbose', or 'custom'. */
|
||||
/** Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'. */
|
||||
retainExtractionMode?: string;
|
||||
/** Custom extraction prompt (only active when retainExtractionMode is 'custom'). */
|
||||
retainCustomInstructions?: string;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"public"
|
||||
],
|
||||
"scripts": {
|
||||
"prebuild": "npm run build -w @vectorize-io/hindsight-client",
|
||||
"dev": "next dev --turbopack -p ${PORT:-9999}",
|
||||
"build": "NODE_ENV=production next build && npm run build:standalone",
|
||||
"build:standalone": "rm -rf standalone && SERVER_JS=$(find .next/standalone -path '*/node_modules' -prune -o -name 'server.js' -print | head -1) && test -n \"$SERVER_JS\" || (echo 'Error: server.js not found in .next/standalone - standalone build failed' && exit 1) && STANDALONE_ROOT=$(dirname \"$SERVER_JS\") && cp -r \"$STANDALONE_ROOT\" standalone && cp -r .next/standalone/node_modules standalone/node_modules && mkdir -p standalone/.next && cp -r .next/static standalone/.next/static && mkdir -p standalone/public && (cp -r public/* standalone/public/ 2>/dev/null || true)",
|
||||
@@ -38,14 +39,12 @@
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.5",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/cytoscape": "^3.21.9",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
@@ -55,8 +54,6 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"cron-parser": "^5.6.1",
|
||||
"cronstrue": "^3.21.0",
|
||||
"cytoscape": "^3.33.1",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.1",
|
||||
"lucide-react": "^0.553.0",
|
||||
|
||||
@@ -8,6 +8,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank
|
||||
const { searchParams } = new URL(request.url);
|
||||
const tags = searchParams.getAll("tags");
|
||||
const tagsMatch = searchParams.get("tags_match");
|
||||
const limit = searchParams.get("limit");
|
||||
const offset = searchParams.get("offset");
|
||||
|
||||
if (!bankId) {
|
||||
return NextResponse.json(
|
||||
@@ -26,6 +28,12 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank
|
||||
if (tagsMatch) {
|
||||
queryParams.append("tags_match", tagsMatch);
|
||||
}
|
||||
if (limit) {
|
||||
queryParams.append("limit", limit);
|
||||
}
|
||||
if (offset) {
|
||||
queryParams.append("offset", offset);
|
||||
}
|
||||
|
||||
const url = dataplaneBankUrl(
|
||||
bankId,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRef, useEffect, useCallback, useMemo, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { prepare, layout, prepareWithSegments, layoutWithLines } from "@chenglou/pretext";
|
||||
import type { GraphData, GraphNode, GraphLink } from "./graph-2d";
|
||||
import type { GraphData, GraphNode, GraphLink } from "./graph-data";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -21,6 +21,12 @@ interface PreparedNode {
|
||||
/** Color derived from link count (heat gradient) */
|
||||
heatColor: string;
|
||||
linkCount: number;
|
||||
/**
|
||||
* Per-node phase in [0, 2π), derived from the id hash. Desynchronizes the
|
||||
* ambient drift + pulse so the field breathes organically instead of in
|
||||
* lockstep. Precomputed here so the animation loop stays trig-only.
|
||||
*/
|
||||
phase: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -372,6 +378,7 @@ export function Constellation({
|
||||
// so the grouping reads at a glance; otherwise it keeps the heat gradient.
|
||||
heatColor: centroid ? color : heat,
|
||||
linkCount: lc,
|
||||
phase: ((Math.abs(seed) % 1000) / 1000) * Math.PI * 2,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -448,15 +455,24 @@ export function Constellation({
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Screen positions
|
||||
// Ambient-motion clock (seconds).
|
||||
const time = (typeof performance !== "undefined" ? performance.now() : 0) / 1000;
|
||||
// Drift amplitude in world units — nodes slowly wander around their home
|
||||
// position so the whole field visibly breathes.
|
||||
const DRIFT_AMP = 16;
|
||||
|
||||
// Screen positions (with a slow per-node ambient drift baked in, so links —
|
||||
// which read straight from screenX/screenY below — follow for free).
|
||||
const screenX = new Float32Array(preparedNodes.length);
|
||||
const screenY = new Float32Array(preparedNodes.length);
|
||||
const visible = new Uint8Array(preparedNodes.length);
|
||||
|
||||
for (let i = 0; i < preparedNodes.length; i++) {
|
||||
const n = preparedNodes[i];
|
||||
const sx = cx + n.wx * zoom;
|
||||
const sy = cy + n.wy * zoom;
|
||||
const driftX = DRIFT_AMP * Math.sin(time * 0.6 + n.phase);
|
||||
const driftY = DRIFT_AMP * Math.cos(time * 0.5 + n.phase * 1.3);
|
||||
const sx = cx + (n.wx + driftX) * zoom;
|
||||
const sy = cy + (n.wy + driftY) * zoom;
|
||||
screenX[i] = sx;
|
||||
screenY[i] = sy;
|
||||
visible[i] = sx > -margin && sx < W + margin && sy > -margin && sy < H + margin ? 1 : 0;
|
||||
@@ -508,11 +524,39 @@ export function Constellation({
|
||||
ctx.moveTo(ax, ay);
|
||||
ctx.quadraticCurveTo(midX, midY, bx, by);
|
||||
ctx.stroke();
|
||||
|
||||
// A small bead of light travels the curve from the hovered node outward,
|
||||
// so connections read as live signal paths rather than static lines.
|
||||
{
|
||||
// Phase-offset per link so beads don't march in lockstep. Travel runs
|
||||
// from the hovered node (u=0) toward its neighbor (u=1).
|
||||
const fromHovered = link.a === hoverIndex;
|
||||
const raw = (time * 0.22 + (li % 13) / 13) % 1;
|
||||
const u = fromHovered ? raw : 1 - raw;
|
||||
const iu = 1 - u;
|
||||
// Point on the quadratic Bézier at parameter u.
|
||||
const px = iu * iu * ax + 2 * iu * u * midX + u * u * bx;
|
||||
const py = iu * iu * ay + 2 * iu * u * midY + u * u * by;
|
||||
ctx.globalAlpha = 0.9 * (0.4 + 0.6 * Math.sin(u * Math.PI)); // fade at the ends
|
||||
ctx.fillStyle = link.color;
|
||||
ctx.shadowColor = link.color;
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
// Restore the stroke state the loop's next iteration expects.
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.lineWidth = 1.5;
|
||||
}
|
||||
linksDrawn++;
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
} else {
|
||||
const baseAlpha = 0.06 + Math.min(zoom * 0.04, 0.1);
|
||||
// Faint, slow breathing across the whole web so idle links feel alive
|
||||
// without flickering (one global sine, not per-link — stays calm).
|
||||
const shimmer = 1 + 0.18 * Math.sin(time * 0.6);
|
||||
const baseAlpha = (0.06 + Math.min(zoom * 0.04, 0.1)) * shimmer;
|
||||
ctx.lineWidth = 0.4;
|
||||
|
||||
for (const link of linksWithIndices) {
|
||||
@@ -661,11 +705,18 @@ export function Constellation({
|
||||
// Size varies slightly by link count — subtle range like star magnitudes.
|
||||
// When nodeSizeFn is provided (e.g. entities view), it overrides linkCount
|
||||
// sizing so dots can scale by an external weight like co-occurrence count.
|
||||
const baseR = nodeSizeFn ? nodeSizeFn(n.node) : 2.5 + Math.min(n.linkCount * 0.15, 2.5);
|
||||
const rawR = nodeSizeFn ? nodeSizeFn(n.node) : 2.5 + Math.min(n.linkCount * 0.15, 2.5);
|
||||
// Gentle pulse — each dot "breathes" in size, out of phase with its
|
||||
// neighbors, so the field twinkles like a living star map.
|
||||
const pulse = 1 + 0.13 * Math.sin(time * 1.05 + n.phase);
|
||||
const baseR = rawR * pulse;
|
||||
const r = Math.max(1.5, baseR * Math.min(zoom, 2));
|
||||
|
||||
// Opacity varies — fewer links = dimmer, more links = brighter
|
||||
const baseAlpha = 0.45 + Math.min(n.linkCount * 0.03, 0.5);
|
||||
// Opacity varies — fewer links = dimmer, more links = brighter. A brightness
|
||||
// twinkle (offset from the size pulse) makes even tiny dots read as alive,
|
||||
// where a radius pulse alone would be imperceptible.
|
||||
const twinkleAlpha = 0.82 + 0.18 * Math.sin(time * 1.4 + n.phase * 2.1);
|
||||
const baseAlpha = (0.45 + Math.min(n.linkCount * 0.03, 0.5)) * twinkleAlpha;
|
||||
|
||||
// Dot — star-like: heat-gradient color, varied size & opacity
|
||||
ctx.beginPath();
|
||||
@@ -679,12 +730,14 @@ export function Constellation({
|
||||
}
|
||||
ctx.fill();
|
||||
|
||||
// Soft glow halo for brighter stars (high link count)
|
||||
// Soft glow halo for brighter stars (high link count) — the halo twinkles
|
||||
// a little (out of phase with the dot's pulse) so hubs feel radiant.
|
||||
if (n.linkCount > 3 && !isHovered && hoverIndex < 0) {
|
||||
const twinkle = 1 + 0.25 * Math.sin(time * 0.9 + n.phase * 1.7);
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, r * 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = n.heatColor;
|
||||
ctx.globalAlpha = 0.06 + Math.min(n.linkCount * 0.005, 0.08);
|
||||
ctx.globalAlpha = (0.06 + Math.min(n.linkCount * 0.005, 0.08)) * twinkle;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
@@ -1119,9 +1172,17 @@ export function Constellation({
|
||||
canvas.addEventListener("mouseup", handleMouseUp);
|
||||
canvas.addEventListener("mouseleave", handleMouseLeave);
|
||||
|
||||
// The canvas also changes size when its container reflows (e.g. the side
|
||||
// panel opening/closing) with no window "resize" event. Observe the element
|
||||
// so the backing store is re-measured — otherwise CSS stretches the old
|
||||
// bitmap and the text/dots look squeezed.
|
||||
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => resize()) : null;
|
||||
ro?.observe(canvas);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animRef.current);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
ro?.disconnect();
|
||||
canvas.removeEventListener("wheel", handleWheel);
|
||||
canvas.removeEventListener("mousemove", handleMouseMove);
|
||||
canvas.removeEventListener("mousedown", handleMouseDown);
|
||||
|
||||
@@ -16,11 +16,9 @@ import {
|
||||
ChevronsRight,
|
||||
Settings2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Network,
|
||||
List,
|
||||
Search,
|
||||
Layers,
|
||||
@@ -33,8 +31,6 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
@@ -43,16 +39,15 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { MemoryDetailPanel } from "./memory-detail-panel";
|
||||
import { MemoryDetailModal } from "./memory-detail-modal";
|
||||
import { Graph2D, convertHindsightGraphData, GraphNode } from "./graph-2d";
|
||||
import { convertHindsightGraphData, GraphNode } from "./graph-data";
|
||||
import { Constellation } from "./constellation";
|
||||
import { TagFilterInput } from "./tag-filter-input";
|
||||
import { ObservationScopeFilter, ObservationScope } from "./observation-scope-filter";
|
||||
import { ScatterChart, Plus, FileText } from "lucide-react";
|
||||
|
||||
type FactType = "world" | "experience" | "observation";
|
||||
type ViewMode = "graph" | "table" | "timeline" | "constellation";
|
||||
type ViewMode = "table" | "timeline" | "constellation";
|
||||
|
||||
// Categorical palette for coloring observation scopes (exact tag sets) when
|
||||
// "Group by scope" clusters the constellation. Distinct, reasonably separable hues.
|
||||
@@ -105,7 +100,6 @@ export function DataView({
|
||||
const [scopes, setScopes] = useState<ObservationScope[]>([]);
|
||||
const [selectedScope, setSelectedScope] = useState<string[] | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedGraphNode, setSelectedGraphNode] = useState<any>(null);
|
||||
const [modalMemoryId, setModalMemoryId] = useState<string | null>(null);
|
||||
// Table view: toggle between live facts (graph-fed) and invalidated facts (archive).
|
||||
const [showInvalidated, setShowInvalidated] = useState(false);
|
||||
@@ -132,10 +126,7 @@ export function DataView({
|
||||
last_consolidated_at: string | null;
|
||||
} | null>(null);
|
||||
|
||||
// Graph controls state
|
||||
const [showLabels, setShowLabels] = useState(true);
|
||||
const [maxNodes, setMaxNodes] = useState<number | undefined>(undefined);
|
||||
const [showControlPanel, setShowControlPanel] = useState(true);
|
||||
// Constellation controls state
|
||||
const [visibleLinkTypes, setVisibleLinkTypes] = useState<Set<string>>(
|
||||
new Set(["semantic", "temporal", "entity", "causal"])
|
||||
);
|
||||
@@ -152,17 +143,6 @@ export function DataView({
|
||||
});
|
||||
};
|
||||
|
||||
// Esc key handler to deselect graph node
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && selectedGraphNode) {
|
||||
setSelectedGraphNode(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedGraphNode]);
|
||||
|
||||
// `silent` skips the loading spinner — used by the background consolidation
|
||||
// poll so the view refreshes in place without flashing.
|
||||
const loadData = async (
|
||||
@@ -230,6 +210,8 @@ export function DataView({
|
||||
if (showInvalidated) return invalidatedRows;
|
||||
return data?.table_rows ?? [];
|
||||
}, [data, showInvalidated, invalidatedRows]);
|
||||
const hasActiveMemoryFilters =
|
||||
searchQuery.trim().length > 0 || tagFilters.length > 0 || selectedScope !== null;
|
||||
|
||||
// Helper to get normalized link type
|
||||
const getLinkTypeCategory = (type: string | undefined): string => {
|
||||
@@ -239,7 +221,7 @@ export function DataView({
|
||||
return "semantic";
|
||||
};
|
||||
|
||||
// Convert data for Graph2D (graph data is already filtered server-side)
|
||||
// Convert data for the constellation (graph data is already filtered server-side)
|
||||
const graph2DData = useMemo(() => {
|
||||
if (!data) return { nodes: [], links: [] };
|
||||
const fullData = convertHindsightGraphData(data);
|
||||
@@ -253,44 +235,11 @@ export function DataView({
|
||||
return { nodes: fullData.nodes, links };
|
||||
}, [data, visibleLinkTypes]);
|
||||
|
||||
// Calculate link stats for display
|
||||
const linkStats = useMemo(() => {
|
||||
let semantic = 0,
|
||||
temporal = 0,
|
||||
entity = 0,
|
||||
causal = 0,
|
||||
total = 0;
|
||||
const otherTypes: Record<string, number> = {};
|
||||
graph2DData.links.forEach((l) => {
|
||||
total++;
|
||||
const type = l.type || "unknown";
|
||||
if (type === "semantic") semantic++;
|
||||
else if (type === "temporal") temporal++;
|
||||
else if (type === "entity") entity++;
|
||||
else if (
|
||||
type === "causes" ||
|
||||
type === "caused_by" ||
|
||||
type === "enables" ||
|
||||
type === "prevents"
|
||||
)
|
||||
causal++;
|
||||
else {
|
||||
otherTypes[type] = (otherTypes[type] || 0) + 1;
|
||||
}
|
||||
});
|
||||
return { semantic, temporal, entity, causal, total, otherTypes };
|
||||
}, [graph2DData]);
|
||||
|
||||
// Handle node click in graph - show in panel
|
||||
const handleGraphNodeClick = useCallback(
|
||||
(node: GraphNode) => {
|
||||
const nodeData = data?.table_rows?.find((row: any) => row.id === node.id);
|
||||
if (nodeData) {
|
||||
setSelectedGraphNode(nodeData);
|
||||
}
|
||||
},
|
||||
[data]
|
||||
);
|
||||
const handleGraphNodeClick = useCallback((node: GraphNode) => {
|
||||
// Open the memory dialog for the clicked node (same dialog the table/timeline use).
|
||||
setModalMemoryId(node.id);
|
||||
}, []);
|
||||
|
||||
// Memoized color functions to prevent graph re-initialization
|
||||
// Uses brand colors: primary blue (#0074d9), teal (#009296), amber for entity, purple for causal
|
||||
@@ -509,19 +458,6 @@ export function DataView({
|
||||
return () => clearInterval(id);
|
||||
}, [isConsolidating, currentBank]);
|
||||
|
||||
// Enforce 50 node limit to prevent UI instability, default to 20 or max whichever is smaller
|
||||
useEffect(() => {
|
||||
if (data && maxNodes === undefined) {
|
||||
if (graph2DData.nodes.length > 50) {
|
||||
// Always set maxNodes to 20 when we have >50 nodes (never leave as undefined)
|
||||
setMaxNodes(20);
|
||||
} else if (graph2DData.nodes.length > 20) {
|
||||
setMaxNodes(20);
|
||||
}
|
||||
// If ≤20 nodes, leave maxNodes undefined to show all
|
||||
}
|
||||
}, [data, graph2DData.nodes.length, maxNodes]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{loading && !data ? (
|
||||
@@ -529,7 +465,7 @@ export function DataView({
|
||||
<RefreshCw className="w-8 h-8 mx-auto mb-3 text-muted-foreground animate-spin" />
|
||||
<p className="text-muted-foreground">{t("loadingMemories")}</p>
|
||||
</div>
|
||||
) : data && data.total_units === 0 ? (
|
||||
) : data && data.total_units === 0 && !hasActiveMemoryFilters ? (
|
||||
<div className="text-center py-20">
|
||||
<FileText className="w-10 h-10 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-base font-medium text-foreground mb-1">{t("noMemoriesYet")}</h3>
|
||||
@@ -644,7 +580,7 @@ export function DataView({
|
||||
</Button>
|
||||
)}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{searchQuery || tagFilters.length > 0 ? (
|
||||
{hasActiveMemoryFilters ? (
|
||||
t("matchingMemories", { count: filteredTableRows.length })
|
||||
) : data.table_rows?.length < data.total_units ? (
|
||||
<span>
|
||||
@@ -656,11 +592,8 @@ export function DataView({
|
||||
onClick={() => {
|
||||
const newLimit = Math.min(data.total_units, fetchLimit + 1000);
|
||||
setFetchLimit(newLimit);
|
||||
loadData(
|
||||
newLimit,
|
||||
searchQuery || undefined,
|
||||
tagFilters.length > 0 ? tagFilters : undefined
|
||||
);
|
||||
const { tags, match } = resolveTagQuery();
|
||||
loadData(newLimit, searchQuery || undefined, tags, match);
|
||||
}}
|
||||
className="ml-2 text-primary hover:underline"
|
||||
>
|
||||
@@ -705,11 +638,10 @@ export function DataView({
|
||||
{t("pendingCount", { count: consolidationStatus.pending_consolidation })}
|
||||
<button
|
||||
onClick={() =>
|
||||
loadData(
|
||||
fetchLimit,
|
||||
searchQuery || undefined,
|
||||
tagFilters.length > 0 ? tagFilters : undefined
|
||||
)
|
||||
(() => {
|
||||
const { tags, match } = resolveTagQuery();
|
||||
loadData(fetchLimit, searchQuery || undefined, tags, match);
|
||||
})()
|
||||
}
|
||||
disabled={loading}
|
||||
className="ml-0.5 opacity-70 hover:opacity-100 disabled:opacity-40 transition-opacity"
|
||||
@@ -734,17 +666,6 @@ export function DataView({
|
||||
<ScatterChart className="w-4 h-4" />
|
||||
{t("constellation")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("graph")}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
|
||||
viewMode === "graph"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Network className="w-4 h-4" />
|
||||
{t("graph")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("table")}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
|
||||
@@ -771,244 +692,76 @@ export function DataView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!compactMode && viewMode === "graph" && (
|
||||
<div className="flex gap-0">
|
||||
{/* Graph */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Graph2D
|
||||
data={graph2DData}
|
||||
height={700}
|
||||
showLabels={showLabels}
|
||||
onNodeClick={handleGraphNodeClick}
|
||||
maxNodes={maxNodes}
|
||||
nodeColorFn={nodeColorFn}
|
||||
linkColorFn={linkColorFn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Toggle Button */}
|
||||
<button
|
||||
onClick={() => setShowControlPanel(!showControlPanel)}
|
||||
className="flex-shrink-0 w-5 h-[700px] bg-transparent hover:bg-muted/50 flex items-center justify-center transition-colors"
|
||||
title={showControlPanel ? t("hidePanel") : t("showPanel")}
|
||||
>
|
||||
{showControlPanel ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground/60" />
|
||||
) : (
|
||||
<ChevronLeft className="w-3 h-3 text-muted-foreground/60" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Right Panel - Legend/Controls OR Memory Details */}
|
||||
<div
|
||||
className={`${showControlPanel ? "w-80" : "w-0"} transition-all duration-300 overflow-hidden flex-shrink-0`}
|
||||
>
|
||||
<div className="w-80 h-[700px] bg-card border-l border-border overflow-y-auto">
|
||||
{selectedGraphNode ? (
|
||||
/* Memory Detail View */
|
||||
<MemoryDetailPanel
|
||||
memory={selectedGraphNode}
|
||||
onClose={() => setSelectedGraphNode(null)}
|
||||
inPanel
|
||||
bankId={currentBank || undefined}
|
||||
/>
|
||||
) : (
|
||||
/* Legend & Controls View */
|
||||
<div className="p-4 space-y-5">
|
||||
{/* Legend & Stats */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 text-foreground">
|
||||
{t("graphTitle")}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{/* Nodes */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: "#0074d9" }}
|
||||
/>
|
||||
<span className="text-foreground">{t("nodes")}</span>
|
||||
</div>
|
||||
<span className="font-mono text-foreground">
|
||||
{Math.min(
|
||||
maxNodes ?? graph2DData.nodes.length,
|
||||
graph2DData.nodes.length
|
||||
)}
|
||||
/{graph2DData.nodes.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs font-medium text-muted-foreground mt-2 mb-1">
|
||||
{t("linksWithCount", { count: linkStats.total })}{" "}
|
||||
<span className="text-muted-foreground/60">{t("clickToFilter")}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleLinkType("semantic")}
|
||||
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
|
||||
visibleLinkTypes.has("semantic")
|
||||
? "hover:bg-muted"
|
||||
: "opacity-40 hover:opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-[#0074d9]" />
|
||||
<span className="text-foreground">{t("semantic")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`font-mono ${linkStats.semantic === 0 ? "text-destructive" : "text-foreground"}`}
|
||||
>
|
||||
{linkStats.semantic}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleLinkType("temporal")}
|
||||
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
|
||||
visibleLinkTypes.has("temporal")
|
||||
? "hover:bg-muted"
|
||||
: "opacity-40 hover:opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-[#009296]" />
|
||||
<span className="text-foreground">{t("temporal")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`font-mono ${linkStats.temporal === 0 ? "text-destructive" : "text-foreground"}`}
|
||||
>
|
||||
{linkStats.temporal}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleLinkType("entity")}
|
||||
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
|
||||
visibleLinkTypes.has("entity")
|
||||
? "hover:bg-muted"
|
||||
: "opacity-40 hover:opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-[#f59e0b]" />
|
||||
<span className="text-foreground">{t("entity")}</span>
|
||||
</div>
|
||||
<span className="font-mono text-foreground">{linkStats.entity}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleLinkType("causal")}
|
||||
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
|
||||
visibleLinkTypes.has("causal")
|
||||
? "hover:bg-muted"
|
||||
: "opacity-40 hover:opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-[#8b5cf6]" />
|
||||
<span className="text-foreground">{t("causal")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`font-mono ${linkStats.causal === 0 ? "text-muted-foreground" : "text-foreground"}`}
|
||||
>
|
||||
{linkStats.causal}
|
||||
</span>
|
||||
</button>
|
||||
{Object.entries(linkStats.otherTypes || {}).map(([type, count]) => (
|
||||
<div key={type} className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground capitalize ml-6">{type}</span>
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{count as number}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Controls Section */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 text-foreground">
|
||||
{t("displayTitle")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="show-labels" className="text-sm text-foreground">
|
||||
{t("showLabels")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="show-labels"
|
||||
checked={showLabels}
|
||||
onCheckedChange={setShowLabels}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Limits Section */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 text-foreground">
|
||||
{t("performanceTitle")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label className="text-sm text-foreground">{t("maxNodes")}</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{graph2DData.nodes.length > 50
|
||||
? `${maxNodes ?? 50} / ${graph2DData.nodes.length}`
|
||||
: `${maxNodes ?? "All"} / ${graph2DData.nodes.length}`}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[
|
||||
graph2DData.nodes.length > 50
|
||||
? maxNodes || 20
|
||||
: maxNodes || Math.min(graph2DData.nodes.length, 20),
|
||||
]}
|
||||
min={10}
|
||||
max={Math.min(Math.max(graph2DData.nodes.length, 10), 50)}
|
||||
step={10}
|
||||
onValueChange={([v]) => {
|
||||
const effectiveMax = Math.min(graph2DData.nodes.length, 50);
|
||||
// If we have >50 nodes, never allow "All" (undefined), cap at 50
|
||||
if (graph2DData.nodes.length > 50) {
|
||||
setMaxNodes(v);
|
||||
} else {
|
||||
// Original behavior for ≤50 nodes: allow "All" when slider reaches max
|
||||
setMaxNodes(v >= effectiveMax ? undefined : v);
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("allLinksVisible")}
|
||||
{graph2DData.nodes.length > 50 && (
|
||||
<span className="block text-amber-600 dark:text-amber-400 mt-1">
|
||||
{t("limitedTo50Nodes", { count: graph2DData.nodes.length })}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Hint */}
|
||||
<div className="text-xs text-muted-foreground/60 text-center pt-2">
|
||||
{t("clickNodeForDetails")}
|
||||
</div>
|
||||
{(compactMode || viewMode === "constellation") && (
|
||||
<div className="space-y-3">
|
||||
{/* Constellation controls — moved out of the old side panel to sit
|
||||
inline above the graph, next to the view toggle / filters. */}
|
||||
{!compactMode && (
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
{factType === "observation" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("groupByScope")}
|
||||
</span>
|
||||
<Switch checked={groupByScope} onCheckedChange={setGroupByScope} />
|
||||
</div>
|
||||
)}
|
||||
{!(factType === "observation" && groupByScope) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("colorBy")}
|
||||
</span>
|
||||
<Select
|
||||
value={recencyBasis}
|
||||
onValueChange={(v) => setRecencyBasis(v as RecencyBasis)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-44 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mentioned_at">{t("mentioned")}</SelectItem>
|
||||
<SelectItem value="occurred_start">{t("occurredStart")}</SelectItem>
|
||||
<SelectItem value="occurred_end">{t("occurredEnd")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("linkTypes")}
|
||||
</span>
|
||||
{Object.entries({
|
||||
semantic: "#0074d9",
|
||||
temporal: "#009296",
|
||||
entity: "#f59e0b",
|
||||
causal: "#8b5cf6",
|
||||
}).map(([type, color]) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={() => toggleLinkType(type)}
|
||||
>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
opacity: visibleLinkTypes.has(type) ? 1 : 0.2,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs capitalize ${visibleLinkTypes.has(type) ? "text-foreground" : "text-muted-foreground line-through"}`}
|
||||
>
|
||||
{type}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{(compactMode || viewMode === "constellation") && (
|
||||
<div className="flex gap-0">
|
||||
<div className="flex-1 min-w-0 border border-border rounded-lg overflow-hidden">
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Constellation
|
||||
key={compactMode ? "compact" : "full"}
|
||||
data={graph2DData}
|
||||
@@ -1049,119 +802,6 @@ export function DataView({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Toggle Button + Panel (hidden in compact mode) */}
|
||||
{!compactMode && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowControlPanel(!showControlPanel)}
|
||||
className="flex-shrink-0 w-5 h-[700px] bg-transparent hover:bg-muted/50 flex items-center justify-center transition-colors"
|
||||
title={showControlPanel ? t("hidePanel") : t("showPanel")}
|
||||
>
|
||||
{showControlPanel ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronLeft className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Right Panel — reuse the same panel as graph view */}
|
||||
{showControlPanel && (
|
||||
<div className="w-72 flex-shrink-0 border border-border rounded-lg bg-muted/20 overflow-y-auto h-[700px]">
|
||||
{selectedGraphNode ? (
|
||||
<MemoryDetailPanel
|
||||
memory={selectedGraphNode}
|
||||
onClose={() => setSelectedGraphNode(null)}
|
||||
inPanel
|
||||
bankId={currentBank || undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{t("constellationViewTitle")}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("constellationViewDescription")}
|
||||
</p>
|
||||
{factType === "observation" && (
|
||||
<div className="flex items-center justify-between gap-2 pt-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Layers className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<h4 className="text-xs font-medium text-muted-foreground">
|
||||
{t("groupByScope")}
|
||||
</h4>
|
||||
</div>
|
||||
<Switch checked={groupByScope} onCheckedChange={setGroupByScope} />
|
||||
</div>
|
||||
)}
|
||||
{!(factType === "observation" && groupByScope) && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">
|
||||
{t("colorBy")}
|
||||
</h4>
|
||||
<Select
|
||||
value={recencyBasis}
|
||||
onValueChange={(v) => setRecencyBasis(v as RecencyBasis)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mentioned_at">{t("mentioned")}</SelectItem>
|
||||
<SelectItem value="occurred_start">
|
||||
{t("occurredStart")}
|
||||
</SelectItem>
|
||||
<SelectItem value="occurred_end">{t("occurredEnd")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2 pt-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">
|
||||
{t("linkTypes")}
|
||||
</h4>
|
||||
{Object.entries({
|
||||
semantic: "#0074d9",
|
||||
temporal: "#009296",
|
||||
entity: "#f59e0b",
|
||||
causal: "#8b5cf6",
|
||||
}).map(([type, color]) => (
|
||||
<div
|
||||
key={type}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => toggleLinkType(type)}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
opacity: visibleLinkTypes.has(type) ? 1 : 0.2,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs capitalize ${visibleLinkTypes.has(type) ? "text-foreground" : "text-muted-foreground line-through"}`}
|
||||
>
|
||||
{type}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1 pt-2">
|
||||
<div>
|
||||
{t("nodes")}:{" "}
|
||||
<span className="text-foreground">{graph2DData.nodes.length}</span>
|
||||
</div>
|
||||
<div>
|
||||
{t("links")}:{" "}
|
||||
<span className="text-foreground">{graph2DData.links.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1396,9 +1036,7 @@ export function DataView({
|
||||
})()
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{data.table_rows?.length > 0
|
||||
? t("noMemoriesMatchFilter")
|
||||
: t("noMemoriesFound")}
|
||||
{hasActiveMemoryFilters ? t("noMemoriesMatchFilter") : t("noMemoriesFound")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Constellation } from "./constellation";
|
||||
import { convertHindsightGraphData, GraphNode } from "./graph-2d";
|
||||
import { convertHindsightGraphData, GraphNode } from "./graph-data";
|
||||
|
||||
type EntityGraphResponse = Awaited<ReturnType<typeof client.getEntityGraph>>;
|
||||
|
||||
|
||||
@@ -1,726 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import cytoscape from "cytoscape";
|
||||
|
||||
import fcose from "cytoscape-fcose";
|
||||
|
||||
// Register the fcose extension
|
||||
cytoscape.use(fcose);
|
||||
|
||||
// Hook to detect dark mode
|
||||
function useIsDarkMode() {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkDark = () => {
|
||||
setIsDark(document.documentElement.classList.contains("dark"));
|
||||
};
|
||||
|
||||
checkDark();
|
||||
|
||||
// Watch for theme changes
|
||||
const observer = new MutationObserver(checkDark);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return isDark;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Types & Interfaces
|
||||
// ============================================================================
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label?: string;
|
||||
color?: string;
|
||||
size?: number;
|
||||
group?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface GraphLink {
|
||||
source: string;
|
||||
target: string;
|
||||
color?: string;
|
||||
width?: number;
|
||||
type?: string;
|
||||
entity?: string;
|
||||
weight?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
links: GraphLink[];
|
||||
}
|
||||
|
||||
export interface Graph2DProps {
|
||||
data: GraphData;
|
||||
height?: number;
|
||||
showLabels?: boolean;
|
||||
onNodeClick?: (node: GraphNode) => void;
|
||||
onNodeHover?: (node: GraphNode | null) => void;
|
||||
nodeColorFn?: (node: GraphNode) => string;
|
||||
nodeSizeFn?: (node: GraphNode) => number;
|
||||
linkColorFn?: (link: GraphLink) => string;
|
||||
linkWidthFn?: (link: GraphLink) => number;
|
||||
maxNodes?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Values
|
||||
// ============================================================================
|
||||
|
||||
// Brand colors
|
||||
const BRAND_PRIMARY = "#0074d9";
|
||||
const LINK_SEMANTIC = "#0074d9"; // Primary blue for semantic
|
||||
|
||||
const DEFAULT_NODE_COLOR = BRAND_PRIMARY;
|
||||
const DEFAULT_LINK_COLOR = LINK_SEMANTIC;
|
||||
const DEFAULT_LINK_WIDTH = 1;
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export function Graph2D({
|
||||
data,
|
||||
height = 600,
|
||||
showLabels = true,
|
||||
onNodeClick,
|
||||
onNodeHover,
|
||||
nodeColorFn,
|
||||
nodeSizeFn,
|
||||
linkColorFn,
|
||||
linkWidthFn,
|
||||
maxNodes,
|
||||
}: Graph2DProps) {
|
||||
const t = useTranslations("graph2d");
|
||||
const [containerDiv, setContainerDiv] = useState<HTMLDivElement | null>(null);
|
||||
const cyRef = useRef<any>(null);
|
||||
const isInitializingRef = useRef(false);
|
||||
const lastDataSignatureRef = useRef<string>("");
|
||||
const [_hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
|
||||
const [hoveredLink, setHoveredLink] = useState<GraphLink | null>(null);
|
||||
const [linkTooltipPos, setLinkTooltipPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isFocusMode, setIsFocusMode] = useState(false);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// Use refs to store callbacks and data to prevent re-renders from resetting the graph
|
||||
const onNodeClickRef = useRef(onNodeClick);
|
||||
const onNodeHoverRef = useRef(onNodeHover);
|
||||
const fullDataRef = useRef(data);
|
||||
const nodeColorFnRef = useRef(nodeColorFn);
|
||||
const linkColorFnRef = useRef(linkColorFn);
|
||||
const isFocusModeRef = useRef(isFocusMode);
|
||||
onNodeClickRef.current = onNodeClick;
|
||||
onNodeHoverRef.current = onNodeHover;
|
||||
fullDataRef.current = data;
|
||||
nodeColorFnRef.current = nodeColorFn;
|
||||
linkColorFnRef.current = linkColorFn;
|
||||
isFocusModeRef.current = isFocusMode;
|
||||
|
||||
// Transform and limit data - only limit nodes, show ALL links between visible nodes
|
||||
const graphData = useMemo(() => {
|
||||
let nodes = [...data.nodes];
|
||||
|
||||
// Limit nodes if needed
|
||||
if (maxNodes && nodes.length > maxNodes) {
|
||||
nodes = nodes.slice(0, maxNodes);
|
||||
}
|
||||
|
||||
// Show ALL links between visible nodes (no random link limiting)
|
||||
const nodeIds = new Set(nodes.map((n) => n.id));
|
||||
const links = data.links.filter((l) => nodeIds.has(l.source) && nodeIds.has(l.target));
|
||||
|
||||
return { nodes, links };
|
||||
}, [data, maxNodes]);
|
||||
|
||||
// Track mounting state
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
return () => setIsMounted(false);
|
||||
}, []);
|
||||
|
||||
// Convert to Cytoscape format
|
||||
const cyElements = useMemo(() => {
|
||||
// Calculate node importance based on connections
|
||||
const nodeConnections = new Map<string, number>();
|
||||
graphData.links.forEach((link) => {
|
||||
nodeConnections.set(link.source, (nodeConnections.get(link.source) || 0) + 1);
|
||||
nodeConnections.set(link.target, (nodeConnections.get(link.target) || 0) + 1);
|
||||
});
|
||||
|
||||
const nodes = graphData.nodes.map((node) => {
|
||||
const connections = nodeConnections.get(node.id) || 0;
|
||||
const dynamicSize = nodeSizeFn
|
||||
? nodeSizeFn(node)
|
||||
: Math.max(16, Math.min(40, 16 + connections * 4)); // Smaller, more subtle sizing
|
||||
|
||||
return {
|
||||
data: {
|
||||
id: node.id,
|
||||
label: node.label || node.id.substring(0, 8),
|
||||
color: nodeColorFn ? nodeColorFn(node) : node.color || DEFAULT_NODE_COLOR,
|
||||
size: node.size || dynamicSize,
|
||||
originalNode: node,
|
||||
connections: connections,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const edges = graphData.links.map((link, idx) => ({
|
||||
data: {
|
||||
id: `edge-${idx}`,
|
||||
source: link.source,
|
||||
target: link.target,
|
||||
color: linkColorFn ? linkColorFn(link) : link.color || DEFAULT_LINK_COLOR,
|
||||
width: linkWidthFn ? linkWidthFn(link) : link.width || DEFAULT_LINK_WIDTH,
|
||||
type: link.type,
|
||||
entity: link.entity,
|
||||
weight: link.weight,
|
||||
originalLink: link,
|
||||
},
|
||||
}));
|
||||
|
||||
return [...nodes, ...edges];
|
||||
}, [graphData, nodeColorFn, nodeSizeFn, linkColorFn, linkWidthFn]);
|
||||
|
||||
// Create data signature to prevent double initialization
|
||||
const dataSignature = useMemo(() => {
|
||||
return JSON.stringify({
|
||||
nodeCount: graphData.nodes.length,
|
||||
linkCount: graphData.links.length,
|
||||
nodeIds: graphData.nodes
|
||||
.map((n) => n.id)
|
||||
.sort()
|
||||
.join(","),
|
||||
showLabels,
|
||||
isDarkMode,
|
||||
maxNodes,
|
||||
});
|
||||
}, [graphData.nodes, graphData.links, showLabels, isDarkMode, maxNodes]);
|
||||
|
||||
// Initialize Cytoscape
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
// Small delay to ensure container is mounted
|
||||
const timeout = setTimeout(() => {
|
||||
if (isCancelled || !isMounted || !containerDiv || isInitializingRef.current) return;
|
||||
|
||||
// Check if data has actually changed to prevent double initialization
|
||||
if (lastDataSignatureRef.current === dataSignature) {
|
||||
console.log("Data signature unchanged, skipping graph initialization");
|
||||
return;
|
||||
}
|
||||
|
||||
// Additional validation - check if element has dimensions
|
||||
const rect = containerDiv.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) {
|
||||
console.warn("Container has no dimensions, skipping cytoscape initialization");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle empty data case
|
||||
if (cyElements.length === 0) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we already have a graph with the same data
|
||||
if (cyRef.current && !cyRef.current.destroyed()) {
|
||||
const currentNodes = cyRef.current.nodes().length;
|
||||
const currentEdges = cyRef.current.edges().length;
|
||||
const newNodes = cyElements.filter((el) => !(el.data as any).source).length;
|
||||
const newEdges = cyElements.filter((el) => (el.data as any).source).length;
|
||||
|
||||
// If the element counts are the same, just update styles and skip reinitialization
|
||||
if (currentNodes === newNodes && currentEdges === newEdges) {
|
||||
console.log("Graph already initialized with same data, skipping reinitialization");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up existing graph before creating new one
|
||||
console.log("Data changed, destroying existing graph");
|
||||
cyRef.current.destroy();
|
||||
cyRef.current = null;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
isInitializingRef.current = true;
|
||||
|
||||
// Theme-aware colors
|
||||
const textColor = isDarkMode ? "#ffffff" : "#1f2937";
|
||||
const textBgColor = isDarkMode ? "rgba(0,0,0,0.8)" : "rgba(255,255,255,0.9)";
|
||||
|
||||
try {
|
||||
console.log("Initializing cytoscape with container:", containerDiv);
|
||||
console.log("Elements count:", cyElements.length);
|
||||
console.log("Sample elements:", cyElements.slice(0, 2));
|
||||
|
||||
// Try minimal initialization first
|
||||
const cy = cytoscape({
|
||||
container: containerDiv,
|
||||
elements: [],
|
||||
// Disable edge selection to prevent gray border on click
|
||||
selectionType: "single",
|
||||
userZoomingEnabled: true,
|
||||
userPanningEnabled: true,
|
||||
boxSelectionEnabled: false,
|
||||
// Disable automatic layout on initialization
|
||||
layout: { name: "preset" },
|
||||
style: [
|
||||
{
|
||||
selector: "node",
|
||||
style: {
|
||||
"background-color": "data(color)",
|
||||
width: "data(size)",
|
||||
height: "data(size)",
|
||||
label: showLabels ? "data(label)" : "",
|
||||
color: textColor,
|
||||
"text-valign": "bottom",
|
||||
"text-halign": "center",
|
||||
"font-size": "8px",
|
||||
"font-weight": 500,
|
||||
"text-margin-y": 3,
|
||||
"text-wrap": "wrap",
|
||||
"text-max-width": "80px",
|
||||
"text-background-color": textBgColor,
|
||||
"text-background-opacity": 0.9,
|
||||
"text-background-padding": "2px",
|
||||
"text-background-shape": "roundrectangle",
|
||||
"border-width": 1,
|
||||
"border-color": isDarkMode ? "#ffffff20" : "#00000020",
|
||||
"border-opacity": 0.3,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "node:selected",
|
||||
style: {
|
||||
"border-width": 3,
|
||||
"border-color": "#0074d9",
|
||||
"border-opacity": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
width: "data(width)",
|
||||
"line-color": "data(color)",
|
||||
"target-arrow-color": "data(color)",
|
||||
"target-arrow-shape": "triangle",
|
||||
"target-arrow-size": 6,
|
||||
"curve-style": "bezier",
|
||||
opacity: isDarkMode ? 0.6 : 0.7,
|
||||
},
|
||||
},
|
||||
// Focus mode styles
|
||||
{
|
||||
selector: ".dimmed",
|
||||
style: {
|
||||
opacity: 0.2,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: ".focused",
|
||||
style: {
|
||||
"border-width": 4,
|
||||
"border-color": "#ff6b35",
|
||||
"border-opacity": 1,
|
||||
"z-index": 999,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: ".connected",
|
||||
style: {
|
||||
"border-width": 2,
|
||||
"border-color": "#0074d9",
|
||||
"border-opacity": 0.8,
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "edge.connection",
|
||||
style: {
|
||||
width: 2,
|
||||
opacity: 1,
|
||||
"z-index": 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "edge.connection:hover",
|
||||
style: {
|
||||
width: 3,
|
||||
opacity: 1,
|
||||
"z-index": 200,
|
||||
},
|
||||
},
|
||||
// Disable edge selection styling
|
||||
{
|
||||
selector: "edge:selected",
|
||||
style: {
|
||||
"overlay-opacity": 0,
|
||||
"overlay-color": "transparent",
|
||||
"overlay-padding": 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
cyRef.current = cy;
|
||||
|
||||
console.log("Cytoscape initialized successfully");
|
||||
|
||||
// Add elements after initialization
|
||||
if (cyElements.length > 0) {
|
||||
console.log("Adding elements to cytoscape");
|
||||
cy.add(cyElements);
|
||||
cy.layout({
|
||||
name: "fcose",
|
||||
quality: "default",
|
||||
randomize: false,
|
||||
animate: true,
|
||||
animationDuration: 1500,
|
||||
// Separation settings - increase to spread nodes more
|
||||
nodeSeparation: 200,
|
||||
idealEdgeLength: () => 250,
|
||||
edgeElasticity: () => 0.05,
|
||||
nestingFactor: 0.05,
|
||||
gravity: 0.05, // Reduced gravity spreads nodes more
|
||||
numIter: 2500,
|
||||
// Overlap prevention
|
||||
nodeOverlap: 30,
|
||||
avoidOverlap: true,
|
||||
nodeDimensionsIncludeLabels: true,
|
||||
// Layout bounds - reduce padding to use more space
|
||||
padding: 20,
|
||||
boundingBox: undefined,
|
||||
// Tiling - increase spacing between disconnected components
|
||||
tile: true,
|
||||
tilingPaddingVertical: 30,
|
||||
tilingPaddingHorizontal: 30,
|
||||
// Force more spread
|
||||
uniformNodeDimensions: false,
|
||||
packComponents: false, // Don't pack components tightly
|
||||
}).run();
|
||||
|
||||
// Fit to viewport
|
||||
cy.fit();
|
||||
}
|
||||
|
||||
// Add basic interactions
|
||||
cy.on("tap", "node", (evt: any) => {
|
||||
const node = evt.target as cytoscape.NodeSingular;
|
||||
const originalNode = node.data("originalNode") as GraphNode;
|
||||
if (onNodeClickRef.current && originalNode) {
|
||||
onNodeClickRef.current(originalNode);
|
||||
}
|
||||
});
|
||||
|
||||
cy.on("mouseover", "node", (evt: any) => {
|
||||
const node = evt.target as cytoscape.NodeSingular;
|
||||
const originalNode = node.data("originalNode") as GraphNode;
|
||||
setHoveredNode(originalNode);
|
||||
if (onNodeHoverRef.current && originalNode) {
|
||||
onNodeHoverRef.current(originalNode);
|
||||
}
|
||||
if (containerDiv) containerDiv.style.cursor = "pointer";
|
||||
});
|
||||
|
||||
cy.on("mouseout", "node", () => {
|
||||
setHoveredNode(null);
|
||||
if (onNodeHoverRef.current) {
|
||||
onNodeHoverRef.current(null);
|
||||
}
|
||||
if (containerDiv) containerDiv.style.cursor = "default";
|
||||
});
|
||||
|
||||
// Edge hover handlers - only work in focus mode and on highlighted edges
|
||||
cy.on("mouseover", "edge", (evt: any) => {
|
||||
const edge = evt.target;
|
||||
|
||||
// Only allow interaction if we're in focus mode and edge is highlighted
|
||||
if (!isFocusModeRef.current || !edge.hasClass("connection")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalLink = edge.data("originalLink") as GraphLink;
|
||||
if (originalLink) {
|
||||
setHoveredLink(originalLink);
|
||||
// Get position for tooltip
|
||||
const renderedPos = edge.renderedMidpoint();
|
||||
setLinkTooltipPos({ x: renderedPos.x, y: renderedPos.y });
|
||||
}
|
||||
});
|
||||
|
||||
cy.on("mouseout", "edge", (evt: any) => {
|
||||
const edge = evt.target;
|
||||
|
||||
// Only clear hover state if we were actually hovering a highlighted edge
|
||||
if (!isFocusModeRef.current || !edge.hasClass("connection")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHoveredLink(null);
|
||||
setLinkTooltipPos(null);
|
||||
});
|
||||
|
||||
// Prevent edge selection to avoid gray border on click
|
||||
cy.on("select", "edge", (evt: any) => {
|
||||
evt.target.unselect();
|
||||
});
|
||||
|
||||
// Double-click to focus on node and its connections
|
||||
cy.on("dblclick", "node", (evt: any) => {
|
||||
const focusedNode = evt.target as cytoscape.NodeSingular;
|
||||
const focusedNodeId = focusedNode.id();
|
||||
|
||||
console.log("Double-clicked node:", focusedNodeId);
|
||||
|
||||
// Enter focus mode
|
||||
setIsFocusMode(true);
|
||||
|
||||
// Clear any existing focus classes
|
||||
cy.elements().removeClass("dimmed focused connected connection");
|
||||
|
||||
// Get all connected nodes and edges
|
||||
const connectedElements = focusedNode.neighborhood();
|
||||
const connectedNodes = connectedElements.nodes();
|
||||
const connectedEdges = connectedElements.edges();
|
||||
|
||||
// Apply styling classes
|
||||
cy.elements().addClass("dimmed"); // Dim everything first
|
||||
focusedNode.removeClass("dimmed").addClass("focused"); // Highlight the focused node
|
||||
connectedNodes.removeClass("dimmed").addClass("connected"); // Highlight connected nodes
|
||||
connectedEdges.removeClass("dimmed").addClass("connection"); // Highlight connecting edges
|
||||
|
||||
// Create a collection of all relevant elements for positioning
|
||||
const relevantElements = focusedNode.union(connectedElements);
|
||||
|
||||
// Reorient the graph to focus on this subgraph
|
||||
cy.animate(
|
||||
{
|
||||
fit: {
|
||||
eles: relevantElements,
|
||||
padding: 100,
|
||||
},
|
||||
center: {
|
||||
eles: focusedNode,
|
||||
},
|
||||
},
|
||||
{
|
||||
duration: 800,
|
||||
easing: "ease-out-cubic",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Click on background to reset focus
|
||||
cy.on("tap", (evt: any) => {
|
||||
if (evt.target === cy) {
|
||||
console.log("Clicked background - resetting focus");
|
||||
|
||||
// Exit focus mode
|
||||
setIsFocusMode(false);
|
||||
|
||||
// Remove all focus classes
|
||||
cy.elements().removeClass("dimmed focused connected connection");
|
||||
|
||||
// Zoom out to show all elements
|
||||
cy.animate(
|
||||
{
|
||||
fit: {
|
||||
eles: cy.elements(),
|
||||
padding: 50,
|
||||
},
|
||||
},
|
||||
{
|
||||
duration: 600,
|
||||
easing: "ease-out",
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
isInitializingRef.current = false;
|
||||
lastDataSignatureRef.current = dataSignature;
|
||||
} catch (error) {
|
||||
console.error("Error initializing cytoscape:", error);
|
||||
setIsLoading(false);
|
||||
isInitializingRef.current = false;
|
||||
}
|
||||
}, 100); // 100ms delay
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
clearTimeout(timeout);
|
||||
isInitializingRef.current = false;
|
||||
if (cyRef.current) {
|
||||
cyRef.current.destroy();
|
||||
cyRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [dataSignature, isMounted, containerDiv]);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (cyRef.current) {
|
||||
cyRef.current.resize();
|
||||
cyRef.current.fit(undefined, 80);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full rounded-lg overflow-hidden border border-border"
|
||||
style={{ height }}
|
||||
>
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background z-10">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
|
||||
<p className="text-sm text-muted-foreground">{t("loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cytoscape container */}
|
||||
{isMounted && (
|
||||
<div
|
||||
ref={setContainerDiv}
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: isDarkMode
|
||||
? "radial-gradient(circle at 1px 1px, rgba(255,255,255,0.08) 1px, transparent 0)"
|
||||
: "radial-gradient(circle at 1px 1px, rgba(0,0,0,0.06) 1px, transparent 0)",
|
||||
backgroundSize: "20px 20px",
|
||||
backgroundColor: isDarkMode ? "#0f1419" : "#f8fafc",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && graphData.nodes.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground">{t("emptyState")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link hover tooltip */}
|
||||
{hoveredLink && linkTooltipPos && (
|
||||
<div
|
||||
className="absolute z-30 pointer-events-none"
|
||||
style={{
|
||||
left: linkTooltipPos.x,
|
||||
top: linkTooltipPos.y,
|
||||
transform: "translate(-50%, -100%) translateY(-8px)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`px-3 py-2 rounded-lg shadow-lg text-sm ${
|
||||
isDarkMode
|
||||
? "bg-gray-800 text-white"
|
||||
: "bg-white text-gray-900 border border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium capitalize mb-1">
|
||||
{(() => {
|
||||
const type = hoveredLink.type || "semantic";
|
||||
if (["causes", "caused_by", "enables", "prevents"].includes(type)) {
|
||||
return t("linkTypeCausal", { type: type.replace("_", " ") });
|
||||
}
|
||||
return t("linkTypeGeneric", { type });
|
||||
})()}
|
||||
</div>
|
||||
{hoveredLink.entity && (
|
||||
<div className="text-xs opacity-80">
|
||||
{t("linkTooltipEntity")} <span className="font-medium">{hoveredLink.entity}</span>
|
||||
</div>
|
||||
)}
|
||||
{hoveredLink.weight !== undefined && (
|
||||
<div className="text-xs opacity-80">
|
||||
{t("linkTooltipWeight")}{" "}
|
||||
<span className="font-medium">{hoveredLink.weight.toFixed(3)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls hint */}
|
||||
<div className="absolute bottom-4 right-4 text-xs text-muted-foreground/60 z-20">
|
||||
{t("controlsHint")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
export function convertHindsightGraphData(hindsightData: {
|
||||
nodes?: Array<{ data: { id: string; label?: string; color?: string } }>;
|
||||
edges?: Array<{
|
||||
data: {
|
||||
source: string;
|
||||
target: string;
|
||||
color?: string;
|
||||
lineStyle?: string;
|
||||
linkType?: string;
|
||||
entityName?: string;
|
||||
weight?: number;
|
||||
similarity?: number;
|
||||
};
|
||||
}>;
|
||||
table_rows?: Array<{ id: string; text: string; entities?: string; context?: string }>;
|
||||
}): GraphData {
|
||||
const nodes: GraphNode[] = (hindsightData.nodes || []).map((n) => {
|
||||
const tableRow = hindsightData.table_rows?.find((r) => r.id === n.data.id);
|
||||
// Use memory text as label, truncated to ~40 chars
|
||||
let label = n.data.label;
|
||||
if (!label && tableRow?.text) {
|
||||
label = tableRow.text.length > 40 ? tableRow.text.substring(0, 40) + "..." : tableRow.text;
|
||||
}
|
||||
if (!label) {
|
||||
label = n.data.id.substring(0, 8);
|
||||
}
|
||||
return {
|
||||
id: n.data.id,
|
||||
label,
|
||||
color: n.data.color,
|
||||
metadata: tableRow,
|
||||
};
|
||||
});
|
||||
|
||||
const links: GraphLink[] = (hindsightData.edges || []).map((e) => ({
|
||||
source: e.data.source,
|
||||
target: e.data.target,
|
||||
color: e.data.color,
|
||||
// Use linkType directly from API, fallback to lineStyle check, default to semantic
|
||||
type: e.data.linkType || (e.data.lineStyle === "dashed" ? "temporal" : "semantic"),
|
||||
entity: e.data.entityName, // API returns entityName
|
||||
weight: e.data.weight ?? e.data.similarity,
|
||||
}));
|
||||
|
||||
return { nodes, links };
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Shared graph data model + conversion used by the memory visualizations
|
||||
// (Constellation, entities view). The Cytoscape-based "Graph" view that used to
|
||||
// live here was removed; only the framework-agnostic types and the API-response
|
||||
// converter remain, since the constellation and entity views build on them.
|
||||
|
||||
// ============================================================================
|
||||
// Types & Interfaces
|
||||
// ============================================================================
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label?: string;
|
||||
color?: string;
|
||||
size?: number;
|
||||
group?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface GraphLink {
|
||||
source: string;
|
||||
target: string;
|
||||
color?: string;
|
||||
width?: number;
|
||||
type?: string;
|
||||
entity?: string;
|
||||
weight?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
links: GraphLink[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
export function convertHindsightGraphData(hindsightData: {
|
||||
nodes?: Array<{ data: { id: string; label?: string; color?: string } }>;
|
||||
edges?: Array<{
|
||||
data: {
|
||||
source: string;
|
||||
target: string;
|
||||
color?: string;
|
||||
lineStyle?: string;
|
||||
linkType?: string;
|
||||
entityName?: string;
|
||||
weight?: number;
|
||||
similarity?: number;
|
||||
};
|
||||
}>;
|
||||
table_rows?: Array<{ id: string; text: string; entities?: string; context?: string }>;
|
||||
}): GraphData {
|
||||
const nodes: GraphNode[] = (hindsightData.nodes || []).map((n) => {
|
||||
const tableRow = hindsightData.table_rows?.find((r) => r.id === n.data.id);
|
||||
// Use memory text as label, truncated to ~40 chars
|
||||
let label = n.data.label;
|
||||
if (!label && tableRow?.text) {
|
||||
label = tableRow.text.length > 40 ? tableRow.text.substring(0, 40) + "..." : tableRow.text;
|
||||
}
|
||||
if (!label) {
|
||||
label = n.data.id.substring(0, 8);
|
||||
}
|
||||
return {
|
||||
id: n.data.id,
|
||||
label,
|
||||
color: n.data.color,
|
||||
metadata: tableRow,
|
||||
};
|
||||
});
|
||||
|
||||
const links: GraphLink[] = (hindsightData.edges || []).map((e) => ({
|
||||
source: e.data.source,
|
||||
target: e.data.target,
|
||||
color: e.data.color,
|
||||
// Use linkType directly from API, fallback to lineStyle check, default to semantic
|
||||
type: e.data.linkType || (e.data.lineStyle === "dashed" ? "temporal" : "semantic"),
|
||||
entity: e.data.entityName, // API returns entityName
|
||||
weight: e.data.weight ?? e.data.similarity,
|
||||
}));
|
||||
|
||||
return { nodes, links };
|
||||
}
|
||||
@@ -154,12 +154,23 @@ export function MentalModelsView() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const mentalModelsData = await client.listMentalModels(
|
||||
currentBank,
|
||||
selectedTags.length > 0 ? selectedTags : undefined,
|
||||
selectedTags.length > 0 ? tagsMatch : undefined
|
||||
);
|
||||
setMentalModels(mentalModelsData.items || []);
|
||||
// The API caps each response at PAGE_SIZE, so page through until a short
|
||||
// page is returned to load every mental model for this bank.
|
||||
const PAGE_SIZE = 100;
|
||||
const all: MentalModel[] = [];
|
||||
for (let offset = 0; ; offset += PAGE_SIZE) {
|
||||
const page = await client.listMentalModels(
|
||||
currentBank,
|
||||
selectedTags.length > 0 ? selectedTags : undefined,
|
||||
selectedTags.length > 0 ? tagsMatch : undefined,
|
||||
PAGE_SIZE,
|
||||
offset
|
||||
);
|
||||
const items = page.items || [];
|
||||
all.push(...items);
|
||||
if (items.length < PAGE_SIZE) break;
|
||||
}
|
||||
setMentalModels(all);
|
||||
} catch (error) {
|
||||
console.error("Error loading mental models:", error);
|
||||
} finally {
|
||||
|
||||
@@ -14,7 +14,19 @@ interface JsonViewerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function parseJsonString(value: string): unknown {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value;
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function toDisplayText(value: unknown): string {
|
||||
value = typeof value === "string" ? parseJsonString(value) : value;
|
||||
if (typeof value === "string") return value;
|
||||
// Unescape newlines inside string values so multi-line content (e.g. prompts)
|
||||
// renders as real line breaks under `whitespace-pre-wrap` instead of literal "\n".
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary/50 border border-border">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
@@ -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.
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
title: "Give Zed's AI Assistant a Persistent Memory"
|
||||
authors: [benfrank241]
|
||||
slug: "2026/07/07/zed-persistent-memory"
|
||||
date: 2026-07-07T12:00
|
||||
tags: [hindsight, zed, integration, memory, persistent-memory, mcp, tutorial]
|
||||
description: "The Zed editor's AI assistant is sharp inside a task and a stranger between them. hindsight-zed gives it long-term memory that recalls and retains across sessions."
|
||||
image: /img/blog/zed-persistent-memory.png
|
||||
hide_table_of_contents: true
|
||||
---
|
||||
|
||||

|
||||
|
||||
[Zed](https://zed.dev) is a fast, Rust-built editor with a genuinely good AI assistant in its Agent Panel. But like most coding agents, it is brilliant inside a single task and a stranger between them. Close the panel, start a new conversation tomorrow, and it has forgotten that this repo uses pnpm, that you settled on a repository pattern last week, and that you like your tests colocated.
|
||||
|
||||
`hindsight-zed` fixes that. One command wires Zed's Agent Panel to a shared [Hindsight](https://github.com/vectorize-io/hindsight) memory so the agent can recall relevant decisions before it answers and retain durable facts as it works, across every session.
|
||||
|
||||
<!-- truncate -->
|
||||
|
||||
## TL;DR
|
||||
|
||||
- `hindsight-zed init` registers the Hindsight **MCP server** in Zed and adds a rule telling the agent to use it.
|
||||
- The agent gets three tools: `recall`, `retain`, and `reflect`.
|
||||
- Recall runs at **query time** against your actual message, so it pulls context relevant to what you just asked, with no lag.
|
||||
- Works with [Hindsight Cloud](https://hindsight.vectorize.io) or a self-hosted server. It is [agent memory](https://vectorize.io/what-is-agent-memory) that outlives the conversation.
|
||||
|
||||
## The problem: a great assistant with no yesterday
|
||||
|
||||
A coding assistant that forgets everything between sessions makes you the memory. You re-explain the stack, restate the conventions, and re-litigate decisions you already made, every time you open a fresh conversation. The model is capable; it just has no continuity.
|
||||
|
||||
Persistent memory closes that gap. The point of memory is that the things you have already told your agent, and the things it has already figured out, are still there next time. That is what turns a per-session tool into an assistant that actually knows your project.
|
||||
|
||||
## How Zed's memory works
|
||||
|
||||
Zed does not expose a pre-prompt hook, so there is nowhere to automatically inject context before a turn. What Zed does give you is two building blocks, and the integration uses both.
|
||||
|
||||
**MCP context servers.** Zed runs [Model Context Protocol](https://modelcontextprotocol.io) servers listed under `context_servers` in its `settings.json` and exposes their tools in the Agent Panel. `hindsight-zed` registers the Hindsight MCP server there, which hands the agent `recall`, `retain`, and `reflect` tools.
|
||||
|
||||
**A global instructions file.** Zed includes `~/.config/zed/AGENTS.md` in every agent conversation. The integration writes a short rule into that file, inside a fenced `<!-- HINDSIGHT:BEGIN -->` to `<!-- HINDSIGHT:END -->` block so it never touches your own rules. The rule tells the agent to call `recall` at the start of each task to load relevant decisions, preferences, and project context, and to call `retain` whenever it learns a durable fact worth keeping across sessions.
|
||||
|
||||
The result is recall that happens at query time, against the message you actually sent. Because it runs when you ask rather than on a fixed schedule, it can pull the memories relevant to this specific request. From your seat it is automatic: you type, the agent quietly checks its memory, then answers.
|
||||
|
||||
One transport note. Zed does not yet ship native HTTP MCP transport, so the server connects through the [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) stdio bridge, run via `npx`. Because that bridge runs on Node.js, and the setup CLI is a Node tool too, Node.js is the only requirement. No Python needed.
|
||||
|
||||
## Install and quick start
|
||||
|
||||
```bash
|
||||
npx hindsight-zed init --api-token YOUR_HINDSIGHT_API_KEY --bank-id my-memory
|
||||
```
|
||||
|
||||
`hindsight-zed` is a zero-dependency Node CLI, so `npx` runs it with no global install. Prefer a persistent command? Run `npm install -g hindsight-zed` first.
|
||||
|
||||
`init` adds the `hindsight` MCP server to `~/.config/zed/settings.json` and the recall/retain rule to `~/.config/zed/AGENTS.md`. Restart Zed, open the Agent Panel, and the `hindsight` server should show a green dot.
|
||||
|
||||
That is the whole setup. Under the hood, the settings entry looks like this:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"context_servers": {
|
||||
"hindsight": {
|
||||
"source": "custom",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y", "mcp-remote",
|
||||
"https://api.hindsight.vectorize.io/mcp/my-memory/",
|
||||
"--header", "Authorization: Bearer YOUR_HINDSIGHT_API_KEY"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If your `settings.json` uses comments (JSONC), `init` will not rewrite it. Instead it prints the exact `context_servers` entry for you to paste. You can see that snippet any time with `hindsight-zed init --print-only`.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `hindsight-zed init` | Add the MCP server and the recall/retain rule |
|
||||
| `hindsight-zed status` | Show whether the server and rule are configured |
|
||||
| `hindsight-zed uninstall` | Remove the server and the rule |
|
||||
| `hindsight-zed init --print-only` | Print the config to add manually |
|
||||
|
||||
Prefix any of these with `npx ` if you did not install globally.
|
||||
|
||||
## Cloud or self-hosted
|
||||
|
||||
For **Hindsight Cloud**, pass an API key from your dashboard. The API URL defaults to `https://api.hindsight.vectorize.io`, so there is nothing else to set.
|
||||
|
||||
For a **self-hosted** server, point at your own base URL with `--api-url http://localhost:8888`. An open local server needs no token.
|
||||
|
||||
Configuration resolves from flags, environment, or `~/.hindsight/zed.json` (written by `init`):
|
||||
|
||||
| Setting | Env var | Default |
|
||||
| --- | --- | --- |
|
||||
| API URL | `HINDSIGHT_API_URL` | `https://api.hindsight.vectorize.io` |
|
||||
| API token | `HINDSIGHT_API_TOKEN` | none (required for Cloud) |
|
||||
| Bank id | `HINDSIGHT_ZED_BANK_ID` | `zed` |
|
||||
|
||||
The bank is one isolated store. Point Zed and your other tools at the same bank id and they share one memory, which is the idea behind [one memory for every AI tool](/blog/2026/04/07/one-memory-for-every-ai-tool).
|
||||
|
||||
## Verify it works
|
||||
|
||||
Give the agent a durable fact in one conversation: "This repo uses pnpm, never npm." Start a **new** conversation and ask for something related, like "add the date-fns dependency." A memory-aware agent recalls the convention and reaches for pnpm without being reminded, because the fact was retained to Hindsight and recalled against your new request.
|
||||
|
||||
You can watch this happen from the other side too. Open your Hindsight bank and you will see the retained convention show up as a stored memory after the first conversation.
|
||||
|
||||
## Frequently asked questions
|
||||
|
||||
**Does the agent recall automatically?**
|
||||
Recall is a tool the agent calls, and the global rule tells it to call `recall` at the start of every task. So in practice it runs on its own, and because it runs at query time it uses your actual message to find relevant memory.
|
||||
|
||||
**What do I need installed?**
|
||||
Just Node.js (version 18.3 or newer). `hindsight-zed` is a zero-dependency Node CLI, and Zed's MCP bridge (`mcp-remote`) also runs on Node via `npx`. No Python required.
|
||||
|
||||
**Will it overwrite my Zed config?**
|
||||
No. The rule lives inside a fenced `HINDSIGHT` block in `AGENTS.md`, and `init` leaves the rest untouched. If your `settings.json` has comments, `init` prints the snippet instead of rewriting the file.
|
||||
|
||||
**What does `reflect` do?**
|
||||
Alongside `recall` and `retain`, the agent can call `reflect` to consolidate and reason over what it has stored, so memory improves as it accumulates rather than becoming a flat pile of notes.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [What is agent memory?](https://vectorize.io/what-is-agent-memory): the concepts behind recall and retention.
|
||||
- [Best AI agent memory systems](https://vectorize.io/articles/best-ai-agent-memory-systems): how the major memory frameworks compare.
|
||||
- [Cursor persistent memory](/blog/2026/06/12/cursor-persistent-memory): the same idea for the other AI-first editor.
|
||||
- [One memory for every AI tool](/blog/2026/04/07/one-memory-for-every-ai-tool): point Zed and your other agents at the same bank.
|
||||
@@ -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)
|
||||
|
||||
@@ -19,8 +19,16 @@ Zed doesn't yet have native HTTP-MCP transport, so the server is connected throu
|
||||
|
||||
## Setup
|
||||
|
||||
`hindsight-zed` is a zero-dependency Node CLI — Node.js is the only requirement (already needed for the `mcp-remote` bridge). Run it straight from npm with `npx`:
|
||||
|
||||
```bash
|
||||
pip install hindsight-zed
|
||||
npx @vectorize-io/hindsight-zed init --api-token YOUR_HINDSIGHT_API_KEY --bank-id my-memory
|
||||
```
|
||||
|
||||
Or install it globally for a persistent command:
|
||||
|
||||
```bash
|
||||
npm install -g @vectorize-io/hindsight-zed
|
||||
hindsight-zed init --api-token YOUR_HINDSIGHT_API_KEY --bank-id my-memory
|
||||
```
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import memoriesGo from '!!raw-loader!@site/examples/api/memories.go';
|
||||
|
||||
## List memory units
|
||||
|
||||
List the memory units in a bank. The response includes each unit's `fact_type` (`world` | `experience` | `observation`), `state` (`valid` | `invalidated`), entities, occurred dates, and — for facts a user has edited — an `edited_at` timestamp. Invalidated rows are **included by default** so curation stays auditable; filter with `state=`.
|
||||
List the memory units in a bank. The response includes each unit's `fact_type` (`world` | `experience` | `observation`), `state` (`valid` | `invalidated`), metadata, entities, occurred dates, and — for facts a user has edited — an `edited_at` timestamp. Invalidated rows are **included by default** so curation stays auditable; filter with `state=`.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="python" label="Python">
|
||||
@@ -47,6 +47,8 @@ List the memory units in a bank. The response includes each unit's `fact_type` (
|
||||
|
||||
## Fetch a single memory unit
|
||||
|
||||
Fetch a memory unit by ID, including its content, metadata, entities, timestamps, tags, and curation state.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={memoriesPy} section="get-memory" language="python" />
|
||||
|
||||
@@ -29,6 +29,8 @@ The API service handles all memory operations (retain, recall, reflect).
|
||||
|
||||
If not provided, the server uses embedded `pg0` — convenient for development but not recommended for production.
|
||||
|
||||
To run against Oracle Database 23ai instead, set `HINDSIGHT_API_DATABASE_BACKEND=oracle` and use an `oracle+oracledb://…` URL. See the [Oracle Database guide](./oracle) for full setup instructions.
|
||||
|
||||
The `DATABASE_SCHEMA` setting allows you to use a custom PostgreSQL schema instead of the default `public` schema. This is useful for:
|
||||
- Multi-database setups where you want Hindsight tables in a dedicated schema
|
||||
- Hosting platforms (e.g., Supabase) where `public` schema is reserved or shared
|
||||
@@ -145,6 +147,7 @@ If you need to switch from one extension to another:
|
||||
| `HINDSIGHT_API_TEXT_SEARCH_EXTENSION` | Text search backend: `native`, `vchord`, `pg_textsearch`, `pgroonga`, or `pg_search` | `native` |
|
||||
| `HINDSIGHT_API_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE` | PostgreSQL text search dictionary used by the `native` backend (e.g. `english`, `french`, `simple`, `zhparser`) | `english` |
|
||||
| `HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER` | ParadeDB `pg_search` tokenizer used when creating BM25 indexes. Empty uses ParadeDB's default tokenizer (`unicode_words`). | unset |
|
||||
| `HINDSIGHT_API_BM25_MAX_QUERY_TERMS` | Optional cap on the number of terms in the native PostgreSQL BM25 `tsquery`. Long queries OR-join every normalized token, which can match too much of a large bank. `0` keeps the historical uncapped behavior; a positive value bounds only the `native` backend (other BM25 backends receive the raw query). | `0` |
|
||||
| `HINDSIGHT_API_LLM_OUTPUT_LANGUAGE` | When set, forces every LLM-generated artifact (retain facts, consolidation observations, reflect responses) into this language. Free-form (e.g. `Spanish`, `Japanese`). | unset |
|
||||
|
||||
Hindsight supports five backends for BM25 keyword retrieval:
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
# Oracle Database
|
||||
|
||||
Hindsight uses PostgreSQL as its default storage backend, but it also runs on
|
||||
**Oracle Database 23ai** for organizations that standardize on Oracle
|
||||
infrastructure. All memory operations — retain, recall, and reflect — work the
|
||||
same way on Oracle; the backend is selected with a single environment variable.
|
||||
|
||||
This guide covers everything needed to run Hindsight against Oracle: the
|
||||
prerequisites, the driver, a local quick start, provisioning a production
|
||||
database, running migrations, and the handful of behavioural differences from
|
||||
PostgreSQL.
|
||||
|
||||
:::info When to use Oracle
|
||||
Oracle is the right choice when your organization already runs Oracle and needs
|
||||
Hindsight to live inside that footprint. For everything else, the default
|
||||
PostgreSQL backend is simpler to operate — see [Storage](./storage) for the
|
||||
rationale. Oracle and PostgreSQL are configured independently; you pick one per
|
||||
deployment.
|
||||
:::
|
||||
|
||||
## Requirements
|
||||
|
||||
| Requirement | Details |
|
||||
|-------------|---------|
|
||||
| Oracle Database | **23ai** (23.4+). [Oracle Database Free 23ai](https://www.oracle.com/database/free/) works for development. |
|
||||
| `VECTOR` type | Used for embeddings. Requires the schema to live in an **ASSM tablespace** (see below). |
|
||||
| Oracle Text | Full-text search uses Oracle Text indexes. The schema user needs the `CTXAPP` role. |
|
||||
| Driver | [`python-oracledb`](https://python-oracledb.readthedocs.io/) ≥ 2.5.0, running in **thin mode** — pure Python, no Oracle Instant Client required. |
|
||||
|
||||
:::warning The schema must use an ASSM tablespace
|
||||
Oracle's `SYSTEM` tablespace uses *manual* segment space management (MSSM),
|
||||
which **does not support `VECTOR` columns**. Create the Hindsight user in a
|
||||
tablespace with **Automatic Segment Space Management (ASSM)** — otherwise
|
||||
migrations fail when they create embedding columns. The provisioning SQL below
|
||||
does this for you.
|
||||
:::
|
||||
|
||||
## Install the driver
|
||||
|
||||
The Oracle driver is an optional extra — it is not bundled with the default
|
||||
packages. Install it alongside Hindsight:
|
||||
|
||||
```bash
|
||||
# With the packaged extra
|
||||
pip install "hindsight-api-slim[oracle]"
|
||||
|
||||
# Or add the driver to an existing install (e.g. the full hindsight-api package)
|
||||
pip install hindsight-api oracledb
|
||||
```
|
||||
|
||||
If the driver is missing at startup, Hindsight fails with:
|
||||
`python-oracledb is required for Oracle backend. Install it with: pip install oracledb`.
|
||||
|
||||
## Quick start (local Oracle)
|
||||
|
||||
The fastest way to try Hindsight on Oracle is the bundled helper script, which
|
||||
starts a local **Oracle Database Free 23ai** container, provisions the test
|
||||
user with the correct tablespace and grants, and prints a ready-to-use
|
||||
connection URL:
|
||||
|
||||
```bash
|
||||
# Start Oracle Free in Docker and bootstrap the hindsight_test user
|
||||
./scripts/dev/start-oracle.sh
|
||||
|
||||
# ...prints:
|
||||
# export HINDSIGHT_API_DATABASE_BACKEND=oracle
|
||||
# export HINDSIGHT_API_DATABASE_URL='oracle+oracledb://hindsight_test:hindsight_test@localhost:1521/FREEPDB1'
|
||||
|
||||
# Stop and remove the container when done
|
||||
./scripts/dev/stop-oracle.sh
|
||||
```
|
||||
|
||||
A cold start takes 60–120s while the database initializes. If the first run
|
||||
reports a provisioning error, the database was still starting up — just re-run
|
||||
`./scripts/dev/start-oracle.sh` once the container is healthy (it is idempotent).
|
||||
|
||||
Once the script prints the connection URL, export the variables it shows, and
|
||||
**also set the schema** to the Oracle user it created:
|
||||
|
||||
```bash
|
||||
export HINDSIGHT_API_DATABASE_SCHEMA=HINDSIGHT_TEST
|
||||
```
|
||||
|
||||
Then run migrations and start the API (see the steps below). Setting the schema
|
||||
is required on Oracle — see [step 3](#3-configure-hindsight). This is the same
|
||||
setup Hindsight's CI uses to test the Oracle backend.
|
||||
|
||||
## Production setup
|
||||
|
||||
### 1. Provision the schema user
|
||||
|
||||
Connect to your pluggable database as a privileged user (for example `SYSTEM`)
|
||||
and create a dedicated tablespace and user for Hindsight. The tablespace **must**
|
||||
use ASSM so `VECTOR` columns are supported:
|
||||
|
||||
```sql
|
||||
-- ASSM tablespace (required for VECTOR columns). Size to your data volume.
|
||||
CREATE BIGFILE TABLESPACE hindsight_ts
|
||||
DATAFILE 'hindsight_ts.dbf' SIZE 2G AUTOEXTEND ON NEXT 500M MAXSIZE UNLIMITED
|
||||
EXTENT MANAGEMENT LOCAL
|
||||
SEGMENT SPACE MANAGEMENT AUTO;
|
||||
|
||||
-- Dedicated schema user
|
||||
CREATE USER hindsight IDENTIFIED BY "<strong-password>"
|
||||
DEFAULT TABLESPACE hindsight_ts
|
||||
TEMPORARY TABLESPACE temp
|
||||
QUOTA UNLIMITED ON hindsight_ts;
|
||||
|
||||
-- Object privileges Hindsight's migrations need
|
||||
GRANT CONNECT, RESOURCE, CREATE TABLE, CREATE SEQUENCE, CREATE VIEW, CREATE PROCEDURE TO hindsight;
|
||||
|
||||
-- Oracle Text (full-text search indexes)
|
||||
GRANT CTXAPP TO hindsight;
|
||||
```
|
||||
|
||||
:::note Least privilege
|
||||
`CONNECT` and `RESOURCE` cover the basics; the explicit `CREATE TABLE / SEQUENCE
|
||||
/ VIEW / PROCEDURE` grants and `CTXAPP` are what the schema migrations require.
|
||||
No `DBA` role is needed. On a managed service where `CREATE TABLESPACE` is not
|
||||
available directly, provision the schema through the platform's admin tooling —
|
||||
the requirements are unchanged: an **ASSM** default tablespace (needed for
|
||||
`VECTOR` columns) plus the `CTXAPP` role.
|
||||
:::
|
||||
|
||||
### 2. Build the connection URL
|
||||
|
||||
Hindsight uses SQLAlchemy-style URLs. The Oracle form is:
|
||||
|
||||
```
|
||||
oracle+oracledb://USER:PASSWORD@HOST:PORT/SERVICE_NAME
|
||||
```
|
||||
|
||||
| Part | Example | Notes |
|
||||
|------|---------|-------|
|
||||
| `USER` / `PASSWORD` | `hindsight` / `s3cret` | The schema user from step 1. URL-encode reserved characters (`@`, `/`, `:`) in the password. |
|
||||
| `HOST:PORT` | `db.internal:1521` | The listener host and port (Oracle default is `1521`). |
|
||||
| `SERVICE_NAME` | `FREEPDB1` | The **service name** of your pluggable database (not the SID). `FREEPDB1` for Oracle Free. |
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
oracle+oracledb://hindsight:s3cret@db.internal:1521/ORCLPDB1
|
||||
```
|
||||
|
||||
:::warning Connection support: Easy Connect only
|
||||
Hindsight builds the Oracle connection from the URL as a plain
|
||||
`host:port/service_name` descriptor. **Wallet-based mTLS, TLS/TCPS, and TNS
|
||||
aliases or full connect descriptors are not currently supported** by the
|
||||
connection layer. In practice:
|
||||
|
||||
- **Oracle Autonomous Database** and other services that require a wallet /
|
||||
mTLS are not supported as-is — connect to a database reachable over a direct
|
||||
`host:port/service` listener.
|
||||
- The driver does not negotiate TLS itself, so secure the connection at the
|
||||
network layer (private networking, VPN, or a TLS-terminating proxy).
|
||||
:::
|
||||
|
||||
### 3. Configure Hindsight
|
||||
|
||||
Point Hindsight at Oracle with two environment variables:
|
||||
|
||||
```bash
|
||||
export HINDSIGHT_API_DATABASE_BACKEND=oracle
|
||||
export HINDSIGHT_API_DATABASE_URL='oracle+oracledb://hindsight:s3cret@db.internal:1521/ORCLPDB1'
|
||||
export HINDSIGHT_API_DATABASE_SCHEMA=HINDSIGHT # the Oracle user from step 1
|
||||
```
|
||||
|
||||
`HINDSIGHT_API_DATABASE_BACKEND` defaults to `postgresql`; set it to `oracle` to
|
||||
select the Oracle backend.
|
||||
|
||||
:::warning Set `DATABASE_SCHEMA` to your Oracle user
|
||||
`HINDSIGHT_API_DATABASE_SCHEMA` defaults to `public` — a PostgreSQL concept. On
|
||||
Oracle a schema **is a user**, there is no `public` schema, and leaving the
|
||||
default makes migrations fail with `ORA-01435: user does not exist`. Set it to
|
||||
the schema user you created in step 1, spelled exactly as Oracle stores it —
|
||||
**uppercase** (e.g. `HINDSIGHT`) unless you created the user with a quoted
|
||||
lower-case name.
|
||||
:::
|
||||
|
||||
See [Configuration → Database](./configuration#database) for the full list of
|
||||
database variables.
|
||||
|
||||
### 4. Run migrations
|
||||
|
||||
Hindsight runs the same schema migrations on Oracle as on PostgreSQL. By default
|
||||
the API applies them automatically on startup
|
||||
(`HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP=true`). To run them explicitly — for
|
||||
example in a controlled deploy step — use:
|
||||
|
||||
```bash
|
||||
hindsight-admin run-db-migration
|
||||
```
|
||||
|
||||
This routes through the dialect-aware migration runner and creates the Oracle
|
||||
schema. (Unlike the admin CLI's data-movement commands, `run-db-migration`
|
||||
is fully supported on Oracle — see [Limitations](#limitations-vs-postgresql).)
|
||||
|
||||
:::warning Migrate with your runtime embedding dimension
|
||||
The embedding `VECTOR` columns are sized to the dimension of the configured
|
||||
embeddings model. Run migrations with the **same embeddings provider/model you
|
||||
will serve with** — otherwise the column dimension won't match the vectors the
|
||||
API produces and retain fails with `ORA-51803: Vector dimension count must
|
||||
match…` (for example, a schema built for a 384-dim local model rejects the
|
||||
1536-dim vectors from OpenAI `text-embedding-3-small`). If you change the
|
||||
embeddings model later, re-run migrations with `--embedding-dimension <N>` to
|
||||
resize the columns.
|
||||
:::
|
||||
|
||||
### 5. Start the API
|
||||
|
||||
```bash
|
||||
hindsight-api
|
||||
```
|
||||
|
||||
On startup Hindsight logs the resolved database (with credentials masked); it
|
||||
should show your Oracle host and confirm the Oracle backend is active.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
Oracle-relevant settings, all documented in full on the
|
||||
[Configuration](./configuration) page:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `HINDSIGHT_API_DATABASE_BACKEND` | `postgresql` (default) or `oracle`. |
|
||||
| `HINDSIGHT_API_DATABASE_URL` | `oracle+oracledb://…` connection URL. |
|
||||
| `HINDSIGHT_API_DATABASE_SCHEMA` | Schema/user for the tables. On Oracle set this to your schema user (uppercase); the `public` default fails. |
|
||||
| `HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP` | Auto-apply migrations when the API boots (default `true`). |
|
||||
|
||||
## Limitations vs PostgreSQL
|
||||
|
||||
Memory operations behave identically on Oracle, but a few operational and
|
||||
internal details differ:
|
||||
|
||||
- **Admin CLI data commands are PostgreSQL-only.** `hindsight-admin` backup,
|
||||
restore, bank export/import, and worker-status use asyncpg binary `COPY` and
|
||||
`TRUNCATE`, which are PostgreSQL-specific and not available on Oracle.
|
||||
Schema migrations (`run-db-migration`) *are* supported on Oracle.
|
||||
- **No embedded database.** The `pg0` embedded PostgreSQL used for zero-config
|
||||
local development has no Oracle equivalent — Oracle always requires a running
|
||||
instance (use the [quick-start script](#quick-start-local-oracle) locally).
|
||||
- **Consolidation reconciliation is skipped.** The similarity-based
|
||||
near-duplicate reconciliation pass in consolidation
|
||||
(`HINDSIGHT_API_CONSOLIDATION_DEDUP_THRESHOLD`) is a PostgreSQL-only path;
|
||||
consolidation still runs on Oracle, without that extra reconciliation step.
|
||||
- **Entity resolution uses Oracle fuzzy matching.** Fuzzy entity lookup during
|
||||
retain uses Oracle's text matching rather than PostgreSQL's `pg_trgm` trigram
|
||||
matching. Behaviour is equivalent; the underlying mechanism differs.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause / Fix |
|
||||
|---------|-------------|
|
||||
| `python-oracledb is required for Oracle backend` | The driver isn't installed. Run `pip install oracledb` (or install the `[oracle]` extra). |
|
||||
| `ORA-01435: user does not exist` on migration | `HINDSIGHT_API_DATABASE_SCHEMA` is unset (defaults to `public`) or misspelled. Set it to your Oracle schema user, uppercase (e.g. `HINDSIGHT`). |
|
||||
| `ORA-51803: Vector dimension count must match` on retain | The schema was migrated with a different embedding dimension than the running embeddings model. Migrate with the same embeddings config, or re-run `run-db-migration --embedding-dimension <N>`. |
|
||||
| Migration errors when creating embedding/`VECTOR` columns | The schema user's default tablespace is not ASSM (often the `SYSTEM` tablespace). Recreate the user in an ASSM tablespace as shown above. |
|
||||
| Full-text search errors / missing Oracle Text index | The schema user is missing the `CTXAPP` role. Run `GRANT CTXAPP TO <user>;`. |
|
||||
| `ORA-12514` / service not found | The URL uses a SID or wrong service name. Use the pluggable database **service name** (e.g. `FREEPDB1`), not the SID. |
|
||||
| Login works manually but fails from Hindsight | A reserved character in the password isn't URL-encoded. Encode `@ / : ?` in the `DATABASE_URL`. |
|
||||
|
||||
## See also
|
||||
|
||||
- [Storage](./storage) — why PostgreSQL is the default, and how Oracle fits in
|
||||
- [Configuration](./configuration#database) — all database environment variables
|
||||
- [Installation](./installation) — packaging and deployment options
|
||||
- [Admin CLI](./admin-cli) — administrative commands (PostgreSQL-only data operations)
|
||||
@@ -42,6 +42,8 @@ By building on PostgreSQL, we keep the system simple:
|
||||
|
||||
For enterprise deployments, Hindsight also supports Oracle AI Database with full feature parity. All memory operations—retain, recall, and reflect—work identically on Oracle, making it a drop-in option for organizations that standardize on Oracle infrastructure.
|
||||
|
||||
See the [Oracle Database guide](./oracle) for setup: prerequisites, provisioning, connection URLs, migrations, and the differences from PostgreSQL.
|
||||
|
||||
## Development with pg0
|
||||
|
||||
For local development, Hindsight uses **[pg0](https://github.com/vectorize-io/pg0)**—an embedded PostgreSQL distribution.
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -232,6 +232,12 @@ const sidebars: SidebarsConfig = {
|
||||
label: 'Admin CLI',
|
||||
customProps: { icon: 'lu-terminal' },
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
id: 'developer/oracle',
|
||||
label: 'Oracle Database',
|
||||
customProps: { icon: 'lu-database' },
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
id: 'developer/extensions',
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -10,6 +10,12 @@ For the source code, see [`hindsight-integrations/zed`](https://github.com/vecto
|
||||
|
||||
← [Back to main changelog](/changelog)
|
||||
|
||||
## [0.2.0](https://github.com/vectorize-io/hindsight/tree/integrations/zed/v0.2.0)
|
||||
|
||||
**Breaking Changes**
|
||||
|
||||
- Switched the Zed integration setup CLI from Python to Node.js, removing the Python dependency (installation/setup flow changes).<span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/benfrank241" target="_blank" rel="noopener noreferrer" style={{color: "var(--ifm-color-primary)", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: "4px", verticalAlign: "middle"}}><img src="https://github.com/benfrank241.png?size=40" alt="@benfrank241" width="18" height="18" style={{borderRadius: "50%"}} />@benfrank241</a><span style={{color: "var(--ifm-color-emphasis-500)", margin: "0 0.3em"}}>·</span><a href="https://github.com/vectorize-io/hindsight/commit/9dee1c594" target="_blank" rel="noopener noreferrer" style={{fontFamily: "var(--ifm-font-family-monospace, monospace)", fontSize: "0.85em", color: "var(--ifm-color-emphasis-600)"}}>9dee1c594</a>
|
||||
|
||||
## [0.1.0](https://github.com/vectorize-io/hindsight/tree/integrations/zed/v0.1.0)
|
||||
|
||||
**Features**
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Fact extraction mode: 'concise' (default), 'verbose', or 'custom'",
|
||||
"description": "Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'",
|
||||
"title": "Retain Extraction Mode"
|
||||
},
|
||||
"retain_custom_instructions": {
|
||||
|
||||
|
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 |
|
After Width: | Height: | Size: 98 KiB |
@@ -6592,7 +6592,7 @@
|
||||
}
|
||||
],
|
||||
"title": "Retain Extraction Mode",
|
||||
"description": "Fact extraction mode: 'concise' (default), 'verbose', or 'custom'"
|
||||
"description": "Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'"
|
||||
},
|
||||
"retain_custom_instructions": {
|
||||
"anyOf": [
|
||||
@@ -7631,7 +7631,7 @@
|
||||
}
|
||||
],
|
||||
"title": "Retain Extraction Mode",
|
||||
"description": "Fact extraction mode: 'concise' (default), 'verbose', or 'custom'."
|
||||
"description": "Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'."
|
||||
},
|
||||
"retain_custom_instructions": {
|
||||
"anyOf": [
|
||||
@@ -9551,6 +9551,10 @@
|
||||
"date": "2024-01-15T10:30:00Z",
|
||||
"entities": "Alice (PERSON), Google (ORGANIZATION)",
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"metadata": {
|
||||
"channel": "engineering",
|
||||
"source": "slack"
|
||||
},
|
||||
"text": "Alice works at Google on the AI team",
|
||||
"type": "world"
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import memoriesGo from '!!raw-loader!@site/examples/api/memories.go';
|
||||
|
||||
## List memory units
|
||||
|
||||
List the memory units in a bank. The response includes each unit's `fact_type` (`world` | `experience` | `observation`), `state` (`valid` | `invalidated`), entities, occurred dates, and — for facts a user has edited — an `edited_at` timestamp. Invalidated rows are **included by default** so curation stays auditable; filter with `state=`.
|
||||
List the memory units in a bank. The response includes each unit's `fact_type` (`world` | `experience` | `observation`), `state` (`valid` | `invalidated`), metadata, entities, occurred dates, and — for facts a user has edited — an `edited_at` timestamp. Invalidated rows are **included by default** so curation stays auditable; filter with `state=`.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="python" label="Python">
|
||||
@@ -47,6 +47,8 @@ List the memory units in a bank. The response includes each unit's `fact_type` (
|
||||
|
||||
## Fetch a single memory unit
|
||||
|
||||
Fetch a memory unit by ID, including its content, metadata, entities, timestamps, tags, and curation state.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={memoriesPy} section="get-memory" language="python" />
|
||||
|
||||
@@ -29,6 +29,8 @@ The API service handles all memory operations (retain, recall, reflect).
|
||||
|
||||
If not provided, the server uses embedded `pg0` — convenient for development but not recommended for production.
|
||||
|
||||
To run against Oracle Database 23ai instead, set `HINDSIGHT_API_DATABASE_BACKEND=oracle` and use an `oracle+oracledb://…` URL. See the [Oracle Database guide](./oracle) for full setup instructions.
|
||||
|
||||
The `DATABASE_SCHEMA` setting allows you to use a custom PostgreSQL schema instead of the default `public` schema. This is useful for:
|
||||
- Multi-database setups where you want Hindsight tables in a dedicated schema
|
||||
- Hosting platforms (e.g., Supabase) where `public` schema is reserved or shared
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
# Oracle Database
|
||||
|
||||
Hindsight uses PostgreSQL as its default storage backend, but it also runs on
|
||||
**Oracle Database 23ai** for organizations that standardize on Oracle
|
||||
infrastructure. All memory operations — retain, recall, and reflect — work the
|
||||
same way on Oracle; the backend is selected with a single environment variable.
|
||||
|
||||
This guide covers everything needed to run Hindsight against Oracle: the
|
||||
prerequisites, the driver, a local quick start, provisioning a production
|
||||
database, running migrations, and the handful of behavioural differences from
|
||||
PostgreSQL.
|
||||
|
||||
:::info When to use Oracle
|
||||
Oracle is the right choice when your organization already runs Oracle and needs
|
||||
Hindsight to live inside that footprint. For everything else, the default
|
||||
PostgreSQL backend is simpler to operate — see [Storage](./storage) for the
|
||||
rationale. Oracle and PostgreSQL are configured independently; you pick one per
|
||||
deployment.
|
||||
:::
|
||||
|
||||
## Requirements
|
||||
|
||||
| Requirement | Details |
|
||||
|-------------|---------|
|
||||
| Oracle Database | **23ai** (23.4+). [Oracle Database Free 23ai](https://www.oracle.com/database/free/) works for development. |
|
||||
| `VECTOR` type | Used for embeddings. Requires the schema to live in an **ASSM tablespace** (see below). |
|
||||
| Oracle Text | Full-text search uses Oracle Text indexes. The schema user needs the `CTXAPP` role. |
|
||||
| Driver | [`python-oracledb`](https://python-oracledb.readthedocs.io/) ≥ 2.5.0, running in **thin mode** — pure Python, no Oracle Instant Client required. |
|
||||
|
||||
:::warning The schema must use an ASSM tablespace
|
||||
Oracle's `SYSTEM` tablespace uses *manual* segment space management (MSSM),
|
||||
which **does not support `VECTOR` columns**. Create the Hindsight user in a
|
||||
tablespace with **Automatic Segment Space Management (ASSM)** — otherwise
|
||||
migrations fail when they create embedding columns. The provisioning SQL below
|
||||
does this for you.
|
||||
:::
|
||||
|
||||
## Install the driver
|
||||
|
||||
The Oracle driver is an optional extra — it is not bundled with the default
|
||||
packages. Install it alongside Hindsight:
|
||||
|
||||
```bash
|
||||
# With the packaged extra
|
||||
pip install "hindsight-api-slim[oracle]"
|
||||
|
||||
# Or add the driver to an existing install (e.g. the full hindsight-api package)
|
||||
pip install hindsight-api oracledb
|
||||
```
|
||||
|
||||
If the driver is missing at startup, Hindsight fails with:
|
||||
`python-oracledb is required for Oracle backend. Install it with: pip install oracledb`.
|
||||
|
||||
## Quick start (local Oracle)
|
||||
|
||||
The fastest way to try Hindsight on Oracle is the bundled helper script, which
|
||||
starts a local **Oracle Database Free 23ai** container, provisions the test
|
||||
user with the correct tablespace and grants, and prints a ready-to-use
|
||||
connection URL:
|
||||
|
||||
```bash
|
||||
# Start Oracle Free in Docker and bootstrap the hindsight_test user
|
||||
./scripts/dev/start-oracle.sh
|
||||
|
||||
# ...prints:
|
||||
# export HINDSIGHT_API_DATABASE_BACKEND=oracle
|
||||
# export HINDSIGHT_API_DATABASE_URL='oracle+oracledb://hindsight_test:hindsight_test@localhost:1521/FREEPDB1'
|
||||
|
||||
# Stop and remove the container when done
|
||||
./scripts/dev/stop-oracle.sh
|
||||
```
|
||||
|
||||
A cold start takes 60–120s while the database initializes. If the first run
|
||||
reports a provisioning error, the database was still starting up — just re-run
|
||||
`./scripts/dev/start-oracle.sh` once the container is healthy (it is idempotent).
|
||||
|
||||
Once the script prints the connection URL, export the variables it shows, and
|
||||
**also set the schema** to the Oracle user it created:
|
||||
|
||||
```bash
|
||||
export HINDSIGHT_API_DATABASE_SCHEMA=HINDSIGHT_TEST
|
||||
```
|
||||
|
||||
Then run migrations and start the API (see the steps below). Setting the schema
|
||||
is required on Oracle — see [step 3](#3-configure-hindsight). This is the same
|
||||
setup Hindsight's CI uses to test the Oracle backend.
|
||||
|
||||
## Production setup
|
||||
|
||||
### 1. Provision the schema user
|
||||
|
||||
Connect to your pluggable database as a privileged user (for example `SYSTEM`)
|
||||
and create a dedicated tablespace and user for Hindsight. The tablespace **must**
|
||||
use ASSM so `VECTOR` columns are supported:
|
||||
|
||||
```sql
|
||||
-- ASSM tablespace (required for VECTOR columns). Size to your data volume.
|
||||
CREATE BIGFILE TABLESPACE hindsight_ts
|
||||
DATAFILE 'hindsight_ts.dbf' SIZE 2G AUTOEXTEND ON NEXT 500M MAXSIZE UNLIMITED
|
||||
EXTENT MANAGEMENT LOCAL
|
||||
SEGMENT SPACE MANAGEMENT AUTO;
|
||||
|
||||
-- Dedicated schema user
|
||||
CREATE USER hindsight IDENTIFIED BY "<strong-password>"
|
||||
DEFAULT TABLESPACE hindsight_ts
|
||||
TEMPORARY TABLESPACE temp
|
||||
QUOTA UNLIMITED ON hindsight_ts;
|
||||
|
||||
-- Object privileges Hindsight's migrations need
|
||||
GRANT CONNECT, RESOURCE, CREATE TABLE, CREATE SEQUENCE, CREATE VIEW, CREATE PROCEDURE TO hindsight;
|
||||
|
||||
-- Oracle Text (full-text search indexes)
|
||||
GRANT CTXAPP TO hindsight;
|
||||
```
|
||||
|
||||
:::note Least privilege
|
||||
`CONNECT` and `RESOURCE` cover the basics; the explicit `CREATE TABLE / SEQUENCE
|
||||
/ VIEW / PROCEDURE` grants and `CTXAPP` are what the schema migrations require.
|
||||
No `DBA` role is needed. On a managed service where `CREATE TABLESPACE` is not
|
||||
available directly, provision the schema through the platform's admin tooling —
|
||||
the requirements are unchanged: an **ASSM** default tablespace (needed for
|
||||
`VECTOR` columns) plus the `CTXAPP` role.
|
||||
:::
|
||||
|
||||
### 2. Build the connection URL
|
||||
|
||||
Hindsight uses SQLAlchemy-style URLs. The Oracle form is:
|
||||
|
||||
```
|
||||
oracle+oracledb://USER:PASSWORD@HOST:PORT/SERVICE_NAME
|
||||
```
|
||||
|
||||
| Part | Example | Notes |
|
||||
|------|---------|-------|
|
||||
| `USER` / `PASSWORD` | `hindsight` / `s3cret` | The schema user from step 1. URL-encode reserved characters (`@`, `/`, `:`) in the password. |
|
||||
| `HOST:PORT` | `db.internal:1521` | The listener host and port (Oracle default is `1521`). |
|
||||
| `SERVICE_NAME` | `FREEPDB1` | The **service name** of your pluggable database (not the SID). `FREEPDB1` for Oracle Free. |
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
oracle+oracledb://hindsight:s3cret@db.internal:1521/ORCLPDB1
|
||||
```
|
||||
|
||||
:::warning Connection support: Easy Connect only
|
||||
Hindsight builds the Oracle connection from the URL as a plain
|
||||
`host:port/service_name` descriptor. **Wallet-based mTLS, TLS/TCPS, and TNS
|
||||
aliases or full connect descriptors are not currently supported** by the
|
||||
connection layer. In practice:
|
||||
|
||||
- **Oracle Autonomous Database** and other services that require a wallet /
|
||||
mTLS are not supported as-is — connect to a database reachable over a direct
|
||||
`host:port/service` listener.
|
||||
- The driver does not negotiate TLS itself, so secure the connection at the
|
||||
network layer (private networking, VPN, or a TLS-terminating proxy).
|
||||
:::
|
||||
|
||||
### 3. Configure Hindsight
|
||||
|
||||
Point Hindsight at Oracle with two environment variables:
|
||||
|
||||
```bash
|
||||
export HINDSIGHT_API_DATABASE_BACKEND=oracle
|
||||
export HINDSIGHT_API_DATABASE_URL='oracle+oracledb://hindsight:s3cret@db.internal:1521/ORCLPDB1'
|
||||
export HINDSIGHT_API_DATABASE_SCHEMA=HINDSIGHT # the Oracle user from step 1
|
||||
```
|
||||
|
||||
`HINDSIGHT_API_DATABASE_BACKEND` defaults to `postgresql`; set it to `oracle` to
|
||||
select the Oracle backend.
|
||||
|
||||
:::warning Set `DATABASE_SCHEMA` to your Oracle user
|
||||
`HINDSIGHT_API_DATABASE_SCHEMA` defaults to `public` — a PostgreSQL concept. On
|
||||
Oracle a schema **is a user**, there is no `public` schema, and leaving the
|
||||
default makes migrations fail with `ORA-01435: user does not exist`. Set it to
|
||||
the schema user you created in step 1, spelled exactly as Oracle stores it —
|
||||
**uppercase** (e.g. `HINDSIGHT`) unless you created the user with a quoted
|
||||
lower-case name.
|
||||
:::
|
||||
|
||||
See [Configuration → Database](./configuration#database) for the full list of
|
||||
database variables.
|
||||
|
||||
### 4. Run migrations
|
||||
|
||||
Hindsight runs the same schema migrations on Oracle as on PostgreSQL. By default
|
||||
the API applies them automatically on startup
|
||||
(`HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP=true`). To run them explicitly — for
|
||||
example in a controlled deploy step — use:
|
||||
|
||||
```bash
|
||||
hindsight-admin run-db-migration
|
||||
```
|
||||
|
||||
This routes through the dialect-aware migration runner and creates the Oracle
|
||||
schema. (Unlike the admin CLI's data-movement commands, `run-db-migration`
|
||||
is fully supported on Oracle — see [Limitations](#limitations-vs-postgresql).)
|
||||
|
||||
:::warning Migrate with your runtime embedding dimension
|
||||
The embedding `VECTOR` columns are sized to the dimension of the configured
|
||||
embeddings model. Run migrations with the **same embeddings provider/model you
|
||||
will serve with** — otherwise the column dimension won't match the vectors the
|
||||
API produces and retain fails with `ORA-51803: Vector dimension count must
|
||||
match…` (for example, a schema built for a 384-dim local model rejects the
|
||||
1536-dim vectors from OpenAI `text-embedding-3-small`). If you change the
|
||||
embeddings model later, re-run migrations with `--embedding-dimension <N>` to
|
||||
resize the columns.
|
||||
:::
|
||||
|
||||
### 5. Start the API
|
||||
|
||||
```bash
|
||||
hindsight-api
|
||||
```
|
||||
|
||||
On startup Hindsight logs the resolved database (with credentials masked); it
|
||||
should show your Oracle host and confirm the Oracle backend is active.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
Oracle-relevant settings, all documented in full on the
|
||||
[Configuration](./configuration) page:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `HINDSIGHT_API_DATABASE_BACKEND` | `postgresql` (default) or `oracle`. |
|
||||
| `HINDSIGHT_API_DATABASE_URL` | `oracle+oracledb://…` connection URL. |
|
||||
| `HINDSIGHT_API_DATABASE_SCHEMA` | Schema/user for the tables. On Oracle set this to your schema user (uppercase); the `public` default fails. |
|
||||
| `HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP` | Auto-apply migrations when the API boots (default `true`). |
|
||||
|
||||
## Limitations vs PostgreSQL
|
||||
|
||||
Memory operations behave identically on Oracle, but a few operational and
|
||||
internal details differ:
|
||||
|
||||
- **Admin CLI data commands are PostgreSQL-only.** `hindsight-admin` backup,
|
||||
restore, bank export/import, and worker-status use asyncpg binary `COPY` and
|
||||
`TRUNCATE`, which are PostgreSQL-specific and not available on Oracle.
|
||||
Schema migrations (`run-db-migration`) *are* supported on Oracle.
|
||||
- **No embedded database.** The `pg0` embedded PostgreSQL used for zero-config
|
||||
local development has no Oracle equivalent — Oracle always requires a running
|
||||
instance (use the [quick-start script](#quick-start-local-oracle) locally).
|
||||
- **Consolidation reconciliation is skipped.** The similarity-based
|
||||
near-duplicate reconciliation pass in consolidation
|
||||
(`HINDSIGHT_API_CONSOLIDATION_DEDUP_THRESHOLD`) is a PostgreSQL-only path;
|
||||
consolidation still runs on Oracle, without that extra reconciliation step.
|
||||
- **Entity resolution uses Oracle fuzzy matching.** Fuzzy entity lookup during
|
||||
retain uses Oracle's text matching rather than PostgreSQL's `pg_trgm` trigram
|
||||
matching. Behaviour is equivalent; the underlying mechanism differs.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause / Fix |
|
||||
|---------|-------------|
|
||||
| `python-oracledb is required for Oracle backend` | The driver isn't installed. Run `pip install oracledb` (or install the `[oracle]` extra). |
|
||||
| `ORA-01435: user does not exist` on migration | `HINDSIGHT_API_DATABASE_SCHEMA` is unset (defaults to `public`) or misspelled. Set it to your Oracle schema user, uppercase (e.g. `HINDSIGHT`). |
|
||||
| `ORA-51803: Vector dimension count must match` on retain | The schema was migrated with a different embedding dimension than the running embeddings model. Migrate with the same embeddings config, or re-run `run-db-migration --embedding-dimension <N>`. |
|
||||
| Migration errors when creating embedding/`VECTOR` columns | The schema user's default tablespace is not ASSM (often the `SYSTEM` tablespace). Recreate the user in an ASSM tablespace as shown above. |
|
||||
| Full-text search errors / missing Oracle Text index | The schema user is missing the `CTXAPP` role. Run `GRANT CTXAPP TO <user>;`. |
|
||||
| `ORA-12514` / service not found | The URL uses a SID or wrong service name. Use the pluggable database **service name** (e.g. `FREEPDB1`), not the SID. |
|
||||
| Login works manually but fails from Hindsight | A reserved character in the password isn't URL-encoded. Encode `@ / : ?` in the `DATABASE_URL`. |
|
||||
|
||||
## See also
|
||||
|
||||
- [Storage](./storage) — why PostgreSQL is the default, and how Oracle fits in
|
||||
- [Configuration](./configuration#database) — all database environment variables
|
||||
- [Installation](./installation) — packaging and deployment options
|
||||
- [Admin CLI](./admin-cli) — administrative commands (PostgreSQL-only data operations)
|
||||
@@ -42,6 +42,8 @@ By building on PostgreSQL, we keep the system simple:
|
||||
|
||||
For enterprise deployments, Hindsight also supports Oracle AI Database with full feature parity. All memory operations—retain, recall, and reflect—work identically on Oracle, making it a drop-in option for organizations that standardize on Oracle infrastructure.
|
||||
|
||||
See the [Oracle Database guide](./oracle) for setup: prerequisites, provisioning, connection URLs, migrations, and the differences from PostgreSQL.
|
||||
|
||||
## Development with pg0
|
||||
|
||||
For local development, Hindsight uses **[pg0](https://github.com/vectorize-io/pg0)**—an embedded PostgreSQL distribution.
|
||||
|
||||
@@ -295,6 +295,14 @@
|
||||
"icon": "lu-terminal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "doc",
|
||||
"id": "developer/oracle",
|
||||
"label": "Oracle Database",
|
||||
"customProps": {
|
||||
"icon": "lu-database"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "doc",
|
||||
"id": "developer/extensions",
|
||||
|
||||
@@ -115,6 +115,11 @@ HINDSIGHT_API_LOG_LEVEL=info
|
||||
# chinese_lindera/lindera(chinese), japanese_lindera/lindera(japanese),
|
||||
# korean_lindera/lindera(korean), ngram(min,max), edge_ngram(min,max)
|
||||
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER=
|
||||
# Optional cap on the number of terms in the native PostgreSQL BM25 tsquery.
|
||||
# Long queries OR-join every normalized token, which can match too much of a
|
||||
# large bank. 0 (default) keeps the historical uncapped behavior; a positive
|
||||
# value bounds only the native backend (other BM25 backends get the raw query).
|
||||
# HINDSIGHT_API_BM25_MAX_QUERY_TERMS=0
|
||||
|
||||
# File Parser (Optional - uses markitdown by default)
|
||||
# HINDSIGHT_API_FILE_PARSER=markitdown
|
||||
|
||||