Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 87856fcc7e fix(opencode): default-export the Plugin function directly
OpenCode's plugin loader iterates Object.entries(mod) and invokes every
export as a Plugin factory `(input) => Promise<Hooks>`, deduping by
identity. Our prior default export was a PluginModule object
(`{ id, server }`), which opencode tried to call as a function and
crashed with `fn3 is not a function. (In 'fn3(input)', 'fn3' is an
instance of Object)` at load time.

Default-export the HindsightPlugin function itself so both default and
named `HindsightPlugin` exports point to the same reference (dedupe
suppresses a second call). Update the default-export smoke test to
assert this invariant.

Verified end-to-end against opencode 1.1.49 with the built dist — the
plugin now initializes, registers tools/hooks, and processes session
events without error.
2026-04-14 15:37:55 +02:00
Nicolò Boschi eefa449d54 docs(opencode): drop misleading npm install step, document Hindsight Cloud
OpenCode auto-installs plugins listed in the "plugin" array at startup via
Bun; the prior instructions to `npm install` the package were misleading.
Also add a dedicated Hindsight Cloud section with api.hindsight.vectorize.io
and token guidance.
2026-04-14 15:31:52 +02:00
Nicolò Boschi 7d5d5b2781 release(opencode): v0.1.3 2026-04-14 15:17:36 +02:00
AldousandAldous the Orchestrator b79ab2b752 feat(openclaw): merge inline retain tags with defaults (#948)
* feat(openclaw): close remaining retain parity gaps

* docs(openclaw): preserve transcript format for retain parity patch

* refactor(openclaw): drop unused retain prefix config

* fix(openclaw): keep retain tag normalization narrow

* feat(openclaw): merge inline retain tags with defaults

---------

Co-authored-by: Aldous the Orchestrator <[email protected]>
2026-04-14 14:17:50 +02:00
Mr. Khachaturov cf9918891b docs(configuration): document HINDSIGHT_API_RETAIN_CHUNK_BATCH_SIZE (#1045)
The retain_chunk_batch_size hierarchical config field and its
ENV_RETAIN_CHUNK_BATCH_SIZE loader have existed in HindsightConfig
since the retain streaming batch landed, but the Retain section of
the configuration reference never got a row for them — users who
want to cap chunk-batch size on large document ingestion had to
discover the env var by grepping the source.

Add a row to the Retain table next to the other chunk/batch knobs,
with the same format as surrounding entries and an explicit note
that the field is configurable per bank via the bank config API.
2026-04-14 14:06:43 +02:00
Nicolò Boschi 9372462e13 fix(clients): set identifying User-Agent on all HTTP requests (#1041) (#1052)
Cloudflare (and other proxies with UA-based bot filtering) block the
default "Python-urllib/X.Y" and "reqwest/..." UA strings with error 1010,
causing all retain/recall traffic to silently fail against self-hosted
deployments.

Generated-client wrappers now send "hindsight-client-<lang>/<version>"
by default and expose a user_agent/userAgent override so integrations
can identify themselves. Each integration passes its own UA
("hindsight-<integration>/<version>") at client construction.

Integrations using raw urllib/fetch (claude-code, codex, openclaw,
paperclip) set the header directly in their HTTP layer — this fixes
the reported Cloudflare 1010 issue for the claude-code plugin.
2026-04-14 13:58:44 +02:00
Nicolò Boschi f2fc8f9f26 feat(api): add recall controls to mental model trigger (#1048)
* feat(api): add recall controls to mental model trigger

Internal recall during mental model refresh used to hardcode
include_chunks=True with fixed token budgets, wasting prompt budget on
chunks that some refreshes don't need.

Adds three knobs exposed both as hierarchical config (env -> tenant ->
bank) and as per-mental-model overrides on the trigger JSONB field:

- recall_include_chunks / trigger.include_chunks
- recall_max_tokens / trigger.recall_max_tokens
- recall_chunks_max_tokens / trigger.recall_chunks_max_tokens

Trigger value (when set) wins over bank/global config. Both refresh
paths (task handler and synchronous refresh_mental_model) forward the
overrides into reflect_async.

* feat(control-plane): expose recall trigger fields in mental model dialogs

Adds form fields under the Options tab for the three new trigger
overrides (include_chunks, recall_max_tokens, recall_chunks_max_tokens)
in both the create and update mental model dialogs. Empty/Default means
inherit the bank/global config.

* fix(control-plane): cap mental model dialog height and add scroll

* style(control-plane): theme scrollbars to match app surface

* refactor(control-plane): group mental model options into Refresh/Tags/Recall sections

* refactor(control-plane): move Fact Types into Recall, add Other Mental Models section

* fix(cli): pass new recall trigger fields in MentalModelTriggerInput

* chore: regenerate hindsight-docs skill openapi/configuration

* test(hierarchical-config): bump configurable field count for new recall fields
2026-04-14 13:27:16 +02:00
Nicolò Boschi 6a80ecbf65 docs: reframe observations as evidence-grounded consolidated knowledge (#1051)
* docs: reframe observations as evidence-grounded consolidated knowledge

The previous framing leaned on "synthesis" and "patterns", which reads as
LLM summarization and undersells what observations actually are: deduplicated
beliefs grounded in specific source memories (with quotes), refined — not
overwritten — when new evidence arrives, and carrying a computed freshness
trend (stable / strengthening / weakening / stale).

* docs: regenerate hindsight-docs skill references
2026-04-14 12:30:04 +02:00
Nicolò Boschi 870bf4a3d1 feat(operations): expose task_payload and document_ids on async ops (#1049)
* feat(operations): expose task_payload and document_ids on async ops

Add a "Load raw" affordance to the operations dialog so users can
inspect which document(s) an async operation was processing. Motivated
by pending/failed retain ops where there was previously no way to tell
which content was in flight.

- API: `GET /v1/default/banks/{bank_id}/operations/{operation_id}` now
  accepts `?include_payload=true` and returns `task_payload` (the raw
  submission params). Off by default since payloads can be large.
- Retain: replaces the singular `generated_document_id` in
  `result_metadata` with a `document_ids: list[str]` that captures
  every effective doc id (user-provided or generated), via an atomic,
  idempotent JSONB set-append. Multi-doc retains and user-supplied ids
  are now visible from the operation row.
- Control plane: dialog shows `result_metadata` as JSON (always) and
  a "Load raw" button that fetches the payload on demand; handles
  parent ops (payload lives on children) with a clear message.
- Regenerate OpenAPI spec and Python/TS/Rust/Go clients.
- Add tests covering user-supplied/generated/shared document_ids and
  the include_payload query param.

* chore: regenerate hindsight-docs skill openapi.json

* fix(cli): pass new include_payload arg to get_operation_status
2026-04-14 11:57:52 +02:00
Mr. Khachaturov 099f4c925a fix(bank-template): align BankTemplateConfig with _CONFIGURABLE_FIELDS (#1044)
BankTemplateConfig declared 12 hierarchical config fields, but
HindsightConfig._CONFIGURABLE_FIELDS — the allowlist the engine uses
to decide what can be overridden per-bank — contains 22. Ten fields
existed in HindsightConfig and config_resolver.update_bank_config()
accepted them, but the template import path at
POST /v1/default/banks/{id}/import couldn't deliver them: the
manifest handler resolves overrides via BankTemplateConfig.get_config_updates(),
which is a model_dump() filter, so any field not declared on the model
is silently dropped before reaching update_bank_config().

Expose the ten missing fields on BankTemplateConfig so they flow
through get_config_updates() and reach update_bank_config() unchanged:
retain_default_strategy, retain_strategies, retain_chunk_batch_size,
mcp_enabled_tools, consolidation_llm_batch_size,
consolidation_source_facts_max_tokens,
consolidation_source_facts_max_tokens_per_observation,
max_observations_per_scope, reflect_source_facts_max_tokens,
llm_gemini_safety_settings.

No engine changes. No new validation. config_resolver.update_bank_config()
already validates these fields correctly through _CONFIGURABLE_FIELDS;
the template manifest schema was the only thing blocking the path.

Adds a parametrized integration test that POSTs each new field through
/v1/default/banks/{id}/import and asserts the applied value round-trips
via GET /v1/default/banks/{id}/config under the "overrides" slot, matching
the shape test_import_applies_config already uses at
tests/test_bank_templates.py.
2026-04-14 11:54:34 +02:00
Nicolò Boschi e08faadc17 feat(worker): log [PENDING_BREAKDOWN] bucketing pending rows by claim filter (#1050)
Production incident: a 'pending' retain sat in the queue for hours while
workers had free slots. WORKER_STATS only reports the global pending count,
so there was no way to tell whether the rows were claimable-but-not-claimed
(real bug) vs filtered out by the claim WHERE clause (data state — orphaned
batch_retain parents with task_payload IS NULL, retry backoff, or worker_id
already stamped).

Add one extra periodic line, only when global_pending > 0, that buckets
pending rows per operation_type by the predicates the claim query filters
on. ``claimable`` is the residual that should be picked up next poll; if
``claimable > 0`` while workers report free slots, the bug is somewhere
else (lock contention, tenant discovery) and that line narrows the search.

[PENDING_BREAKDOWN] batch_retain: total=1 claimable=0 payload_null=1 ...
                  | retain: total=3 claimable=1 payload_null=0 retry_blocked=1 assigned=1
                  | consolidation: total=26 claimable=26 payload_null=0 ...

Implementation reuses the existing per-schema loop in _log_progress_if_due,
adding one GROUP BY query per schema. Buckets are aggregated across schemas
before rendering.
2026-04-14 11:36:38 +02:00
Nicolò Boschi dbd1d1a743 fix(retain): prevent IndexError on embeddings/facts length mismatch (#1037) (#1047)
`generate_embeddings_batch` now raises if the backend returns a different
number of vectors than input texts, instead of letting `zip()` silently
drop facts and surface later as `IndexError` in `_map_results_to_contents`.

`_map_results_to_contents` is also reworked to iterate `processed_facts`
(which is 1:1 with `unit_ids` by construction) and validates the lengths
match, providing defense-in-depth against any future drift.
2026-04-14 11:14:07 +02:00
Ben c084765950 blog: Update OpenClaw post for v0.6.0/v0.6.2 (#1038)
* blog: update OpenClaw post to reflect v0.6.0/v0.6.2 plugin changes
2026-04-13 15:39:00 -04:00
Nicolò Boschi d6ad53986a feat: add hindsight-architect skill (#1035) 2026-04-13 18:13:44 +02:00
DK09876andClaude Opus 4.6 6076354a9c fix(opencode): fix message parsing, shared state, and post-compaction retain (#1034)
Three bugs fixed:
1. msg.role → msg.info.role: OpenCode SDK wraps role inside info, so all
   messages were silently filtered out, breaking retain and recall (#941)
2. Move PluginState to module level so it persists across sessions instead
   of being recreated per plugin instantiation
3. Reset lastRetainedTurn after compaction so idle-retain resumes when the
   message list shrinks

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-04-13 18:05:25 +02:00
Nicolò Boschi 9f9c3a1b40 release(opencode): v0.1.2 2026-04-13 16:13:54 +02:00
Nicolò Boschi 8ba862b026 release(openclaw): v0.6.2 2026-04-13 16:08:28 +02:00
apnea fd87de9c15 fix(opencode-plugin): correct session.messages response shape and update tests (#993) 2026-04-13 16:02:04 +02:00
Nicolò Boschi adc85129ba feat(openclaw): retain as Anthropic-shaped JSON with tool_use/tool_result blocks (#1031)
* feat(openclaw): retain conversation as JSON by default

Default retention payload now mirrors the Claude Code integration: a
JSON-stringified array of {role, content} message objects, instead of the
legacy `[role: x] ... [x:end]` text markers. Structured JSON makes
downstream consumers (recall reranking, control-plane document viewer,
external pipelines) much easier to parse and stops fact extraction from
chasing the marker syntax as if it were content.

Add `retainFormat: "json" | "text"` plugin config (default `"json"`) so
operators can roll back to the legacy text shape if a custom downstream
pipeline depends on it.

* feat(openclaw): retain tool_use and tool_result blocks by default

Extends the JSON retain format so each message's content is an
Anthropic-shaped block array — text, tool_use, tool_result — instead of
a flat string. The agent's tool calls (with full inputs) and tool
results are now preserved in memory, matching what the Claude Code
integration stores and giving downstream fact extraction / recall
rerank a much richer signal.

- New `retainToolCalls` config (default true). Set false to keep
  flat-string content per message.
- Operational Hindsight MCP tools (recall/retain/search/CRUD) are
  filtered out to prevent feedback loops.
- Tool result content truncated at 2000 chars.
- OpenClaw's native shape (toolCall blocks inside assistant messages,
  separate role=toolResult messages) is normalized to Anthropic's shape
  on the way out: tool_use stays on assistant, tool_result becomes a
  synthesized user message containing just the tool_result block.
- `thinking` blocks are dropped.
2026-04-13 15:56:11 +02:00
Voscko 2ff805d6e9 fix(openclaw): stabilize session identity and skip operational turns (#987)
* fix(openclaw): stabilize session identity and skip operational turns

* test(openclaw): validate dispatch identity guardrails

* fix(openclaw): address review feedback on identity guardrails
2026-04-13 15:55:29 +02:00
Nicolò Boschi 8125a0d758 docs: add 0.5.1 changelog entry and release blog post (#1032)
- Generated 0.5.1 section in changelog via scripts/dev/generate-changelog.sh
- Added "What's new in Hindsight 0.5.1" blog post covering CLI coverage,
  Cloudflare OAuth proxy, default bank template, SiliconFlow reranker,
  hindsight-all daemon lifecycle package, and reliability fixes
2026-04-13 15:54:06 +02:00
Ben e1e137b027 blog: How I Built Multi-User AI Memory into a Financial Product from Day One (#1030)
* blog: Add Ming Fang fintech customer story — multi-user AI memory from day one
2026-04-13 09:52:30 -04:00
r266-tech 6b5aa3afe8 fix(embedded): add timeout to _cleanup lock acquisition (#1023)
* fix(embedded): add timeout to _cleanup lock acquisition (#1022)

_cleanup() acquires self._lock with a bare 'with' statement. When another
thread holds the lock (e.g. _ensure_started mid-operation), Ctrl+C causes
the shutdown path to hang indefinitely.

Replace with self._lock.acquire(timeout=5.0) so cleanup completes within
5 seconds even when the lock is contended. If timeout expires, proceed
with best-effort cleanup and log a warning.

Also wrap self._client.close() in try/except since the client may be in
an inconsistent state during interrupted shutdown.

Closes #1022

* test(embedded): add unit test for _cleanup lock timeout behavior

* fix(embedded): rework — skip shared-state teardown on lock timeout

Address Codex review findings:
- On timeout, only set _closed flag (prevents new ops) and return.
  Do NOT mutate shared state without the lock — the daemon's idle
  timeout handles cleanup on its own.
- Log client.close() exceptions at DEBUG level instead of swallowing.
2026-04-13 15:47:50 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e28b8c00f6 chore(deps): bump softprops/action-gh-release from 2 to 3 (#1024)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 15:42:18 +02:00
Nicolò Boschi 1be5ff33b0 fix(openclaw): register agent hooks on every plugin entry invocation (#1029)
OpenClaw calls the plugin entry multiple times per process (CLI, gateway,
lazy reloads), each with a fresh api bound to its own plugin registry. A
module-level `hooksRegistered` flag let the first call win and left later
registries with zero hindsight hooks — so auto-recall/auto-retain silently
stopped firing on live agent turns in 0.6.0/0.6.1.

Also document in CLAUDE.md that changelogs never carry "Unreleased"
sections; the release script writes entries at cut time.
2026-04-13 15:38:00 +02:00
Nicolò Boschi aeb0c8b553 Release v0.5.1
- Update version to 0.5.1 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.5
2026-04-13 12:11:04 +02:00
Nicolò Boschi d0b2ab9ad2 feat(reranker): add SiliconFlow provider; share Cohere-compatible HTTP client (#1019)
* feat(reranker): add SiliconFlow provider and share Cohere-compatible HTTP client

Closes #859.

Adds a `siliconflow` reranker provider for SiliconFlow's Cohere-compatible
`/rerank` endpoint, and refactors ZeroEntropy plus the Cohere custom-base_url
code path onto a shared `_CohereCompatibleRerankClient`. Setting
`HINDSIGHT_API_RERANKER_COHERE_BASE_URL` now routes the `cohere` provider
through the same HTTP client, making it a generic entry point for any
Cohere-compatible rerank host (Azure AI Foundry, Jina, Voyage, self-hosted
BGE, ...).

* fixup: update cohere tests for shared HTTP client + regen docs skill + ruff format
2026-04-13 12:04:12 +02:00
Nicolò Boschi 93562bfaaf release(openclaw): v0.6.1 2026-04-13 12:02:05 +02:00
Nicolò Boschi 9679d8139d fix(openclaw): setup wizard now asks for token value, not env var name (#1021)
User feedback from the 0.6.0 wizard: the prompt "Environment variable
holding your Hindsight Cloud API token" is confusing. Users paste the
raw token (or worse, the whole `NAME=value` pair), get an
UPPER_SNAKE_CASE validation error, and have no idea the wizard expected
a name instead of the value.

Rework: the interactive wizard now asks for the token / API key VALUE
via `p.password()` (masked input) and stores it inline as a plaintext
string in openclaw.json. The outro note tells users where the secret
was stored and shows the one-liner to switch to a SecretRef later.

For CI / production where a SecretRef is preferred, the existing
`--token-env` and `--api-key-env` non-interactive flags continue to
work. Also added their direct-value counterparts:

  --token <value>     stores inline in openclaw.json
  --token-env <VAR>   stores as SecretRef

  --api-key <value>   stores inline in openclaw.json
  --api-key-env <VAR> stores as SecretRef

`--token` / `--token-env` and `--api-key` / `--api-key-env` are
mutually exclusive within a mode. For api mode, any combination with
`--no-token` is also rejected.

The plugin manifest marks `llmApiKey` and `hindsightApiToken` as
sensitive, so `openclaw config get` continues to redact their values
regardless of storage shape.

Tests: 142 unit tests (up from 127 pre-change) cover both direct-value
and SecretRef paths across all three modes, plus the new mutual-
exclusivity errors. Smoke test exercises 7 setup variants (was 4) and
5 negative tests (was 3); all pass end-to-end against a real openclaw
install.
2026-04-13 11:53:00 +02:00
Nicolò Boschi ab7feb144b feat(worker): diagnostic logging for stuck/slow async tasks (#1017)
* feat(worker): diagnostic logging for stuck/slow async tasks

Surface what each in-flight worker task is doing so users can diagnose
stalls (issue #1001) and runaway LLM retry loops (#996) from logs alone,
without killing tasks and losing the forensic trail.

Adds four new periodic log lines (every 30s):

* [WORKER_STATS] now includes asyncpg pool stats (idle/in_use/waiters)
  and process RSS — pool exhaustion and unbounded memory growth are
  invisible without these.
* [WORKER_TASK] one line per in-flight task with op_id, type, bank,
  age, current stage, and stage age. Sorted oldest-first; tasks past
  5 min get a [STUCK?] prefix.
* [STUCK_STACK] async stack trace dumped once per doubling threshold
  (5/10/20/40 min...) so stuck tasks self-document without flooding.
* [DB_WAITS] pg_stat_activity snapshot of any non-idle Hindsight
  session waiting on a lock — catches the retain-pipeline deadlock
  case where the coroutine looks fine but is blocked on a Postgres lock.

Stage breadcrumbs are wired via a contextvar (worker/stage.py) at:

* memory_engine.execute_task — task.{type}
* retain/orchestrator phases — retain.phase1/2/3, retain.extract_and_embed
* llm_wrapper.call/call_with_tools — llm.{provider}.{scope}[+structured|+tools]
* per-attempt updates in openai_compatible (incl. _call_ollama_native),
  litellm, and gemini retry loops — llm.{provider}.{scope}.attempt=N/M

The attempt counter makes JSON-schema retry loops on small models
visible by stage name + stage age, instead of needing to bump log
level and grep for WARN lines.

set_stage is a no-op outside a worker context, so engine code is safe
to call from sync HTTP requests, tests, and the CLI without setup.

* fix(test-api): repair regressions from main merges

Three independent regressions surfaced in test-api after recent merges to
main; fix all of them so this PR's CI can pass.

1. apply_combined_scoring overwrote single-result scores

   #957 added passthrough-reranker detection via `len(ce_scores) <= 1`,
   which also triggers for n=1 candidate cases — corrupting any
   single-result rerank by replacing the real CE score with a rank-based
   value. It also misfired when multiple legitimate results happened to
   tie on score (common in tests with synthetic data).

   Replace the heuristic with an explicit `is_passthrough_reranker`
   parameter, set by the caller based on `cross_encoder.provider_name`.
   Fixes 13 tests across test_combined_scoring and test_reranking_proof_count.

2. tool_search_observations breaks when request_context is a MagicMock

   #972 added `replace(request_context, internal=True)` inside
   tool_search_observations to avoid double-billing internal recall calls.
   The existing test suite passes a MagicMock as request_context, which
   `dataclasses.replace` rejects.

   Update the test fixture to pass a real RequestContext dataclass.
   Fixes 4 tests in test_reflect_source_facts_config.

3. recall_id collisions cause "Operation already exists"

   recall_id was `f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}"` —
   two recalls on the same bank within the same millisecond collide,
   raising ValueError from budgeted_operation. This presented as flaky
   "Operation recall-... already exists" failures in test_consolidation
   and test_consolidation_failure_recovery.

   Append a uuid suffix so recall_id is guaranteed unique.

* fix: repair main-branch CI regressions blocking this PR

* test-embed: 3 tests in test_profile_daemon_config.py patched
  manager.is_running to True, but #1016 added pre-Popen is_running
  checks in _start_daemon and _start_daemon_locked that short-circuit
  on True, so Popen was never called and the env was never captured.
  Make is_running return False before Popen and True after via a
  popen_called flag, so both pre-Popen guards proceed and the
  post-Popen readiness loop breaks immediately. Patch time.sleep too
  to skip the 2s stability wait.

* test-openclaw-integration: package.json required hindsight-all@^0.1.0
  but the workspace ships 0.5.0, so npm ci refused. Bump the constraint
  to ^0.5.0 and regenerate package-lock.json.

* verify-generated-files: regenerate skills/hindsight-docs/references
  for mental-models.md and cli.md (drift on main, untouched by this PR).
2026-04-13 11:37:52 +02:00
Nicolò Boschi 0c4b79b6d3 chore(ci): guard against workspace-resolved deps in integration lockfiles (#1020)
The openclaw 0.6.0 release workflow failed at `npm run build` because
`hindsight-integrations/openclaw/package-lock.json` had
`@vectorize-io/hindsight-client` resolved as a workspace symlink
(`link: true`) instead of a registry URL. npm had silently preferred the
workspace over the declared registry version when `npm install` was
originally run from the monorepo root, even though openclaw isn't in
the root `workspaces` array. The release runner has no pre-built
workspace `dist/`, so tsc couldn't find the types and the publish never
happened. (The test CI job masked this because it explicitly pre-builds
workspace deps before `npm ci`.)

Add two guards so it can't recur:

1. `scripts/check-integration-lockfiles.sh` — scans every
   `hindsight-integrations/*/package-lock.json` and fails if any dep's
   `resolved` URL is empty, a `file:` URL, a relative path, or the entry
   is a `link: true` workspace symlink. Prints the exact fix (regenerate
   the lockfile from inside the integration directory, not the monorepo
   root).

2. `check-integration-lockfiles` job in `.github/workflows/test.yml` —
   runs the script on every PR that touches an integration lockfile or
   package.json. Gated on the new `integrations-lockfiles` detect-changes
   output. Added to `report-pr-status` needs list.

3. Inline `Check integration lockfile` step in `release-integration.yml`
   for the TypeScript branch — belt + suspenders in case a bad lockfile
   ever slips past PR gating.

Verified: regression-tested the script against the broken pre-release
lockfile from commit da21e072 and it correctly identifies
`node_modules/@vectorize-io/hindsight-client: (link=true — workspace
symlink)` and exits non-zero. On the current tree (post-fix) all 7
integration lockfiles pass.
2026-04-13 11:35:46 +02:00
Nicolò Boschi e9270fd312 fix(openclaw): resolve hindsight-* deps from the npm registry, not workspace
The release-integration.yml workflow failed at `tsc` with
  Cannot find module '@vectorize-io/hindsight-client' or its corresponding
  type declarations.

Root cause: the openclaw integration's package-lock.json had
@vectorize-io/hindsight-client resolved to ../../hindsight-clients/typescript
— the monorepo workspace path. That happened because an earlier
`npm install` was run from the monorepo root, where npm preferred the
workspace over the registry even though openclaw isn't itself listed in
the root workspaces array. Locally the build worked because the
workspace directory exists; in CI the workspace's `dist/` is gitignored
and not built before the release workflow's `npm ci`, so tsc couldn't
resolve the types.

Regenerated the lockfile from within the openclaw directory so npm
resolves @vectorize-io/hindsight-client (^0.5.0) and
@vectorize-io/hindsight-all (^0.1.0) directly from the npm registry. The
lockfile's `resolved` URLs now point at registry.npmjs.org.
2026-04-13 10:48:53 +02:00
Nicolò Boschi da21e0727c release(openclaw): v0.6.0 2026-04-13 10:43:57 +02:00
Nicolò Boschi d4b8b3544b fix(openclaw): ignore ctx.channelId when it is a provider name (#854) (#1018)
Some OpenClaw hook contexts populate `ctx.channelId` with the provider
name (e.g. "discord") instead of the actual channel ID, which short-
circuited the sessionKey fallback in `deriveBankId` and collapsed all
Discord channel memories into a single `main::discord` bank.

Add a `sanitizeChannelId` helper that treats `ctx.channelId` as missing
when it equals the provider or matches a known provider token, so the
parsed sessionKey channel is used instead. Apply it to both
`deriveBankId` and `buildRetainRequest` so `channel_id` metadata and
thread extraction also benefit.
2026-04-13 10:33:17 +02:00
Nicolò Boschi 873223964b feat(openclaw): interactive setup wizard with Cloud / API / Embedded modes (#1014)
* feat(openclaw): interactive setup wizard with Cloud / API / Embedded modes

Ship a new `hindsight-openclaw-setup` bin that walks users through picking a
mode and writes the corresponding plugin config into openclaw.json:

- Cloud — managed Hindsight (default URL + token SecretRef)
- External API — user's own running Hindsight (URL + optional token SecretRef)
- Embedded daemon — local hindsight-all daemon (LLM provider + key SecretRef)

Pure config manipulation (mode application, SecretRef construction, atomic
save/load) lives in src/setup-lib.ts and is covered by 21 unit tests. The
src/setup.ts CLI entry is a thin @clack/prompts wrapper on top.

Mode switches correctly clear stale fields from the opposite modes so a
user flipping between e.g. Cloud and Embedded doesn't end up with a mixed
configuration. All credentials are always written as env-backed SecretRef
objects, never plaintext.

Scanner-safe: neither setup.ts nor setup-lib.ts imports subprocess APIs or
reads environment variables, so the new files don't reintroduce the
dangerous-exec / env-harvesting findings that #974 just cleared.

* feat(openclaw): non-interactive setup flags + smoke test + CI

- setup.ts now accepts --mode cloud|api|embedded plus mode-specific flags
  (--api-url, --token-env, --no-token, --provider, --api-key-env, --model,
  --config-path) to skip the interactive TUI. Interactive remains the
  default when no --mode is given. main() is guarded by an isDirectRun()
  check so importing from tests does not trigger the wizard.

- src/setup.test.ts adds 23 unit tests covering every flag, invalid input
  (unknown flags, missing values, conflicting --token-env + --no-token,
  mode requirements) and the full non-interactive write path for each
  mode including cross-mode state cleanup.

- scripts/smoke-test.sh is a new end-to-end install smoke test:
  * packs a fresh tarball (or uses an existing one passed in argv[1])
  * installs via `openclaw plugins install <tarball>` WITHOUT
    --dangerously-force-unsafe-install — fails loudly if the scanner
    reports any findings
  * asserts workspace deps (@vectorize-io/hindsight-all, hindsight-client)
    resolved from the npm registry into the extension's node_modules
  * runs `hindsight-openclaw-setup` non-interactively for all 4 mode
    variants (cloud default URL, external API no-auth, embedded openai
    with model override, embedded claude-code no-key) and asserts
    `openclaw config validate` + `openclaw plugins doctor` pass after each
  * runs 3 negative tests to assert bad flag combinations fail fast
  * backs up and restores ~/.openclaw/openclaw.json around the run

- .github/workflows/test.yml adds a smoke-openclaw-install job on
  ubuntu-latest that installs the published `openclaw` CLI, rebuilds the
  workspace deps, and runs scripts/smoke-test.sh. Gated by the same
  detect-changes outputs as build-openclaw-integration and added to the
  report-pr-status needs list.

* chore(openclaw): point cloud mode at api.hindsight.vectorize.io, drop stale install.sh

- Replace the placeholder Hindsight Cloud URL with the real one,
  https://api.hindsight.vectorize.io, in setup-lib.ts and the three
  suites that hard-coded it (setup-lib.test.ts, setup.test.ts,
  scripts/smoke-test.sh).

- Delete hindsight-integrations/openclaw/install.sh. It predated
  `openclaw plugins install` and documented the pre-0.6.0 env-var flow
  ('export OPENAI_API_KEY', 'openclaw plugins enable'), which is
  superseded by the interactive/non-interactive hindsight-openclaw-setup
  wizard plus README quick start.

* fix(openclaw): smoke test — tolerate unrelated bundled-plugin diagnostics

In clean CI environments, `openclaw plugins doctor` can emit diagnostics
for bundled plugins (seen: "ollama: memory embedding provider already
registered") that have nothing to do with hindsight-openclaw. The
previous smoke-test check required the literal string "No plugin issues
detected" in doctor output, which treated those unrelated warnings as
failures.

Replace that check with two narrower ones: (a) `plugins doctor` must
exit zero, and (b) its output must not contain any line that mentions
hindsight together with fail/error/not-loaded. Unrelated bundled-plugin
warnings no longer fail the smoke test.

* docs(openclaw): document hindsight-openclaw-setup wizard

The plugin's own README was updated to lead with the setup wizard when
the feature landed, but the docs site page (docs-integrations/openclaw.md)
was still showing a Quick Start driven entirely by raw `openclaw config
set` commands. Update the Quick Start to mirror the README flow: install
the plugin, run `hindsight-openclaw-setup`, start the gateway. Include
the three modes (Cloud / External API / Embedded) and the non-interactive
--mode flag variants for CI.

Also add pointer notes at the top of the "LLM Configuration" and
"External API (Advanced)" sections so readers who arrived there directly
know the wizard already covers those paths.

Extend the 0.6.0 (Unreleased) changelog entry with the wizard under
**Features** and regenerate the skill mirror.

* fix(openclaw): resolve bin invocation when launched via npm symlink + doc the correct invocation

Two related problems found during end-to-end install testing:

1. `isDirectRun()` in setup.ts compared `process.argv[1]` against
   `fileURLToPath(import.meta.url)`. When the bin is invoked through
   `node_modules/.bin/hindsight-openclaw-setup` (an npm-created symlink
   into `dist/setup.js`), these two paths differ: argv[1] is the symlink
   and import.meta.url is the resolved target. The equality check failed,
   `main()` never ran, and the command silently exited with status 0 and
   no output. Canonicalize both via `realpathSync` before comparing —
   same approach the backfill bin already uses (`isDirectExecution` in
   src/backfill.ts).

2. `openclaw plugins install @vectorize-io/hindsight-openclaw` unpacks
   the plugin into ~/.openclaw/extensions/ but does not put its bins on
   $PATH, so the README/docs instruction `hindsight-openclaw-setup` was
   misleading — users would get "command not found". Update the Quick
   Start in both README.md and hindsight-docs/docs-integrations/openclaw.md
   to invoke the wizard via `npx --package @vectorize-io/hindsight-openclaw
   hindsight-openclaw-setup`, matching the existing invocation shown for
   the hindsight-openclaw-backfill bin.
2026-04-13 10:30:13 +02:00
Nicolò Boschi e5724fcba0 fix(embed): serialize daemon start and stop killing healthy daemons (#1016)
* fix(embed): serialize daemon start and stop killing healthy daemons

Two concurrent `hindsight-embed daemon start` calls used to kill each
other's freshly-started daemons: `_clear_port` unconditionally stopped
any hindsight daemon on the target port before spawning a new one, so
each caller detected the other's healthy daemon and SIGTERM'd it.

Two changes fix this at the source instead of requiring every
integration to serialize externally:

1. `_clear_port` no longer kills a *healthy* hindsight daemon. If
   /health returns 200, return True and reuse the existing daemon.
   Only reclaim the port when the listener is unhealthy (stale from a
   version upgrade or a crash), matching the original stated intent.

2. `_start_daemon` now holds an exclusive flock on the profile's lock
   file for the whole startup sequence, and re-checks `is_running()`
   inside the lock. Concurrent callers serialize on the flock; the
   waiter returns immediately once the winner's daemon is up. The
   post-_clear_port `is_running()` check also prevents spawning a
   second daemon if a foreign-started daemon showed up mid-flight.

Tests updated: two existing tests codified the old kill-on-healthy
behavior; they now assert the new reuse behavior. Added new tests for
unhealthy-daemon reclamation and for the serialization/double-check
paths.

* style(retain): reformat ann seeds sql calls onto single lines
2026-04-13 10:22:55 +02:00
Nicolò Boschi 848451bd01 docs(mental-models): clarify that tags filter refresh source memories (#1013)
Addresses #945 and the related confusion in #1004. The mental model
`tags` field acts as a hard `all_strict` filter on source memories
during refresh, but this wasn't obvious from the parameter tables
or the UI form — users hit empty refresh content while direct reflect
on the same query worked.

- Expand the `tags` parameter description in the mental-models API
  doc and mirror it in the skills reference.
- Add a warning callout in the "Tags and Visibility" section pointing
  users at backfill / trigger.tags_match / tag_groups workarounds.
- Add helper text under the Tags input (both Create and Edit forms)
  in the control plane mental-models view.
2026-04-13 09:51:19 +02:00
Nicolò Boschi f82f58fa83 fix(reranker): surface real import errors and fix transformers 5.x race in jina-mlx (#994)
* fix(reranker): surface real import errors and fix transformers 5.x race in jina-mlx

Two fixes for jina-mlx reranker startup on Apple Silicon (#994):

1. Pre-warm transformers.AutoTokenizer before importing mlx_lm. transformers 5.x
   uses _LazyModule and has an unguarded window where concurrent imports from
   another thread (e.g. local embeddings init in an executor) can cause
   `from transformers import AutoTokenizer` inside mlx_lm's tokenizer_utils to
   raise ImportError.

2. Narrow the `except ImportError` so unrelated transitive failures inside
   mlx_lm propagate verbatim with chained traceback. The previous bare except
   masked the real error with a misleading "install mlx" message even when
   mlx and mlx_lm were correctly installed.

* fix(tests): stub mlx modules for jina-mlx import test + sync link_utils lint format

- Stub mlx and mlx.core in sys.modules so test_initialize_surfaces_transitive_import_error
  works in CI environments where mlx is not installed (CI's import mlx.core was failing
  before the patched __import__ ever saw mlx_lm, hitting the install-hint branch).
- Apply the lint reformat to link_utils.py that lint.sh produces; verify-generated-files
  was failing because the committed file didn't match lint output.
2026-04-13 09:48:28 +02:00
Nicolò Boschi 2d74007d80 fix(worker): reserve consolidation slots within max_slots (#1006) (#1012)
Consolidation tasks were sharing the same slot pool as retain and could only
claim leftover slots. With a continuous retain queue, retains saturated
max_slots and consolidation was permanently starved.

Make consolidation_max_slots a true reservation: non-consolidation tasks may
use at most (max_slots - consolidation_max_slots) slots, leaving the remainder
always available for consolidation. Also inject operation_type on claimed
consolidation rows so in-flight tracking works (the JSON payload didn't carry
the field, so _in_flight_by_type["consolidation"] was never incremented).

Adds a regression test that submits 10 retains + 1 consolidation with
max_slots=5, consolidation_max_slots=2 and verifies retain caps at 3 while
consolidation still claims its slot. Existing retain-only saturation tests
updated to set consolidation_max_slots=0.

Docs clarify the reservation semantics in configuration.md.
2026-04-13 09:42:43 +02:00
Nicolò Boschi 05686e1236 docs: clarify audit logging is off by default (#944) (#1008)
* docs: clarify audit logging is off by default (#944)

Explains that /audit-logs returns empty until HINDSIGHT_API_AUDIT_LOG_ENABLED=true, which was the confusion reported in the issue.

* docs: regenerate skill mirror for audit logging section
2026-04-13 09:32:11 +02:00
Nicolò Boschi 93300b9104 fix(cli): surface HTTP response body in API errors (#1011)
Previously `hindsight memory retain/recall/reflect` errors rendered as
"Unexpected Response: Response { ... }" with no body, hiding the actual
validation detail (e.g. FastAPI's `{"detail": "..."}` payload). Users had
to fall back to `curl` to see why a request failed.

Adds a helper that unpacks progenitor's `ErrorResponse`,
`UnexpectedResponse`, and `InvalidResponsePayload` variants and includes
the response body in the error message.

Refs #1007.
2026-04-13 09:30:39 +02:00
Nicolò Boschi 9402572339 fix(embed): restore macOS FORCE_CPU default for local embeddings/reranker (#1010)
* fix(embed): restore macOS FORCE_CPU default for local embeddings/reranker

PR #933 (0.5.0) removed the unconditional macOS CPU-force block from
DaemonEmbedManager._start_daemon. The block was the actual mechanism
that reached the daemon subprocess env — the profile .env value written
by `hindsight-embed configure` does not propagate, because _start_daemon
only copies a whitelist of keys (llm_*, log_level, idle_timeout) into
the subprocess env.

Net effect on 0.5.0 + macOS Apple Silicon: sentence-transformers
auto-selects MPS, daemon init hangs, startup times out.

Restore the block so FORCE_CPU is set by default on Darwin, while still
honoring an explicit user override (e.g. FORCE_CPU=0 to opt into MPS).

Fixes #962

* fix(embed): propagate all HINDSIGHT_* keys from profile config to daemon env

The daemon env builder only copied a whitelist of keys (llm_*, log_level,
idle_timeout) from the merged profile config. Any other HINDSIGHT_* key
written to the profile's .env — e.g. HINDSIGHT_API_EMBEDDINGS_PROVIDER,
HINDSIGHT_API_EMBEDDINGS_TEI_URL, or the FORCE_CPU flags on non-macOS —
was silently dropped when spawning the daemon subprocess.

Pass the full set of HINDSIGHT_* keys through after the whitelist loop,
so profile-level settings actually reach the daemon.
2026-04-13 09:27:34 +02:00
PaulKnag e9cc771bbd fix(recall): use async generate_embeddings_batch for query embedding (#999)
The recall hot path in _search_with_retries calls
embedding_utils.generate_embedding() synchronously, which runs
sentence-transformers GPU inference on the asyncio event loop thread.
This blocks /health and all concurrent requests for the duration of
each embedding call. Under consolidation load (WorkerPoller runs
in-process with 2 concurrent slots), stacked sync embedding calls
cause /health to exceed watchdog timeouts and trigger destructive
service restarts.

Replace the single sync generate_embedding() call with the async
generate_embeddings_batch() wrapper that already exists in the same
codebase and is used correctly at 3 other call sites in this file
(lines 5469, 6655, 6877). The batch wrapper offloads GPU inference
to a thread pool via run_in_executor, keeping the event loop free.

This was the only remaining sync embedding call in memory_engine.py.
2026-04-13 09:12:02 +02:00
Octopusandocto-patch 2a2b90b0a0 test(config): add regression test for entity_labels format validation (fixes #946) (#1005)
Previously, PATCH /v1/default/banks/{id}/config accepted malformed
entity_labels (e.g. plain strings instead of LabelGroup dicts) with
HTTP 200, then failed with a 500 on the next retain call. The fix in
PR #902 added validation to config_resolver.update_bank_config, but
no regression test was added to prevent a future regression.

This commit adds a focused test that:
- Asserts that a string list (["person", "client"]) raises ValueError
  with "Invalid entity_labels format" rather than being silently stored
- Asserts that a correctly shaped LabelGroup list succeeds

Co-authored-by: octo-patch <[email protected]>
2026-04-13 09:08:34 +02:00
r266-tech 2635bbb49e fix(cli): memory list shows [UNKNOWN] for all fact types (#998)
* fix(cli): read fact_type key in memory list/get pretty output

The API response uses the key 'fact_type' but the CLI formatter reads
'type', causing every memory to display as [UNKNOWN]. Also fixes the
serde rename on MemoryUnitDetail and adds 'observation' match arm.

* fix(cli): add observation and experience match arms to print_fact gradient
2026-04-13 09:07:08 +02:00
r266-tech 2e88bac605 test(reflect): regression test for internal billing in sub-recalls (#972) (#989)
PR #972 fixed double-billing by marking reflect's internal recall calls
as internal=True. Add 4 focused tests to prevent regression:

- search_observations passes internal=True to recall_async
- tool_recall passes internal=True to recall_async
- Neither function mutates the original request context

Fixes #988
2026-04-13 09:04:05 +02:00
r266-tech 2644930561 docs(cli): document webhook, audit, operation, and memory history subcommands (#983)
PR #968 added full OpenAPI endpoint coverage (46/62 → 62/62) but
cli.md was not updated. Add sections for:

- Webhook management (list/create/update/delete/deliveries)
- Audit logs (list with action/transport/date filters)
- Operation management (list/get/cancel/retry)
- Memory history and clear-observations
- Document update
- Bank set-disposition and consolidation-recover
- New flags on recall (--tags, --query-timestamp) and reflect (--fact-types)

Fixes #982
2026-04-13 09:01:53 +02:00
ooa-andera bbd3c5dc04 docs: add ContextForge MCP gateway integration (#961)
Add ContextForge as a community integration. ContextForge (IBM) is an
open-source MCP gateway that aggregates multiple MCP servers behind a
single authenticated endpoint.

This integration registers Hindsight's built-in /mcp endpoint as a
gateway backend in ContextForge, giving every connected AI tool (Dust,
Claude Desktop, custom agents) access to retain, recall, and reflect
tools through a unified MCP hub.

- Add integration entry to integrations.json (community, mcp category)
- Add docs page with setup guide (UI, API, Helm auto-registration)
- Add sidebar link

Tested end-to-end locally: ContextForge discovers all 30 Hindsight MCP
tools and can execute them through the gateway.
2026-04-13 09:00:38 +02:00
akhaterandakhater 4f9cf15cdd fix(recall): preserve RRF ranking when reranker is a passthrough (#957)
The slim deployment default (`reranker_provider=rrf`,
`RRFPassthroughCrossEncoder`) returns a constant 0.5 score for every
candidate. After sigmoid normalisation that becomes a constant
`cross_encoder_score_normalized` across all candidates, so the
multiplicative recency / temporal / proof_count boosts inside
`apply_combined_scoring` become the *only* ranking signal.

For non-temporal queries on `world` facts the temporal and proof_count
boosts collapse to 1.0, leaving `recency_boost` alone. The final
ordering is then a pure newest-first sort, regardless of how relevant a
candidate is to the query — and `rrf_normalized` is explicitly set to
0.0 a few lines above, so the upstream RRF rank is discarded entirely.

In practice this means any biographical / historical / long-tail world
fact (anything with an old `occurred_start`) is guaranteed to lose to a
recent fact in the candidate set, even when RRF, BM25, semantic search
*and* graph traversal all agree it should be the top result.

## Repro

A `world` fact with `occurred_start` ~30 years in the past, indexed
alongside a few thousand recent observations and world facts in the
same bank, is correctly identified as the top match by every retrieval
arm:

```
semantic   (world): 1000 items | target rank 1
bm25       (world): 1000 items | target rank 1
graph      (world):  346 items | target visited
RRF merged       :  1673 items | target rank 1
```

After reranking with the passthrough cross-encoder it lands at rank 80,
and the token-budget filter then drops it from the response entirely.
The same pattern reproduces for every query phrasing tested (short,
long, with and without entity names).

## Fix

Detect the degenerate-CE case in `apply_combined_scoring` and seed
`cross_encoder_score_normalized` from the RRF rank before the boosts
are applied. The boosts then modulate a meaningful base instead of
replacing it.

- No-op for real cross-encoders (`flashrank`, `local`, `cohere`,
  `litellm`, …) — those produce diverse scores so the `len(set(...)) <= 1`
  guard never triggers.
- No schema, embedding, or API changes.
- Recency / temporal / proof_count boosts are still applied on top, so
  ranking ties between adjacent RRF candidates can still be broken by
  the secondary signals.

## After fix

Same database, same queries, target fact moves from "dropped from
response" to a stable top-10 position across every query variation
tested.

Co-authored-by: akhater <[email protected]>
2026-04-13 08:59:48 +02:00
Nicolò Boschi 2d95f78b09 fix(retain): make chunk insert idempotent and stop retrying integrity errors (#986)
Two related fixes for retain re-submission failures:

1. store_chunks_batch now upserts via ON CONFLICT (chunk_id) DO UPDATE.
   Re-submitting a retain under the same document_id (the pattern in #977)
   previously failed with UniqueViolationError on pk_chunks when any
   upstream path — cascade-delete on is_first_batch, delta-retain chunk
   diff, concurrent worker tasks — didn't clean up before the insert.
   Overwriting is the correct semantics for document_id as a grouping key.

2. MemoryEngine.execute_task now classifies asyncpg
   IntegrityConstraintViolationError subclasses as non-retryable (#980).
   Previously the poller retried them ~3 times over ~3 minutes, burning
   worker capacity on a deterministic error that will never succeed.

Fixes vectorize-io/hindsight#977, vectorize-io/hindsight#980
2026-04-13 08:58:39 +02:00
Nicolò Boschi 773ef0cb63 test(cloudflare-oauth-proxy): add tests, CI, and security hardening (#975)
Follow-up to #922. The initial PR was merged without the tests, CI
job, or release-script entry that CLAUDE.md mandates for new
integrations, and the source had a handful of code-quality issues
flagged in review.

Testing & CI
- Split src/index.ts into env/html/cors/proxy/auth/router modules so
  each unit can be exercised in plain Node without the Workers runtime
- Add 50 vitest tests covering html escaping, CORS application /
  stripping, the /authorize GET+POST flow with a mocked OAuth provider,
  the MCP proxy's header sanitisation, and the outer router's
  preflight + metadata hardening
- Add tsconfig.json, vitest.config.ts, typecheck+test scripts, and a
  test-cloudflare-oauth-proxy-integration job wired into detect-changes
  and report-pr-status
- Add cloudflare-oauth-proxy to VALID_INTEGRATIONS

Hardening
- Remove `any` types; introduce an explicit OAuthHelpers interface
- Replace the plain `!==` password check with a constant-time
  SHA-256-based comparison
- Drop the PII (email) log line from the MCP proxy
- CORS: list explicit methods instead of `*`, include `Mcp-Session-Id`
  in Allow-Headers, emit `Vary: Origin`
- Proxy: strip client Authorization + X-Proxy-Secret + hop-by-hop
  headers, filter upstream response headers through an allowlist
  (drops Set-Cookie and upstream CORS), buffer request body to avoid
  needing `duplex: "half"`
- Override OAuth metadata to advertise S256 only
- README: align PKCE wording with reality and document the single-user
  threat model; wrangler.toml defaults to workers_dev=false
2026-04-13 08:58:24 +02:00
Nicolò Boschi 7b2263ba3b fix(llm): send max_completion_tokens for reasoning models and Azure OpenAI (#979)
PR #858 made the openai provider fall back to max_tokens whenever a custom
base_url was set, to support Mistral/Together-style endpoints. This regressed
two important setups:

1. Reasoning models (GPT-5, o1, o3) reject max_tokens outright with a 400
   ("Unsupported parameter: 'max_tokens' is not supported with this model.
   Use 'max_completion_tokens' instead.").
2. Azure OpenAI is fully OpenAI-API-compatible — it was only classified as
   "third-party compatible" because it requires a custom base_url.

The combination of the two — Azure OpenAI + GPT-5 — is the exact setup the
reporter hit in issue #978 and fails connection verification on startup.

Fix _max_tokens_param_name() so it:

- Always returns max_completion_tokens for reasoning models, regardless of
  base_url (they only support the new parameter name).
- Detects Azure OpenAI endpoints by the *.openai.azure.com hostname and
  treats them as native OpenAI.

The Mistral/Together behavior from #858 is preserved for non-reasoning
models on non-Azure custom base URLs.

Fixes #978
2026-04-13 08:56:16 +02:00
r266-techandr266-tech d054b88403 fix: add PEP 561 py.typed marker to all Python packages (#973)
* fix: add PEP 561 py.typed marker to all Python packages

Add empty py.typed marker files to all 13 Python packages that were
missing them. Only hindsight-integrations/autogen already had one.

Per PEP 561, packages that wish to support type checking must include
a py.typed marker file. Without it, type checkers (mypy, pyright) treat
the package as untyped and skip all inline type annotations.

Fixes #965

* fix: ensure py.typed markers survive client regeneration

Add touch commands in generate-clients.sh to recreate PEP 561 py.typed
marker files after the OpenAPI generator runs, since the script deletes
and regenerates the hindsight_client_api directory.

---------

Co-authored-by: r266-tech <[email protected]>
2026-04-10 23:24:46 +02:00
Ben 1c32a7b928 blog: Hindsight 0.5.0 Templates Hub (#971)
* blog: add Templates Hub deep-dive post for Hindsight 0.5.0
2026-04-10 15:42:46 -04:00
Chris Bartholomew d38ecdb9ec fix(billing): mark reflect's internal recall calls as internal (#972)
Reflect's tool functions (tool_search_observations, tool_recall) call
recall_async with the user's original request_context, which has
internal=False. The usage metering extension sees these as user-facing
recall operations and bills them separately — double-charging the
customer for recalls that are already included in the reflect operation
cost.

Fix: wrap request_context with dataclasses.replace(internal=True) before
passing to recall_async. This matches the pattern used by consolidation,
which already creates an internal RequestContext for its sub-operations.

The internal flag causes the metering extension to:
- Record the usage as "internal_recall" (tracked but not billed)
- Skip credit deduction entirely

Observed impact: a single reflect call was generating 2 extra billed
recall entries (one from tool_search_observations, one from tool_recall),
inflating the customer's recall token count by ~26 tokens per reflect.
2026-04-10 14:45:20 -04:00
404sand808sandClaude Opus 4.6 aad07a141b Add Cloudflare OAuth proxy integration for self-hosted Hindsight (#922)
Adds an OAuth 2.1 proxy Worker that connects cloud MCP clients
(claude.ai, Claude Code, Codex) to a self-hosted Hindsight instance
via Cloudflare Workers and Tunnel.

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-10 18:52:19 +02:00
Chris Bartholomew 3fc87e767c fix(retain): run _ann_seeds temp table inside a transaction (#954)
compute_semantic_links_ann created a TEMP TABLE outside any transaction,
then ran a TRUNCATE / COPY / SELECT / DROP sequence as separate statements
on the same asyncpg connection. This is fine against a direct Postgres
connection but fails intermittently when the caller is routed through
PgBouncer in transaction pool mode:

  CREATE TEMP TABLE IF NOT EXISTS _ann_seeds (...)   -- backend A
  TRUNCATE _ann_seeds                                 -- backend B -> FAILS

Temp tables are session-scoped to the backend that created them. In
PgBouncer transaction mode the backend is only pinned to the client for
the duration of an actual transaction, so between standalone statements
the pooler can (and under concurrency, will) rebind the client to a
different backend. When that happens the _ann_seeds table disappears
and the follow-up statement fails with:

  relation "_ann_seeds" does not exist

Symptom: ~3% of sync retain calls (2 of 61) failed the Hindsight Cloud
smoke test on a recent hindsight-dev deploy. Async retains are masked
by the 3-attempt retry loop so they usually eventually succeed.

Fix: wrap the CREATE TEMP TABLE -> COPY -> SELECT sequence in a single
`async with conn.transaction():` block, and use ON COMMIT DROP so the
temp table is transaction-scoped and auto-cleaned at commit. Also
switch `SET hnsw.ef_search = 60` to `SET LOCAL` so the tuning is
transaction-scoped and no longer leaks onto the pooled backend for
subsequent recall queries. Drop the now-unnecessary manual TRUNCATE,
explicit DROP TABLE, and RESET hnsw.ef_search.

The function docstring still correctly describes this as running on a
separate connection outside the surrounding write transaction — this
change only adds an inner transaction around the ANN work itself to
keep the temp table visible to PgBouncer.

Tests:
- Add TestComputeSemanticLinksAnnPgBouncerSafety with 5 regression
  tests using a mocked connection. These are structural asserts — they
  check that the function enters conn.transaction(), uses ON COMMIT DROP,
  uses SET LOCAL, and does not reintroduce manual TRUNCATE / DROP /
  RESET calls. They would have caught the original bug if they had
  existed, and will catch any future reversion.
2026-04-10 18:36:03 +02:00
Nicolò Boschi e22ae05f47 refactor(openclaw)!: read config from plugin config instead of process.env (#974)
* refactor(openclaw)!: read config from plugin config instead of process.env

The plugin loaded credentials and runtime settings from environment
variables (HINDSIGHT_API_LLM_*, HINDSIGHT_EMBED_API_*, HINDSIGHT_BANK_ID)
plus auto-detection of OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY
/ GROQ_API_KEY. That tripped OpenClaw's install-scanner env-harvesting
rule and bypassed the framework's first-class SecretRef resolution.
Switch to reading from the plugin config exclusively, with secrets
configured via 'openclaw config set ... --ref-source env|file|exec'.

Combined with the daemon lifecycle extraction in #949, this closes the
remaining install-scanner findings the 0.5.x plugin was hitting. The
plugin source now contains neither process.env nor child_process; the
former moved to plugin config (resolved by OpenClaw before the plugin
loads), and the latter lives in @vectorize-io/hindsight-all under
node_modules where the scanner's directory walker skips it. The plugin
can be installed without --dangerously-force-unsafe-install.

BREAKING CHANGE: drops the llmApiKeyEnv plugin config field along with
the HINDSIGHT_API_LLM_*, HINDSIGHT_EMBED_API_*, and HINDSIGHT_BANK_ID
environment variables. Users must now configure llmProvider and
llmApiKey explicitly via 'openclaw config set'. Migration guide is in
hindsight-docs/docs-integrations/openclaw.md and the integration
changelog.

* chore(openclaw): pin published versions of hindsight-all and hindsight-client

Phase 2 (#949) introduced @vectorize-io/hindsight-all and
@vectorize-io/hindsight-client as plugin dependencies using 'file:'
workspace paths. Those paths resolve inside the monorepo but break when
the published tarball is installed outside it — 'openclaw plugins
install @vectorize-io/hindsight-openclaw' failed with 'Cannot find
module @vectorize-io/hindsight-all' because npm could not resolve the
file: path from the extracted extension directory.

Replace both with semver ranges targeting the published versions:

  @vectorize-io/hindsight-all   ^0.1.0
  @vectorize-io/hindsight-client ^0.5.0

Verified end-to-end: 'openclaw plugins install <local-tarball>' now
succeeds without --dangerously-force-unsafe-install and without the
workspace-symlink hack. npm pulls both dependencies from the registry
into the extracted extension's node_modules, the plugin loads cleanly,
and 'openclaw plugins doctor' reports no issues.
2026-04-10 18:27:28 +02:00
Ben b57e337fa2 feat(opencode): add recallTags and recallTagsMatch config options (#969) 2026-04-10 17:14:59 +02:00
Nicolò Boschi c05c491d77 feat(cli): cover every OpenAPI endpoint and request-body param (#968)
Wires the Rust CLI up to every endpoint exposed by the Hindsight OpenAPI
spec and adds CI enforcement so new endpoints or new request-body fields
cannot slip in without matching CLI coverage.

Endpoints
- New `hindsight webhook {list,create,update,delete,deliveries}` and
  `hindsight audit {list,stats}` subcommands.
- `hindsight bank` gains `set-disposition`, `consolidation-recover`,
  `export-template`, `import-template`, `template-schema`.
- `hindsight memory` gains `history` and per-memory `clear-observations`.
- `hindsight document update`, `hindsight operation retry` added.
- Brings CLI coverage from 46/62 to 62/62 operations.

Request-body parameters
- Expose missing flags that the CLI was silently hardcoding: directive
  `--priority`; mental-model `--tags` / `--max-tokens` /
  `--trigger-refresh-after-consolidation`; recall `--query-timestamp`;
  reflect `--fact-types` / `--exclude-mental-models` /
  `--exclude-mental-model-ids`; retain `--document-tags`.

CI enforcement
- New `cli-coverage-check` entry point in `hindsight-dev` parses
  openapi.json and verifies that (a) every operationId is called from
  hindsight-cli/src/ (the progenitor client method names match the
  operationId), and (b) every request-body property is present in
  main.rs as a clap field or `long = "..."` attribute.
- Intentional non-exposures live in `hindsight-cli/.openapi-coverage.toml`
  under `[skip]` / `[fields.<op>]` with a reason each (38 documented
  field skips for flattened structs, nested structs, or fields surfaced
  via a different subcommand).
- New `check-cli-coverage` job in .github/workflows/test.yml, triggered
  on cli/core/dev/ci path changes, runs the script on every PR.
- smoke-test.sh exercises the new webhook / audit / bank-template /
  set-disposition / consolidation-recover commands.
2026-04-10 16:44:56 +02:00
Nicolò Boschi fc941d5cae feat: add HINDSIGHT_API_DEFAULT_BANK_TEMPLATE env var (#966)
* feat: add HINDSIGHT_API_DEFAULT_BANK_TEMPLATE env var

Server-level default bank template applied automatically to every
newly-created bank. Holds an inline JSON BankTemplateManifest with the
same shape as the /import endpoint body. Fields set by the template
become per-bank overrides so they take precedence over equivalent
HINDSIGHT_API_* env defaults. The template is applied once on first
creation and never reapplied, so user overrides via PATCH /config are
never clobbered. Malformed manifests are logged and ignored so a broken
server-level setting cannot wedge bank creation.

* chore: regenerate docs skill

* test: update async_retain test mock for renamed bank_profile helper
2026-04-10 16:41:28 +02:00
Nicolò Boschi 576016f5dc feat: add @vectorize-io/hindsight-all daemon lifecycle package (#949)
* feat: add @vectorize-io/hindsight-embed daemon lifecycle package

Create a new top-level `hindsight-embed-npm/` package that owns the daemon
lifecycle for the Python `hindsight-embed` CLI: spawning via `uvx`, writing
the profile, waiting for `/health`, and shutting down. Nothing more.

Deliberately does not ship an HTTP client — `@vectorize-io/hindsight-client`
already covers retain / recall / reflect / createBank against the Hindsight
API, and the two packages compose: once `manager.start()` returns, consumers
talk to the daemon via `new HindsightClient({ baseUrl: manager.getBaseUrl() })`.

`HindsightEmbedManagerOptions.env` forwards an arbitrary `Record<string,
string>` to both the daemon process and the profile config via `--env K=V`,
and `extraProfileCreateArgs` / `extraDaemonStartArgs` escape hatches cover
any new CLI flag without waiting for a wrapper release.

Refactor `hindsight-integrations/openclaw` to consume both packages:
`HindsightEmbedManager` for daemon lifecycle in local mode, `HindsightClient`
for all HTTP memory operations. Drop the bespoke subprocess/HTTP client that
used to live in openclaw. The retain queue stays local to openclaw (it's a
client-side reliability workaround with a single consumer today — will move
to the client package or server-side when a second consumer needs it).

Wire the new package into the main release pipeline (versioned alongside
the other core packages, published from `v*` tags) and add a CI build job.

* docs: add Embedded Node.js SDK page for @vectorize-io/hindsight-embed

* refactor: rename hindsight-embed-npm to hindsight-all, restructure docs sidebar

The Node package previously named @vectorize-io/hindsight-embed was
semantically misnamed: hindsight-embed (Python) is a CLI tool, while what
this Node package actually provides is the Node equivalent of hindsight-all
— a programmatic lifecycle manager for a local Hindsight daemon. Rename to
match.

Package rename
  - hindsight-embed-npm/ → hindsight-all-npm/ (git mv, history preserved)
  - @vectorize-io/hindsight-embed → @vectorize-io/hindsight-all
  - class HindsightEmbedManager → HindsightServer (matches Python hindsight-all)
  - HindsightEmbedManagerOptions → HindsightServerOptions
  - src/manager.ts → src/server.ts, src/manager.test.ts → src/server.test.ts
  - openclaw (index.ts, backfill.ts, tests) and the claude-code Python port
    updated to reference the new names

Docs restructure
  - Split sdks/python.md: now client-only content. New sdks/hindsight-all.md
    covers the programmatic hindsight-all Python package (HindsightServer and
    HindsightEmbedded).
  - Rename sdks/embed-npm.md → sdks/hindsight-all-npm.md with HindsightServer
    examples.
  - New "Installation" sidebar section, placed after Hosting, containing
    Docker / Kubernetes / Bare Metal (anchor links into developer/installation)
    plus Programmatic API (Python), Programmatic API (Node.js), and Daemon CLI.
  - Add si-docker, si-kubernetes, si-nodedotjs, lu-hard-drive to the sidebar
    ICON_MAP.

Docs dev-server fix
  - docusaurus.config.ts: drop the flaky NODE_ENV sniff for including the
    "Next" version. Use INCLUDE_CURRENT_VERSION exclusively. NODE_ENV was
    unreliable across hot-reload paths and caused the Next version to
    disappear intermittently when editing files.
  - scripts/dev/start-docs.sh: export INCLUDE_CURRENT_VERSION=true so local
    dev always shows Next; production builds leave it unset.

Lockfile cleanup
  - package-lock.json and hindsight-integrations/openclaw/package-lock.json
    had extraneous hindsight-embed-npm blocks left over from the rename.
    Removed manually and verified with npm install.

* ci: fix openclaw jobs by pre-building workspace deps; regenerate docs-skill

The build-openclaw-integration and test-openclaw-integration jobs failed
with "Failed to resolve entry for package @vectorize-io/hindsight-all"
because openclaw depends on two monorepo workspaces via `file:` deps
(@vectorize-io/hindsight-client and @vectorize-io/hindsight-all) whose
`dist/` directories are gitignored and never built before openclaw's npm ci.
Both jobs now install the root workspace and build the two deps first,
mirroring the release-control-plane pattern.

Also regenerate skills/hindsight-docs/references/* via
./scripts/generate-docs-skill.sh:
  - new skill pages for sdks/hindsight-all{.md,-npm.md}
  - updated skill pages for sdks/embed.md and sdks/python.md to match
    the new H1s and split content
  - incidental refreshes to changelog/index.md, developer/models.md,
    openapi.json, and uv.lock that verify-generated-files picked up

* ci: build openclaw before running tests so symlink test can realpath dist
2026-04-10 15:51:44 +02:00
r266-tech b3995d1430 docs: document update_mode parameter in retain API (#959)
PR #932 added update_mode (replace/append) to retain items but
did not update the docs. Add a section explaining the parameter,
when to use append mode, and a JSON example.

Closes #957
2026-04-10 10:22:51 +02:00
Ben f519fc4fd0 blog: Agno Persistent Memory (#951)
* blog: add Agno persistent memory post
2026-04-09 14:27:08 -04:00
YUAN TIANJIANandNicolò Boschi 72fd3d59db feat(openclaw): add config-aware history backfill CLI (#878)
* Add OpenClaw history backfill CLI

* Fix backfill resume and local daemon behavior

* Fix backfill checkpoint finalization semantics

* Fix symlinked backfill CLI entrypoint detection

* fix(ci): skip PR status write for fork approvals

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-04-09 10:44:19 +02:00
5a61ac50e9 feat(openclaw): add session pattern filtering for ignore and stateless sessions (#909)
* feat(openclaw): add session pattern filtering for ignore and stateless sessions

Adds three new config options to the OpenClaw plugin that allow filtering
sessions by key pattern before recall and retain operations fire:

- `ignoreSessionPatterns`: glob patterns for sessions to skip entirely
  (no recall, no retain). Useful for cron/scheduled agent sessions.
- `statelessSessionPatterns`: glob patterns for read-only sessions —
  retain is always skipped; recall is also skipped when
  `skipStatelessSessions` is true (default).
- `skipStatelessSessions`: boolean (default: true). When false, sessions
  matching statelessSessionPatterns can still recall but never retain.

Pattern syntax mirrors lossless-claw: `*` matches non-colon characters,
`**` matches anything including colons. Session keys follow the OpenClaw
format `agent:<agentId>:<type>:<uuid>`.

Example config:
  ignoreSessionPatterns:    ["agent:*:cron:**"]
  statelessSessionPatterns: ["agent:*:subagent:**", "agent:*💓**"]
  skipStatelessSessions:    true

Implementation:
- New `session-patterns.ts` module with compile/match utilities
- Session filter applied in `before_prompt_build` and `agent_end` hooks
  immediately after the existing `excludeProviders` check
- New fields wired through `getPluginConfig`
- Schema added to `openclaw.plugin.json` (additionalProperties: false
  was already set, causing config validation errors without this)
- 11 unit tests in `session-patterns.test.ts`
- 5 integration tests added to `hooks.integration.test.ts`

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* test(openclaw): support HINDSIGHT_API_TOKEN in integration tests

Pass HINDSIGHT_API_TOKEN env var through to HindsightClient and plugin
config in integration tests so tests work against authenticated APIs.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* docs(openclaw): document session pattern filtering options

Add ignoreSessionPatterns, statelessSessionPatterns, and skipStatelessSessions
to the README config table with glob syntax reference and usage examples.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Marco Rutsch <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-04-09 10:43:35 +02:00
1f1716bdb0 feat(openclaw): add resilient startup and richer retain metadata (#942)
* feat(openclaw): enrich retain metadata and ignore heartbeat by default

* docs(openclaw): move retain metadata note out of config table

* fix(openclaw): make hook registration runtime-idempotent

* fix(openclaw): lazily initialize when service start is skipped

---------

Co-authored-by: Aldous <[email protected]>
Co-authored-by: Josh <[email protected]>
Co-authored-by: Aldous the Orchestrator <[email protected]>
2026-04-09 10:43:08 +02:00
Nicolò Boschi 61a8014f9d docs: 0.5.0 release notes, changelog, and blog post (#907)
* docs: add 0.5.0 release notes and changelog

* docs: include all commits since v0.4.22 and add recall perf to blog

* docs: include all commits since v0.4.22 and add recall perf to blog

* docs: add openrouter default model to provider table

* docs: reorder blog sections, fix code snippets, remove paperclip

* docs: add hermes integration docs link

* docs: fix broken anchor in blog post TOC
2026-04-08 18:45:20 +02:00
Nicolò Boschi c5091d29cd fix(deps): pin greenlet<3.4.0 — missing arm64 wheels in 3.4.0 2026-04-08 18:43:42 +02:00
Nicolò Boschi e82bc56580 fix(docker): constrain greenlet<3.4.0 for arm64 Docker builds
greenlet 3.4.0 lacks manylinux_2_41_aarch64 wheels. Use a UV_CONSTRAINT
file instead of the workspace lock file (which doesn't work in the
single-package Docker context).
2026-04-08 18:34:05 +02:00
Nicolò Boschi fa0e63b088 fix(docker): copy uv.lock into build context to pin greenlet version
Without the lock file, uv sync resolves fresh and picks up greenlet
3.4.0 which lacks arm64 wheels for manylinux_2_41, breaking the
multi-arch Docker build.
2026-04-08 18:21:28 +02:00
Nicolò Boschi 27cb7e43e0 Release v0.5.0
- Update version to 0.5.0 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Create documentation version-0.5
2026-04-08 17:56:47 +02:00
Ben 9e23e83abf Add Codex persistent memory blog post (#812)
* Add Codex persistent memory blog post
2026-04-08 10:44:27 -04:00
Nicolò Boschi bdf93f0660 fix: exclude local-llm from [all] extra, add as opt-in to hindsight-all (#936)
* fix: exclude local-llm from [all] extra to avoid heavy llama-cpp-python dep

local-llm (llama-cpp-python) requires C++ compilation and is only needed
for the built-in llamacpp provider. Keep it as a separate opt-in:
pip install 'hindsight-api-slim[local-llm]'

* feat: add local-llm optional extra to hindsight-all

Allows: pip install 'hindsight-all[local-llm]' to get built-in llamacpp support.

* chore: regenerate uv.lock from workspace root
2026-04-08 16:06:26 +02:00
AldousandAldous the Orchestrator b0e8ac0f4d feat(openclaw): add configurable retain tags (#937)
Co-authored-by: Aldous the Orchestrator <[email protected]>
2026-04-08 15:52:32 +02:00
Nicolò Boschi f74b577e02 feat: add built-in llama.cpp LLM provider for local inference (#933)
* feat: add built-in llama.cpp LLM provider for fully local inference

Add `llamacpp` as a new LLM provider that manages a llama-cpp-python server
subprocess. Auto-downloads Gemma 4 E2B Q4_K_M (~3.5 GB) on first use and
runs inference locally via Metal/CUDA with no external services needed.

- New provider: `HINDSIGHT_API_LLM_PROVIDER=llamacpp`
- Singleton server shared across retain/reflect/consolidation
- Configurable: model path, GPU layers, context size, grammar enforcement
- User-extensible via `HINDSIGHT_API_LLAMACPP_EXTRA_ARGS`
- Flash attention + prompt caching enabled by default
- LLM provider cleanup on shutdown (stops subprocess)
- hindsight-embed: `--ui` flag on `daemon start`, removed FORCE_CPU on macOS
- Docs: configuration.md, models.mdx, providers grid updated

* chore: regenerate docs skill and update lockfile for local-llm dep
2026-04-08 15:22:10 +02:00
Nicolò Boschi 3c633e5e16 feat: add retain update_mode='append' for document content concatenation (#932)
* feat: add update_mode='append' for retain to concatenate content to existing documents

When retaining with update_mode='append' and a document_id that already exists,
the new content is appended to the existing document text and the full document
is reprocessed. Delta retain automatically skips unchanged chunks, so only the
new content triggers LLM extraction.

- Add update_mode field to MemoryItem (API), RetainContentDict (internal), MCP tools
- Validate that update_mode='append' requires a document_id
- Fetch existing document content and prepend before processing in orchestrator
- Update Python, TypeScript, Go generated clients and top-level client wrappers
- Add tests for append, multiple appends, no-existing-doc, validation, and default replace

* fix: add update_mode field to Rust CLI and client MemoryItem initializers

* chore: regenerate docs skill references for update_mode
2026-04-08 14:39:16 +02:00
Nicolò Boschi cf0537ba7e chore: drop hindsight-hermes integration (#931)
* chore: drop hindsight-hermes integration in favor of native Hermes memory provider

Hermes Agent now ships with a native Hindsight memory provider (NousResearch/hermes-agent#5094),
making our pip-installable hindsight-hermes package redundant.

Removes:
- hindsight-integrations/hermes/ (source, tests, config)
- CI job, release script entry, changelog generator references
- Cookbook page and pip package changelog (referenced deleted code)

Keeps:
- Integration docs (updated by #881 for native provider)
- Blog posts (historical, already have deprecation notices)
- Sidebar/banner entries (still valid for native integration)

* fix(docs): remove broken cookbook link to deleted hermes-memory page
2026-04-08 11:59:35 +02:00
Nicolò Boschi e5944b63e7 feat: add OpenRouter support for LLM, embeddings, and reranking (#930)
* docs: add best practice for filtering recall by memory shape (#856)

Add guidance on using entity labels with `tag: true` to deterministically
filter recall results when a bank contains different memory shapes
(e.g., concise rules vs. detailed procedures).

* feat: add OpenRouter support for LLM, embeddings, and reranking

OpenRouter is OpenAI-compatible for chat/embeddings and Cohere-compatible
for reranking, so no new provider classes are needed.

- LLM: added as OpenAICompatibleLLM provider (default model: qwen/qwen3.5-9b)
- Embeddings: reuses OpenAIEmbeddings with OpenRouter base URL (default: perplexity/pplx-embed-v1-0.6b)
- Reranker: reuses CohereCrossEncoder with OpenRouter rerank endpoint (default: cohere/rerank-v3.5)
- API key fallback chain: dedicated key → shared OPENROUTER_API_KEY → LLM_API_KEY

* chore: regenerate docs skill references and fix formatting
2026-04-08 11:24:21 +02:00
Nicolò Boschi 37348c859e feat: include occurred_end and mentioned_at in think-prompt fact serialization (#929)
Extend format_facts_for_prompt() to include occurred_end and mentioned_at
temporal fields (when non-null), matching the MemoryFact model. Also add
RecallResponse.to_prompt_string() to Python and TypeScript client SDKs so
users can serialize recall results (with chunks and entity summaries) into
LLM-ready prompt strings.

Closes #924
2026-04-08 10:33:14 +02:00
Nicolò Boschi cece2c903c fix: make LiteLLM SDK embeddings encoding_format configurable (#928)
* fix: make LiteLLM SDK embeddings encoding_format configurable (#925)

The hardcoded encoding_format='float' breaks providers like Voyage AI
(only accepts 'base64') and Gemini (doesn't support the parameter at all).

Add HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT config option
that defaults to 'float' for backwards compatibility. Set to empty string
to omit the parameter for incompatible providers.

* chore: regenerate docs skill after configuration change
2026-04-08 09:41:11 +02:00
Derek Bouius d7c73f4342 security: bump lodash, lodash-es, defu in root lockfile (#915)
* security: bump lodash, lodash-es, and defu in root lockfile

Fixes Dependabot alerts in the root npm workspace lockfile:
- GHSA-r5fr-rjxr-66jc (high) lodash <4.18.1     (alert #338)
- GHSA-r5fr-rjxr-66jc (high) lodash-es <4.18.1  (alert #335)
- GHSA-737v-mqg7-c878 (high) defu <6.1.7        (alert #343)

defu (6.1.4 -> 6.1.7) and lodash (4.17.23 -> 4.18.1) were bumped via
targeted `npm update`. lodash-es was pinned exactly to 4.17.23 by
@chevrotain packages (transitive dep of mermaid in hindsight-docs),
so a `lodash-es` override (>=4.18.1) is added to the root package.json
to force resolution to the patched 4.18.1.

Verified: `npm ci` succeeds with 0 vulnerabilities. Mermaid/chevrotain
consumers all dedupe to lodash-es 4.18.1. lodash-es 4.x is semver-
compatible.

* chore: regenerate hindsight-docs skill

Picks up FAQ and best-practice sections added in #905 that were not
regenerated at merge time, so that `verify-generated-files` passes
for this branch.
2026-04-08 09:11:29 +02:00
Derek Bouius 3b9d2db091 security: bump vite across integrations (high CVE fix) (#913)
* security: bump vite across integrations to patched versions

Fixes Dependabot alerts for vite transitive dev dependency:
- GHSA-v2wj-q39q-566r (high): server.fs.deny bypass with queries
- GHSA-p9ff-h696-f583 (high): related vite server vulnerability

Adds a `vite` entry to the npm `overrides` in each integration's
package.json to force the patched version (>=8.0.5). To make this
possible in ai-sdk, chat, and openclaw — which pinned vitest ^4.0.18
whose vite peer is `^6.0.0 || ^7.0.0` — the minor-compatible bump
vitest ^4.0.18 -> ^4.1.2 is also included. vitest 4.1.x supports
vite 8.x (peer: ^6 || ^7 || ^8), so all six integrations converge on
vite 8.x consistently.

paperclip had no overrides block; one was added.

Verified locally: `npm ci && npx vitest run` passes in all six
integrations (ai-sdk 23, chat 28, openclaw 66, opencode 89, paperclip 27,
nemoclaw 36 tests).

* chore: regenerate hindsight-docs skill

Picks up FAQ and best-practice sections added in #905 that were not
regenerated at merge time, so that `verify-generated-files` passes
for this branch.
2026-04-08 09:11:21 +02:00
easonandeasonysliu 9790d904e0 fix: clamp out-of-range content_index in _map_results_to_contents (#908)
Some LLM providers (e.g. Anthropic Haiku) return 1-indexed
content_index values. When only one content item is provided,
this causes KeyError: 1 since the dict only has key 0.

Clamp content_index to the valid range instead of crashing.

Fixes #873

Co-authored-by: easonysliu <[email protected]>
2026-04-08 09:10:59 +02:00
Ben 2463efd0f2 Update author name from Mike to Michael (#917) 2026-04-07 13:42:29 -04:00
Ben 6674ee4706 Remove hindsight-cloud tag from guest post (#916) 2026-04-07 13:22:48 -04:00
Nicolò Boschi 57f154454d fix(recall): cap entity fanout in graph expansion (#911)
* fix(recall): cap entity fanout in graph expansion to prevent slow queries

On large banks, the entity co-occurrence self-join in _expand_combined()
produces massive intermediate row counts when seeds reference high-fanout
entities (e.g. an entity with 25K+ mentions). This causes recall latency
to degrade significantly.

Changes:
- Replace unbounded entity self-join with LATERAL per-entity cap
  (graph_per_entity_limit, default 200), reducing intermediate rows
  from potentially millions to at most num_entities * 200
- Add ORDER BY unit_id DESC in LATERAL subquery for deterministic
  recency-biased sampling (rides the PK index, no extra sort)
- Add timeout fallback (graph_expansion_timeout, default 10s) that
  drops entity expansion and falls back to semantic+causal only
- Add composite index (entity_id, unit_id) on unit_entities for
  index-only scans in the LATERAL subquery
- Merge 3 unmerged migration heads into one
- Fix recall_perf.py dotenv override issue

Unlike the approach in #895, this does NOT filter out hub entities
entirely — all entities are kept but capped equally, preserving
retrieval quality for queries about frequently-mentioned entities.

Benchmarked on a 67K-unit bank (top entity = 25K mentions):
- retrieval_graph: 0.337s → 0.055s (84% faster)
- end-to-end recall: 0.912s → 0.519s (43% faster)

* fix(tests): fix broken test_combined_scoring and test_reranking_proof_count

- test_combined_scoring: replace MagicMock(spec=RetrievalResult) with real
  dataclass instances — MagicMock attributes returned nested mocks that
  failed on >= comparisons with int
- test_reranking_proof_count: remove deleted `embedding` param from
  RetrievalResult constructor, use None for occurred_start/end to get
  neutral recency (datetime.now gave recency=1.0 which boosted scores)

* refactor: rename config to link_expansion_ prefix, fix observation fanout

- Rename GRAPH_PER_ENTITY_LIMIT → LINK_EXPANSION_PER_ENTITY_LIMIT and
  GRAPH_EXPANSION_TIMEOUT → LINK_EXPANSION_TIMEOUT to follow the
  convention that these are specific to the link_expansion graph retriever
- Apply the same LATERAL per-entity cap to _expand_observations(), which
  had the same unbounded self-join through unit_entities

* style: fix formatting in config.py
2026-04-07 18:59:50 +02:00
Ben 4028dd91f8 blog: One Memory for Every AI Tool I Use (#914)
* blog: One Memory for Every AI Tool I Use (guest post)
2026-04-07 12:57:48 -04:00
AldousandAldous the Orchestrator 0e81d1a25e feat(openclaw): support bankId for static banks (#910)
* feat(openclaw): support exact static bank ids

* test(openclaw): use generic static bank id example

* feat(openclaw): support bankId static bank configuration

---------

Co-authored-by: Aldous the Orchestrator <[email protected]>
2026-04-07 17:13:20 +02:00
Derek Bouius 8a2388a48f security: bump litellm to >=1.83.0 (#912)
Fixes Dependabot alerts:
- GHSA-jjhc-v7c2-5hh6 (critical): Authentication bypass via OIDC userinfo
  cache key collision (CVE-2026-35030)
- GHSA-53mr-6c8q-9789 (high): related litellm vulnerability

Updates both hindsight-api-slim and hindsight-integrations/litellm to
require litellm >=1.83.0. The previous upper cap (<=1.82.6) was set due
to the 1.82.7/1.82.8 supply chain compromise, which has since been yanked
from PyPI; 1.83.0 was published from the new secure CI/CD v2 pipeline
and is safe.

The uv.lock diffs are large because the current uv version (0.9.11)
upgrades the lockfile format (adds revision=3 and upload-time fields);
only litellm itself changes version (1.81.10/1.80.10 -> 1.83.0).

All 68 tests in hindsight-integrations/litellm pass against 1.83.0.
2026-04-07 16:54:24 +02:00
Nicolò Boschi 48185a4bee fix(mcp): validate UUID inputs and add sync_retain tool (#906)
* fix(mcp): validate UUID inputs at engine level and add sync_retain tool (#888)

- Add UUID validation in memory_engine for get_memory_unit, delete_memory_unit,
  get_mental_model, delete_mental_model, get_mental_model_history (raises ValueError)
- Catch ValueError → 400 in HTTP route handlers
- Add sync_retain MCP tool that calls retain_batch_async directly for immediate
  availability (no polling needed)
- Register sync_retain in _ALL_TOOLS, _SINGLE_BANK_TOOLS, UI MCP_TOOL_GROUPS
- Add code-review check for MCP tool registration completeness

* fix: remove UUID validation for mental model IDs (column is TEXT, not UUID)

Mental model IDs are TEXT columns that accept arbitrary string IDs
(e.g., 'team-communication-preferences'). UUID validation was incorrectly
added to get_mental_model, delete_mental_model, and get_mental_model_history.
2026-04-07 11:59:59 +02:00
Nicolò Boschi 7e23f8e149 fix(config): validate entity_labels structure on PATCH (#902)
* test: add regression tests for #874 and #894

Add tests for None event_date in fact extraction (AttributeError fix)
and for _register_profile skipping .env overwrite with short config keys.

* fix(config): validate entity_labels structure on PATCH (#891)

Config PATCH accepted bare strings in entity_labels values without
validation, causing silent failures at retain time. Now validates
via parse_entity_labels() before writing to DB, and fixes the
BankTemplateConfig type from list[str] to list[dict[str, Any]].

* fix(scripts): handle Python client generator README crash gracefully

The openapi-generator sometimes crashes writing README_onlypackage.mustache.
Allow the failure with || true since all API/model files are generated
before that step, and add a verification check for api_client.py.

* chore: regenerate docs skill openapi.json
2026-04-07 11:58:02 +02:00
Nicolò Boschi f659bb17c4 docs: add best practice for filtering recall by memory shape (#856) (#905)
Add guidance on using entity labels with `tag: true` to deterministically
filter recall results when a bank contains different memory shapes
(e.g., concise rules vs. detailed procedures).
2026-04-07 10:41:32 +02:00
Nicolò Boschi f31f82627c fix: add paperclip and opencode to changelog generator (#903)
* fix: add paperclip and opencode to changelog valid integrations

* fix: add paperclip and opencode package names to changelog generator

* release(paperclip): v0.1.1
2026-04-07 10:25:53 +02:00
e1c6220f0e feat: add OpenCode persistent memory plugin (#853)
* feat: add OpenCode persistent memory plugin

Add hindsight-opencode integration with:
- Three custom tools: hindsight_retain, hindsight_recall, hindsight_reflect
- Auto-retain on session.idle with document_id deduplication
- Memory injection on session start via system transform hook
- Memory preservation during context window compaction
- Sliding window retain with retainOverlapTurns support
- 4-level config hierarchy (defaults, user file, plugin options, env vars)
- Dynamic bank ID derivation (agent, project, channel, user dimensions)
- CI job, release script entry, docs page

79 tests across 6 test files.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address review findings for opencode integration

1. Pre-compaction retain now uses shared retainSession() helper,
   respecting retainMode, documentId, and session_id metadata
   consistently with idle-retain (was bypassing retention policy).

2. System transform recall is only consumed after successful injection.
   If Hindsight is briefly unavailable, the plugin retries on the next
   LLM call instead of permanently skipping recall for the session.

3. Config validation for retainMode and recallBudget — typos like
   "full_session" or "maximum" now log a warning and fall back to
   the default instead of silently changing retention semantics.

85 tests (6 new covering compaction documentId, recall retry, and
config validation).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: docs/tools findings from second review round

1. Remove "session" from supported dynamic bank fields in docs —
   the implementation can't vary bank ID per session since it's
   derived once at plugin startup.

2. Explicit tools (retain, reflect) now call ensureBankMission()
   before API calls, so bankMission/retainMission are applied even
   when the agent uses tools exclusively without triggering hooks.

3. Added tests for mission setup via tools path.

88 tests pass.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: recall retry semantics and README bank scoping clarity

1. recallForContext now returns { context, ok } to distinguish
   "no results" (ok=true) from "API error" (ok=false). System
   transform consumes the session on ok=true even with 0 results,
   so empty banks don't cause repeated queries. Only transient API
   failures preserve retry.

2. README clarifies that channel/user bank dimensions are process-
   scoped (set via env vars before launch), not per-session dynamic
   within a running OpenCode process.

89 tests pass.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: review fixes for opencode integration

- Rename CI job from build-opencode-integration to test-opencode-integration
  to match naming convention for integrations that run tests
- Fix tsconfig module resolution to Node16 (consistent with other integrations)
- Extract shared makeConfig test helper to avoid duplication across 3 test files

* fix: remove unused PluginState import from tools.ts

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-04-07 10:11:57 +02:00
Nicolò Boschi 66cbdda3cb test: add regression tests for #874 and #894 (#901)
Add tests for None event_date in fact extraction (AttributeError fix)
and for _register_profile skipping .env overwrite with short config keys.
2026-04-07 09:43:25 +02:00
Nicolò Boschi cf4bd598b4 fix: make bank_id metric label opt-in to prevent OTel memory leak (#898)
* fix: make bank_id metric label opt-in to prevent OTel memory leak

bank_id as an OTel metric attribute creates unbounded histogram growth
since each unique bank_id produces never-evicted time series. Default
to excluding it; opt in with HINDSIGHT_API_METRICS_INCLUDE_BANK_ID=true
for deployments with few banks.

Closes #850

* refactor: use config.py for metrics_include_bank_id setting

Move HINDSIGHT_API_METRICS_INCLUDE_BANK_ID from direct os.getenv in
metrics.py to the standard HindsightConfig path. Add configuration
documentation.
2026-04-07 09:42:59 +02:00
Nicolò Boschi 443c94c827 fix(mcp): auto-coerce string-encoded JSON in tool arguments (#849) (#899)
LLM agents frequently serialize list/dict tool arguments as JSON strings
instead of native types (e.g., tags='["a","b"]' instead of tags=["a","b"]),
causing Pydantic validation failures. This extends _make_tools_tolerant to
detect array/object parameters from the JSON Schema and auto-coerce string
values via json.loads before validation.

Also fixes _make_tools_tolerant compatibility with FastMCP 3.x by adding
a _get_mcp_tools helper that supports both 2.x and 3.x internal APIs.
2026-04-07 09:33:12 +02:00
Abdulkadirklc 26794aab09 feat(recall): add proof_count boost to combined scoring (#821)
* feat(recall): add proof_count boost to combined scoring

Observations with more supporting evidence now rank slightly higher
in recall results. proof_count is threaded through the retrieval
pipeline and applied as a multiplicative boost in reranking:

- types.py: add proof_count field to RetrievalResult
- retrieval.py: include proof_count in SELECT columns
- reranking.py: add log1p-normalized proof_count boost (alpha=0.1)

The boost uses the same multiplicative pattern as recency and temporal
signals. proof_count=1 is neutral, proof_count=50 gives ~+5% boost.
Non-observation fact types are unaffected (neutral 0.5).

* fix(retrieval): Apply proof_count boost to graph and temporal retrieval, normalize scaling

* fix(retrieval): correct proof_norm math to zero-center at count 1

* fix(retrieval): Apply proof_count boost to link_expansion retrieval

* fix: remove BFS zombie, clamp proof_norm to [0,1], fix test comment (log1p->math.log)
2026-04-07 09:32:44 +02:00
Nicolò Boschi 7863ffeb49 fix(paperclip): address review fixes for paperclip integration (#900)
- Add CI job for paperclip integration tests with change detection
- Add paperclip to valid release integrations
- Validate hindsightApiUrl is set in loadConfig()
- Log warnings on recall/retain failures instead of silently swallowing
- Remove hardcoded timeout from reflect call
- Fix tsconfig module resolution to Node16
- Update tests to pass required hindsightApiUrl
2026-04-07 09:32:24 +02:00
Octopus 9e2890ba81 fix(embed): skip profile .env overwrite when config has no HINDSIGHT_API_* keys (#896)
When the daemon is already running, ensure_running() calls _register_profile()
with a config dict using short keys (llm_api_key, llm_provider, etc.) that do
not match the HINDSIGHT_API_* prefix filter. This caused api_config to always
be empty, and create_profile() would overwrite the existing .env with an empty
file on every CLI command.

Add an early return guard so _register_profile() skips the create_profile()
call when api_config is empty, preserving any existing profile configuration.

Fixes #894
2026-04-07 09:28:53 +02:00
Chris Bartholomew e0e65c44f6 fix(query_analyzer): handle dateparser internal crashes gracefully (#893)
DateparserQueryAnalyzer.analyze() called dateparser.search.search_dates()
without any error handling, so internal bugs in the third-party library
propagated all the way up the search/consolidation pipeline and failed
the calling task.

Observed traceback:

  File ".../engine/query_analyzer.py", line 140, in analyze
    results = self._search_dates(query, settings=settings)
  File ".../dateparser/search/search.py", line 294, in search_dates
    "Dates": self.search.search_parse(...)
  File ".../dateparser/search/search.py", line 168, in search_parse
    translated, original = self.search(shortname, text, settings)
  File ".../dateparser/languages/locale.py", line 224, in translate_search
    [original_tokens[i], original_tokens[i + 1]],
  IndexError: list index out of range

Wrap the call in a try/except so any parser failure is treated as
"no temporal constraint found" — the caller can then fall back to
non-temporal retrieval instead of erroring out the whole task. The
failure is logged at WARNING level so we still notice it.

Add a regression test that monkey-patches _search_dates to raise an
IndexError and asserts the analyzer returns an empty constraint and
emits a warning log.
2026-04-07 09:26:27 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 6881f63781 chore(deps): bump actions/github-script from 7 to 8 (#879)
Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 8.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 09:25:55 +02:00
Daniyar 6cb309f72b Fix AttributeError when event_date is None in fact_extraction (#875)
* Fix AttributeError when event_date is None in fact_extraction

`_extract_facts_from_chunk` crashes with `'NoneType' object has no
attribute 'isoformat'` when retaining documents without a timestamp.

Two locations fixed:
- Line 1058: debug log called `event_date.isoformat()` without a None
  check
- Line 921: `parse_datetime_flexible()` can return None, so re-check
  before calling `.strftime()` / `.isoformat()`

Fixes #874

* Revert unnecessary None guard on line 921

The original `if event_date is not None:` already guards that block.
Only line 1058 needed the fix.
2026-04-07 09:22:07 +02:00
shun yiandyishun.eason f9fe6953a3 fix: Windows compatibility for hindsight-embed (#867)
- Add cross-platform file locking support
- Use fcntl on Unix-like systems, msvcrt on Windows
- Add detailed documentation explaining why we don't use external libraries
- Fixes issue where module couldn't be imported on Windows due to missing fcntl

Co-authored-by: yishun.eason <[email protected]>
2026-04-07 09:15:59 +02:00
Volodymyr Prypeshniuk 07de798c3b feat(google): add support for google embeddings and reranker (#863)
* Add support for google embeddings gemini/vertex and google reranker via vertex search api

* Add reference docs
2026-04-07 09:15:31 +02:00
Byeonghoon YooandClaude Opus 4.6 cefa75545a feat(helm): add persistent volume for local model cache (#861)
* feat(helm): add persistent volume for local model cache

When using local reranker (e.g., BAAI/bge-reranker-v2-m3) or local
embedding models, the models are downloaded to /home/hindsight/.cache
on every pod restart, causing slow startup and unnecessary bandwidth.

Add optional persistent volume support:
- api: PVC mounted at /home/hindsight/.cache
- worker: volumeClaimTemplate (StatefulSet) at same path

Disabled by default. Enable via:
  api.persistence.modelCache.enabled: true
  worker.persistence.modelCache.enabled: true

Closes #860

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

* feat(helm): add extraVolumes and extraVolumeMounts for api and worker

Allow users to mount arbitrary volumes (configMaps, secrets, emptyDir,
etc.) into api and worker pods via values, following common helm chart
library conventions.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-07 09:14:09 +02:00
Octopus cd99eef4c5 fix: use max_tokens for OpenAI-compatible endpoints with custom base URL (#858)
Mistral (and several other providers) reject 'max_completion_tokens' with a 422
because they haven't adopted the newer OpenAI parameter name. When the openai
provider is configured with a custom base_url (e.g. Mistral, Together AI),
fall back to the widely-supported 'max_tokens' parameter.

Native OpenAI (no custom base_url) and Groq still use 'max_completion_tokens'.

Fixes #852
2026-04-07 09:13:08 +02:00
Ben cd4b3e96e2 blog: Persistent Memory for AutoGen Agents with Hindsight (#883)
* Add AutoGen persistent memory blog post
2026-04-06 14:59:43 -04:00
Ben e02e7ad3d4 blog: Hindsight is now a native memory provider in Hermes Agent (#882)
* Add Hermes native memory provider blog post
2026-04-06 10:55:48 -04:00
Ben 98fee1e380 docs(hermes): update integration docs for plugin overhaul (hermes-agent#5094) (#881)
* docs(hermes): update integration docs for hermes-agent plugin overhaul
2026-04-06 10:54:59 -04:00
Nicolò Boschi 906b740dd7 fix(docs): add missing SEO frontmatter to paperclip integration 2026-04-02 17:37:02 +02:00
Nicolò Boschi 7990381f6a fix(ci): resolve all CI failures (#847)
* fix(ci): resolve all CI failures — unversioned integrations, test retries

- Move integration docs to separate unversioned docs plugin (docs-integrations/)
  so new integrations don't need to be duplicated across versioned_docs
- Remove integration pages from versioned_docs (v0.3, v0.4) — sidebar
  entries now use links instead of doc refs
- Add missing title/description SEO frontmatter to autogen.md
- Add retry logic (2 attempts) to test-doc-examples.sh for transient
  LLM timeouts
- Add pytest-rerunfailures to test-api with --reruns 2 for flaky
  Gemini-dependent integration tests

* ci: retrigger

* fix: graph entity inheritance, SyncTaskBackend error propagation, fact_type test regressions

- Fix observation entity inheritance in get_graph_data: the unit_entities
  query only fetched entities for visible observation IDs, not their source
  memory IDs, so the inheritance loop always found an empty entity_map
- Remove error swallowing in SyncTaskBackend._execute_task so test failures
  surface instead of being silently logged
- Wrap remaining consolidation submission call sites with try/except since
  consolidation is non-critical for those operations
- Fix test_sync_backend test to expect errors to propagate
- Remove fact_type=["world"] filter from test_document_upsert_behavior and
  test_mentioned_at_from_context_string (same PR #848 regression)
- Remove flaky marker from consolidation test (now deterministic)
2026-04-02 17:17:42 +02:00
Ben 045e8910d1 Blog: Hindsight Is #1 on BEAM — the Benchmark That Tests Memory at 10M Tokens (#851)
* Add BEAM SOTA blog post
2026-04-02 11:02:16 -04:00
Ben 81441ee9af feat(paperclip): add hindsight-paperclip TypeScript integration (#773)
* feat(paperclip): add hindsight-paperclip TypeScript integration

Adds long-term memory for Paperclip AI agents via a lightweight
TypeScript/Node.js npm package with no runtime dependencies.

- recall() / retain() functions for heartbeat lifecycle hooks
- createMemoryMiddleware() for Express HTTP adapter agents
- Bank ID strategy: paperclip::{companyId}::{agentId} (configurable)
- Skill file for agents to call Hindsight REST API directly
- 27 unit tests covering bank derivation, recall, and retain
- Docs page at sdks/integrations/paperclip

* Remove skills file from paperclip integration

* Rename package to @vectorize-io/hindsight-paperclip
2026-04-02 14:26:45 +02:00
Nicolò Boschi 30a319a6ab feat: bank template import/export with Template Hub (#819)
* feat(api): add bank template import/export endpoints

Add POST /banks/{bank_id}/import and GET /banks/{bank_id}/export
endpoints for declarative bank setup via JSON manifests.

A template manifest (version 1) can include bank config overrides
and mental model definitions. Import creates or updates mental
models matched by id, applies config as per-bank overrides, and
returns async operation IDs for content generation.

Export dumps a bank's explicit overrides and mental models as a
manifest that can be re-imported into another bank.

Includes control plane UI: bank creation dialog now accepts an
optional template JSON to pre-configure the bank on creation.

* docs: add Template Gallery page and bank templates reference

- Template Gallery (/templates) with search, category filter, manifest
  preview modal with copy-to-clipboard
- 5 starter templates: Customer Support, Research Assistant, Personal
  Journal, Code Review Buddy, Meeting Notes
- Bank Templates API reference doc (developer/api/bank-templates)
- Sidebar entry under API section

* docs: add Template Gallery links to navbar and sidebar

- Top navbar: "Templates" link between Integrations and Changelog
- Sidebar: "Template Gallery" in Resources section

* fix(docs): remove emoji icons, autofocus search, fix placeholder in template gallery

* docs: rename to Bank Templates, move to Resources sidebar only

* docs: add Bank Templates to Resources navbar dropdown

* feat(api): add directives to bank template import/export

- Add BankTemplateDirective model with name, content, priority, is_active, tags
- Import creates/updates directives matched by name
- Export includes all directives (active and inactive)
- Validation: duplicate names rejected, empty name/content caught
- Tests: 24 tests covering directives create/update, existing vs new
  bank import, validation, export with directives, full round-trip

* docs: add directives to bank templates docs and sample templates

* feat(api): add JSON Schema endpoint for bank template validation

- GET /v1/default/bank-template-schema returns the JSON Schema
  auto-generated from the Pydantic BankTemplateManifest model
- Static schema file at docs/static/bank-template-schema.json
- Docs updated with schema endpoint, static file link, and
  validation examples (Python jsonschema, Node ajv-cli)

* feat(api): live schema validation on import, fix schema endpoint path

- Move schema endpoint to /v1/bank-template-schema (system-level, not per-bank)
- Import endpoint now accepts raw JSON and validates with Pydantic manually,
  returning clean 400 errors instead of raw 422s for all validation failures
- All validation (schema + semantic) returns consistent 400 with detailed messages

* docs: add interactive JSON Schema viewer to Bank Templates page

Renders the Pydantic-generated schema as a collapsible property tree
with types, required badges, defaults, and descriptions. The schema
is imported from the static bank-template-schema.json file.

* ui: add template toggle switch and browse link to bank creation dialog

- Replace always-visible textarea with a switch toggle ("Import from template")
- Textarea only shows when switch is on, keeping the dialog clean by default
- Add "Browse templates" link pointing to hindsight.vectorize.io/templates
- Reset template state when switch is toggled off or dialog is cancelled

* ui: add empty state with Add Document CTA to data view

When a bank has 0 memories, the data view (all tabs: constellation,
graph, table, timeline) shows a centered empty state with a CTA
button that opens the Add Document dialog.

* docs: replace templates with Conversation and Coding Agent

Remove generic placeholder templates. Add two practical templates
based on actual integration patterns:

- Conversation: for chat agents (LiteLLM, LangGraph, Pydantic AI,
  Vercel AI SDK). Tracks user preferences, open threads.
- Coding Agent: for Claude Code/Codex. Tracks technical decisions,
  project context, developer preferences. High literalism.

* docs: rename gallery to Bank Templates Hub, keep API doc as Bank Templates

* docs: register layout-template and file-json icons in navbar and sidebar

* docs: register layout-template icon in DefaultNavbarItem for dropdown items

* docs: show integration icons on template cards

Templates now have an optional `integrations` field referencing
integration IDs from integrations.json. Icons are resolved at render
time and shown in the card header next to the category badge.

* docs: add Personal Assistant template for OpenClaw, Hermes, NemoClaw

* feat: add Export Template to bank actions + map all integrations to templates

- Add "Export Template" to the bank Actions dropdown — exports config,
  mental models, and directives as JSON, copies to clipboard
- Add export API route and client method
- Map remaining integrations to templates: CrewAI, AG2, Agno, Strands,
  LlamaIndex, local-mcp, skills → Conversation; hindclaw → Personal Assistant

* feat: add --template flag to LoCoMo benchmark + remove schema from Hub

- LoCoMo benchmark accepts --template <path> to apply a bank template
  manifest (config, mental models, directives) before ingestion
- Template is applied per-bank in both single-phase and two-phase modes
- BenchmarkRunner.apply_template() reuses the same engine methods as
  the /import API endpoint
- Remove Manifest Schema section from Bank Templates Hub page
  (schema stays in the API reference doc)

* refactor: remove description field from bank template manifest

* docs: remove tags, fact_types, and directives from starter templates

* docs: remove reflect_mission and disposition fields from starter templates

* build: validate template manifests against JSON Schema during docs build

* cleanup: remove unused JsonSchemaViewer component

* docs: remove retain_extraction_mode from starter templates

* ui: enable word wrap in template manifest preview

* docs: add link to Bank Templates reference doc from Hub page

* docs: convert bank templates doc to mdx with multi-language code snippets

- Convert bank-templates.md to .mdx with Tabs/CodeSnippet components
- Add example files: bank-templates.py, .mjs, .sh, .go with doc markers
- Examples cover import, dry-run, export, round-trip, and schema
- Regenerate OpenAPI spec and all client SDKs (Python, TS, Rust, Go)

* fix: migration revision collision + use typed models in benchmark template

- Rename merge migration d6e7f8a9b0c1 -> d6e7f8a9b0c2 to resolve
  revision ID collision with case_insensitive_entities_trgm_index
- Update a4b5c6d7e8f9 down_revision to point to the renamed migration
- Fix f-string lint in case_insensitive migration
- BenchmarkRunner.apply_template() now validates manifest through
  BankTemplateManifest Pydantic model instead of raw dict access
- Remove redundant inline imports (json, Path already at module top)

* fix(docs): add missing Go tab to dry-run code snippet

* ci: retrigger

* fix: sync skills openapi.json + fix bankId null type error in export

- Copy updated openapi.json to skills/hindsight-docs/references/
- Add null guard for bankId in Export Template onClick handler

* fix: sync generated files (memory_engine formatting, docs skill references)

* cleanup: remove obsolete migration collision workaround
2026-04-02 12:21:53 +02:00
Nicolò Boschi 9cfdd464a9 fix(retain): preserve normalized experience fact types (#848)
* fix(retain): preserve normalized experience fact types and remove deprecated opinion type

The ExtractedFactType conversion was re-checking for raw "assistant" fact_type
after the parsing layer had already normalized it to "experience". Since
fact_from_llm.fact_type was always "experience" (never "assistant"), the ternary
always fell through to "world", silently losing experience classification.

Also removes the deprecated "opinion" fact type from internal extraction models,
database constraints/indexes (via migration), and dead code paths. The public API
surface (descriptions, response models, backwards-compat filter) is unchanged.

* refactor(retain): drop unused confidence_score column

The confidence_score column was only ever non-null for opinion facts
(which are now removed). It was always written as NULL and never read
back from the database. Remove it from:
- DB model and migration (DROP COLUMN)
- INSERT queries in fact_storage.py
- retain_async/retain_batch_async parameters
- RetainContext/RetainResult extension models
- RetainBatch dataclass
2026-04-02 12:20:37 +02:00
Nicolò Boschi 8d1bfbbd2b feat: add detail parameter to list/get mental models (#846)
* feat: add detail parameter to list/get mental models (#825)

Add a `detail` query parameter (metadata|content|full) to both list and get
mental model endpoints (HTTP + MCP) to control response size. This reduces
payload for agent boot flows and MCP clients where context budget is limited.

Closes #825

* fix: update Rust CLI for optional mental model fields

The generated Rust client now has content/source_query as Option<String>
after the detail parameter was added. Update CLI code to handle optionals.
2026-04-02 11:52:45 +02:00
Nicolò Boschi 7d6c570a3a fix(embed): clear stale daemon on port before starting (#843)
* fix(embed): clear stale daemon on port before starting new one (#843)

When `uvx hindsight-embed@latest` resolves to a new version, the old
daemon may still be bound to the port, causing EADDRINUSE. Before
starting a daemon, check if the port is occupied, verify it's a
hindsight process via /health, and SIGTERM it if so.

* chore: remove unused signal import from test

* refactor: use cross-platform port check instead of lsof-only

Use socket for port check (works on all platforms), extract PID lookup
into a helper with Windows (netstat) and Unix (lsof) paths, and
extract kill logic into a testable static method.

* refactor: reuse cross-platform helpers in stop() and stop_ui()
2026-04-02 10:57:28 +02:00
Nicolò Boschi 26a64cc00e fix(api): clear memories endpoint no longer deletes the bank profile (#837)
DELETE /v1/default/banks/{id}/memories and the MCP clear_memories tool
were calling delete_bank() without distinguishing from the actual delete-bank
endpoint. When no fact_type filter was provided, the bank row itself was
deleted along with its memories.

Add a delete_bank_profile parameter to delete_bank() (default True) and
pass False from all clear-memories callers so the bank profile, disposition,
and background are preserved.
2026-04-01 18:34:39 +02:00
087545cc1b feat(openclaw): JSONL-backed retain queue for external API resilience (#740)
When the external Hindsight API is unreachable, retain requests are
buffered as JSON lines in a local file and automatically flushed once
connectivity is restored. Queue survives process restarts.

- Only active in external API mode (local daemon handles its own persistence)
- Zero dependencies — uses only Node built-ins (fs, crypto)
- Bulk removal via removeMany() for O(1) file rewrites during flush
- Cached item count so size() is O(1)
- Configurable: retainQueuePath, retainQueueMaxAgeMs (-1 = forever),
  retainQueueFlushIntervalMs (default 60s)
- Flushes on successful retain and on a periodic timer
- All logging routed through structured logger (api.logger)

Co-authored-by: billy <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: Antoine Khater <[email protected]>
2026-04-01 18:06:57 +02:00
Nicolò Boschi 7415ebff7c fix: resolve 25 test regressions from streaming retain pipeline (#722) (#836)
The 3-phase retain pipeline (914ba796) introduced several regressions:

1. **Per-content tags lost** — streaming pipeline used `contents[0].tags`
   for ALL chunks, breaking tag-based visibility. Fixed by tracking
   chunk-to-content mapping so each chunk uses its source content's tags.

2. **Multi-document batches broken** — batches with per-content
   `document_id` values were merged into a single document. Fixed by
   grouping by document_id and processing each group independently.

3. **Migration ID collision** — `d6e7f8a9b0c1` was used by both
   `drop_documents_metadata` and `case_insensitive_entities_trgm_index`.
   Renamed trgm migration to `e8f9a0b1c2d3`, fixed chain, added missing
   schema prefix on DROP INDEX.

4. **Graph entity inheritance** — `get_graph_data` queried entities for
   observation IDs only, but observations inherit entities from source
   memories. Fixed by querying `all_relevant_ids`.

5. **Docstring false positives** — link_utils.py docstrings triggered
   the SQL schema safety test's unqualified table reference check.

6. **Config test count** — `retain_chunk_batch_size` added to
   `_CONFIGURABLE_FIELDS` without updating the test assertion.
2026-04-01 17:59:10 +02:00
Nicolò Boschi 0c97b555ab release(autogen): v0.1.1 2026-04-01 17:51:46 +02:00
Nicolò Boschi 4d117cc274 chore: add autogen to changelog valid integrations list 2026-04-01 17:51:05 +02:00
DK09876andClaude Opus 4.6 a757765ab2 feat: add AutoGen integration for Hindsight (#719)
* feat: add AutoGen integration for Hindsight

Adds hindsight-autogen package providing FunctionTool instances that give
AutoGen agents persistent long-term memory via retain/recall/reflect APIs.

- Package: hindsight_autogen with create_hindsight_tools() factory
- 31 unit tests covering tool creation, invocation, config fallback, errors
- Docs page and integrations.json entry
- README with quickstart and configuration reference

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review feedback for autogen integration

- Fix install instructions to include autogen-agentchat and autogen-ext[openai]
- Add autogen.svg icon to prevent broken image in integrations grid
- Change icon reference from .png to .svg in integrations.json

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: add sleep between retain/recall and close clients in examples

- Add time.sleep(3) between retain and recall to wait for async processing
- Close Hindsight client and model client to avoid unclosed session warnings

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: use asyncio.sleep instead of time.sleep in async examples

time.sleep blocks the event loop; asyncio.sleep yields control.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review feedback - validation, defaults, release script

- Add autogen to VALID_INTEGRATIONS in release-integration.sh
- Remove unused verbose config field
- Extract DEFAULT_BUDGET/MAX_TOKENS/RECALL_TAGS_MATCH constants in config.py,
  import from tools.py to eliminate default duplication
- Add Literal types for budget and recall_tags_match validation
- Modernize type hints to X | None with from __future__ import annotations
- Add [tool.ruff] line-length = 120 to match monorepo convention
- Add py.typed PEP 561 marker
- Re-raise HindsightError before broad Exception catch
- Expand asyncio.sleep(3) comment explaining when/why it's needed
- Remove verbose from docs configure() reference table

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-04-01 17:48:11 +02:00
Derek Bouius 300d089b6a fix: resolve remaining Dependabot security alerts (#833)
* fix: resolve remaining Dependabot security alerts

- Regenerate package-lock.json so npm overrides take effect
  (serialize-javascript, handlebars, path-to-regexp, brace-expansion)
- Upgrade Pygments 2.19.2 -> 2.20.0 in crewai and integration-tests
  lockfiles (fixes ReDoS via GUID matching)

* fix: resolve duplicate alembic revision ID d6e7f8a9b0c1

Two migrations shared the same revision ID: the merge migration
(drop_documents_metadata_column) and the trigram index migration
(case_insensitive_entities_trgm_index). Assign a new unique ID
to the trigram migration and update the downstream dependency.

* chore: fix lint formatting for generated and existing files
2026-04-01 17:22:38 +02:00
Ben 1a1fb35cb0 Add OpenClaw shared memory team setup guide (#788)
* Add blog post: Shared Memory for OpenClaw
2026-04-01 09:33:15 -04:00
Nicolò Boschi 914ba7962c perf: 3-phase retain pipeline — fix deadlocks, cap temporal links, query-time entity expansion (#722)
* perf: 3-phase retain pipeline — fix deadlocks, cap temporal links, query-time entity expansion

Major retain pipeline overhaul addressing deadlocks, write amplification,
and TimeoutErrors. Restructures retain into three phases:

Phase 1: Entity resolution on separate connection (read-heavy)
Phase 2: Core write transaction (atomic) — facts, unit_entities, links
Phase 3: Best-effort display data (error-isolated) — entity viz links, stats

Key changes:
- Sorted bulk INSERT FROM unnest() prevents deadlocks
- Temporal links capped to top-20 per unit (95% reduction)
- Batched semantic ANN via temp table + LATERAL
- Query-time entity expansion via unit_entities self-join
- Entity viz links moved to Phase 3 (post-transaction)
- HINDSIGHT_API_RETAIN_MAX_CONCURRENT config (default: 32)

* fix: increase semantic link top_k from 5 to 20

The hardcoded top_k=5 was artificially limiting semantic link creation.
Link expansion retrieval can consume up to budget (50-200) semantic
neighbors per seed set, but each fact only had 5 outgoing edges — making
the bidirectional graph very sparse.

Increasing to 20 gives retrieval 4x more edges to work with. The ANN
probe cost is unchanged (same HNSW traversal per fact, just returning
more rows). INSERT cost is negligible (~14k rows via bulk INSERT).

Also: all 18 TimeoutErrors in the latest benchmark (beam-1m-u20) were
from Gemini LLM calls, zero from the database — confirming the entity
resolution split eliminated DB timeouts entirely.

* perf: move semantic ANN search to Phase 1 to avoid transaction timeouts

The batched LATERAL ANN query (700 HNSW probes) was the last remaining
source of DB TimeoutErrors — all 29 in the latest benchmark were from
create_semantic_links_batch inside the Phase 2 write transaction.

Split semantic link creation into three phases:
- Phase 1 (separate conn, autocommit): ANN search via temp table + LATERAL.
  No transaction locks, no contention with concurrent writers.
- Phase 2 (write transaction): within-batch numpy similarities (instant) +
  INSERT of both within-batch and Phase 1 ANN results. No DB reads.
- Phase 3 (flush_pending_stats): future hook point for re-checking ANN
  results after commit to catch links missed by concurrent batches.

Also adds 7 unit tests for compute_semantic_links_within_batch covering
empty input, identical/orthogonal embeddings, threshold filtering, top_k
cap, and tuple structure validation.

* fix: handle placeholder unit_ids in Phase 1 ANN search (not valid UUIDs)

* test: add Phase 1 ANN cross-batch test + configurable test PG port

- New test_semantic_links_phase1_ann_cross_batch verifies that the Phase 1
  ANN search with placeholder unit IDs correctly creates cross-batch
  semantic links after remapping to real IDs.
- Test PG port now configurable via HINDSIGHT_TEST_PG_PORT env var
  (default: 5556) to avoid conflicts with running benchmark daemons.

* perf: remove retry_with_backoff from retain, set semaphore default to 4

Remove retry_with_backoff from _run_db_work and _run_delta_db_work:
- Deadlocks are prevented by sorted bulk INSERT (no need for retry)
- Transient timeouts are handled by the worker poller's task-level retry
  (3 attempts, 60s spacing) which is better than rapid internal retries
  that amplify I/O pressure during contention storms

Set HINDSIGHT_API_RETAIN_MAX_CONCURRENT default from 32 to 4:
- The semaphore gates Phase 1 (ANN + entity resolution) + Phase 2 (writes)
- At 4 concurrent, HNSW index I/O is manageable; at 10+ concurrent the
  probes saturate disk and cause cascading timeouts
- LLM extraction still runs at full parallelism (semaphore acquired after)

* fix: add fact_type filter to Phase 1 ANN query to use per-bank HNSW indexes

The LATERAL ANN query was falling back to sequential scan + sort (90ms/probe)
because the per-bank HNSW indexes are partial indexes filtered on fact_type.
Without fact_type in the WHERE clause, PostgreSQL couldn't use them.

Fix: iterate over ('world', 'experience') and run one HNSW-indexed ANN per
type. EXPLAIN shows 8ms/probe (was 90ms) — 11x faster.

700 probes × 8ms × 2 types = ~11s total (was ~63s via seq scan).

* fix: scope temporal links by fact_type + add integration tests

Temporal links now filter by fact_type in the LATERAL query — world facts
only link to world facts, experience to experience. This matches how
retrieval filters results and avoids wasted cross-type link rows.

New integration tests:
- test_semantic_ann_uses_hnsw_index: verifies Phase 1 ANN creates
  cross-batch semantic links (tests fact_type filter + placeholder remap)
- test_temporal_links_scoped_by_fact_type: verifies world facts get
  temporal links to other world facts but NOT to experience facts

* fix: tolerate individual chunk LLM failures instead of failing entire batch

Changed asyncio.gather(*tasks) to asyncio.gather(*tasks, return_exceptions=True)
in both chunk-level and content-level fact extraction. A single chunk timeout
(e.g., Gemini >90s) no longer discards all other successfully extracted facts.

For a 50MB document with 17k chunks, even a 2% chunk failure rate previously
caused 0 completions (entire batch discarded). Now 16,700 facts are extracted
and only the 300 failed chunks are skipped with a warning log.

* fix: batch temporal LATERAL query for large documents (16k+ chunks)

The LATERAL query for temporal links passed all unit_ids at once into
unnest(), causing PostgreSQL timeouts on documents with 16k+ chunks.
Split into batches of 500 units per query to keep each under the
command_timeout.

Also identified: HNSW index creation on shared pg0 instances with
50k+ existing units exceeds the 60s command_timeout. This is a
test infrastructure issue (shared pg0 accumulates data) but also
affects production when creating new banks on large instances.

* feat: streaming chunk batching for large documents (RETAIN_CHUNK_BATCH_SIZE)

Process chunks in mini-batches of N (default 500), committing each batch
to the DB before starting the next. This prevents OOM kills on large
documents (50MB / 17k+ chunks) by keeping only ~500 facts + embeddings
in memory at a time instead of 50k+.

Each mini-batch goes through the full Phase 1 → 2 → 3 pipeline
independently, sharing the same document_id. On recovery (process dies
mid-way), delta retain detects already-committed chunks via content_hash
and skips them — only remaining chunks get re-extracted.

Config: HINDSIGHT_API_RETAIN_CHUNK_BATCH_SIZE (default: 500, 0 to disable)
Per-bank configurable via the hierarchical config system.

Tests:
- test_streaming_chunk_batching_produces_same_facts
- test_streaming_chunk_batching_recovery (delta retain skips committed chunks)
- test_streaming_disabled_for_small_docs

* perf(retain): producer-consumer pipeline + deferred semantic ANN

Replace the sequential streaming loop with a producer-consumer pipeline:
- LLM producer fires concurrent chunk extractions (semaphore-bounded)
- DB consumer drains queue in batches, runs Phase 1+2+3 per batch
- LLM and DB work overlap instead of running sequentially

Defer semantic links to a single final ANN pass after all batches commit:
- Remove within-batch semantic links from Phase 2 (was 2.6s/batch)
- Run parallel ANN (4 connections) after all facts committed
- top_k reduced from 50 to 20 (recall uses at most 20 neighbors)
- Recovery via operation result_metadata checkpoint

Additional optimizations:
- skip_exists_check on temporal/causal link INSERT (saves ~0.5s/batch)
- WHERE EXISTS guard on semantic link INSERT (handles document upsert)
- timeout=300s on ANN queries and bulk INSERT for large banks
- Demote [ANN] debug logs to logger.debug()
- Fix docstring typos (agent_id → bank_id)
- Fix content_index remapping in producer-consumer batches
- Fix delta retain passing contents vs delta_contents

50MB benchmark (mock LLM): 9.2 min (was 23 min) — 2.5x faster.
BEAM 10m benchmark: zero deadlocks, zero DB errors.

* refactor(retain): remove legacy fallback code paths

- Remove process_entities_batch (legacy single-connection entity processing)
- Remove extract_entities_batch_optimized (only caller was the above)
- Remove fallback entity processing inside Phase 2 transaction
- Remove legacy ANN inline fallback in create_semantic_links_batch
- Remove fallback entity_links direct-insert path in Phase 3
- Make resolved_entity_ids/entity_to_unit/unit_to_entity_ids required params

* refactor(retain): replace tuple returns with dataclasses, remove dead code

- Add EntityResolutionResult and Phase1Result dataclasses in types.py
- Replace 4-tuple return from _pre_resolve_phase1 with Phase1Result
- Remove dead `entity_links = []` variables in retain_batch and _try_delta_retain
- Remove unused `confidence_score` parameter from orchestrator.retain_batch
  and _retain_batch_async_internal (was accepted but never used)

* fix(entity-resolver): remove LIKE full-scan fallbacks, use index-only trigram matching

The entity resolution query had LIKE '%...' substring conditions that bypassed
the GIN trigram index, causing full sequential scans of the entities table.
On banks with 10k+ entities, this caused TimeoutErrors (observed in BEAM 10m).

Changes:
- Remove LIKE fallbacks, use trigram % operator only (GIN index-based)
- Lower similarity threshold from 0.3 to 0.15 to catch substring relationships
- Use LOWER() on both sides for case-insensitive matching
- Migration: recreate GIN trigram index on LOWER(canonical_name)

* fix: remove schema prefix from index names in trigram migration

* fix(delta-retain): use same chunk_size as streaming path (3000 vs 120000)

_chunk_contents_for_delta defaulted to chunk_size=120000 while the streaming
path used 3000. On retry, delta re-chunked the document with different
boundaries, found 0 matching chunks, and fell through to full re-extraction.
This wasted all LLM calls on already-committed chunks.

Fix: use the same default (3000) so chunk hashes match on recovery.

* fix(retain): persist generated document_id in operation metadata for retry recovery

When no document_id is provided, retain generates a UUID. On retry, a new UUID
was generated, making delta retain and streaming chunk-hash recovery unable to
find previously committed chunks. All LLM extraction was wasted on retry.

Fix: resolve document_id early in retain_batch (before delta), persist it to
operation result_metadata, and recover it on retry. Both delta and streaming
paths now see the same document_id across attempts.

* refactor(retain): unify into single streaming pipeline, remove non-streaming path

All retains now go through the producer-consumer streaming pipeline,
regardless of document size. Small documents are processed as a single batch.
This eliminates the maintenance burden of two separate code paths.

Also fix document upsert: compare content hash to distinguish recovery
(same content, partially committed) from update (different content, needs
cascade-delete). Previously, existing chunks always triggered recovery mode.

* refactor(retain): remove dead code, replace raw dicts with Phase3Context dataclass

- Remove dead _handle_zero_facts_documents (no callers after path unification)
- Remove unused imports: defaultdict, EntityLink
- Replace raw dict phase3_context with typed Phase3Context dataclass
- Update _build_and_insert_entity_links_phase3 to use typed parameter
2026-04-01 12:52:49 +02:00
Nicolò Boschi 6f173b10a7 fix(consolidation): improve observation quality with structured processing rules (#814)
Rewrite consolidation prompt rules to produce clean, single-facet observations:
- One observation per distinct facet (count, named entity, relationship)
- Match updates by entity/facet, not topic similarity
- No computation — never infer/calculate values not explicitly stated
- Cascade state changes to all affected observations
- Preserve event history (sold, died, moved) — conservative deletes
- Include dates on state changes when available
- Keep observations concise — no cross-facet narrative bloat

Add test_horse_observations.py exercising a realistic sequence of retain
operations (farm with horses being named, sold, dying) and verifying that
observations track history correctly and mental models can synthesize them.
2026-04-01 12:44:52 +02:00
Nicolò Boschi ea834bc7dc breaking: remove BFS and MPFP graph retrieval strategies (#767)
Remove the BFS spreading activation and MPFP (Multi-Path Fact Propagation)
graph retrieval strategies, leaving link_expansion as the sole graph
retrieval algorithm. Rename MPFPTimings to GraphRetrievalTimings and
mpfp_timings field to graph_timings since the timing struct is used by
LinkExpansionRetriever.

Deleted:
- hindsight-api-slim/hindsight_api/engine/search/mpfp_retrieval.py
- hindsight-api-slim/tests/test_mpfp_retrieval.py

Removed config: HINDSIGHT_API_MPFP_TOP_K_NEIGHBORS
2026-04-01 12:44:40 +02:00
Nicolò Boschi 4fd7c5d1f8 fix(db): respect vector extension config in per-bank index migration (#832)
* fix(db): respect vector extension config in per-bank index migration

Migration d5e6f7a8b9c0 hardcoded HNSW when creating per-bank partial
vector indexes, ignoring HINDSIGHT_API_VECTOR_EXTENSION. This caused
banks migrated from pre-v0.4.18 to get HNSW indexes even when
pgvectorscale (DiskANN) or vchord was configured.

- Fix the original migration to read the vector extension config
- Add migration a4b5c6d7e8f9 to detect and recreate mismatched indexes
  (skipped entirely when extension is pgvector, since those are correct)

* chore: regenerate openapi.json for v0.4.22 version bump
2026-04-01 12:22:08 +02:00
Nicolò Boschi 36783df320 feat(control-plane): add Constellation view with Pretext canvas rendering (#831)
Add a new "Constellation" memory visualization as the default view in the
control plane, powered by @chenglou/pretext for DOM-free text layout on canvas.

- Canvas-rendered zoomable/pannable memory map with spatial label deconfliction
- Nodes colored by link-count heat gradient (Hindsight brand teal→cyan→blue)
- Star-like rendering with varied size/opacity based on connectivity
- Hover shows rich tooltip with full memory metadata (text, entities, tags, dates)
- Hover highlights connected nodes and their links, dims the rest
- Click to select and view memory details in the side panel
- Fullscreen mode toggle
- Link type legend and heat gradient legend on the HUD

Also optimizes the graph API endpoint:
- Entity query now filters by visible unit IDs (was doing full table scan)
- Links query caps at 10k edges sorted by weight (was returning 500k+ uncapped)
- Replaced expensive DISTINCT ON with LEAST/GREATEST sort with simple ORDER BY
2026-04-01 11:15:14 +02:00
Derek Bouius ee4510a762 fix(deps): address critical and high severity security vulnerabilities (#827)
* fix(deps): address critical and high severity security vulnerabilities

Bump vulnerable dependencies to patched versions across the monorepo:

Python (critical/high):
- fastmcp >=2.14.0 → >=3.2.0 (SSRF, path traversal, OAuth confused deputy, command injection)
- langchain-core >=1.2.11 → >=1.2.22 (path traversal in legacy load_prompt)

Python (low):
- cryptography >=46.0.5 → >=46.0.6 (incomplete DNS name constraint enforcement)
- pygments: add >=2.20.0 pin (ReDoS via GUID regex)

Node.js:
- serialize-javascript ^7.0.3 → ^7.0.5 (CPU exhaustion DoS)
- handlebars: add >=4.7.9 override (JS injection via AST type confusion)
- path-to-regexp: add >=0.1.13 override (ReDoS via route params)
- brace-expansion: add version range override (process hang/memory exhaustion)

Also adds type: ignore comments for FastMCP 2.x private attribute access that
ty now flags since FastMCP 3.x removed _tool_manager (guarded by try/except
and hasattr at runtime).

Regenerated all lock files across API, integrations, and tests.

* fix(deps): add ajv v8 scoped overrides for schema-utils and ajv-keywords

The global ajv ^6.14.0 override caused schema-utils and ajv-keywords to
receive ajv v6, but they require ajv v8 (for dist/compile/codegen). Add
scoped overrides to ensure these packages get ajv v8 while the global
override remains for packages that need v6.

* fix(tests): remove stateless_http from FastMCP() constructor calls

FastMCP 3.x no longer accepts stateless_http in the constructor. The
tests call tools directly without HTTP transport, so the parameter is
not needed.

* fix: update MCP tests for FastMCP 3.x _tool_manager removal

FastMCP 3.x removed _tool_manager. Tests now use
_local_provider._components for sync tool dict access and
mcp.list_tools() for async filtered tool listing.

* fix: resolve docusaurus build failures (ajv overrides + missing blog date)

- Remove global ajv ^6.14.0 override and scoped ajv-keywords/schema-utils
  overrides that caused webpack compilation errors manifesting as
  "Cannot read properties of undefined (reading 'date')" during SSR
  and "these parameters are deprecated" warnings. Natural version
  resolution (v6.12.6+ for v6 consumers, v8+ for v8 consumers) already
  satisfies the security fix (>= 6.12.3).
- Add missing date frontmatter to learning-capabilities blog post.

* chore: regenerate openapi spec and docs skill
2026-04-01 09:20:34 +02:00
f3f2c6b023 Fix timeline group sort: localeCompare → numeric Date comparison (#820)
* Initial plan

* Fix timeline sort to use numeric datetime comparison instead of string localeCompare

* chore: remove accidentally committed root package-lock.json

Agent-Logs-Url: https://github.com/ThePlenkov/hindsight/sessions/d02f10c5-cc48-4977-84a9-48870f9460ec

Co-authored-by: ThePlenkov <[email protected]>

* chore: restore package-lock.json to its original state from main

Agent-Logs-Url: https://github.com/ThePlenkov/hindsight/sessions/2ede4783-55ef-4f36-8ea7-7d65c5362a0a

Co-authored-by: ThePlenkov <[email protected]>

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: ThePlenkov <[email protected]>
2026-03-31 21:53:08 +02:00
Nicolò Boschi 6f7437be21 blog: What's New in Hindsight 0.4.22 release notes and changelog (#818) 2026-03-31 18:47:30 +02:00
Nicolò Boschi d7f6723546 Release v0.4.22
- Update version to 0.4.22 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.4
2026-03-31 18:14:59 +02:00
Nicolò Boschi 2c32ffadc9 fix(mental-models): add tags_match and tag_groups to trigger config (#786) (#804)
When a mental model has tags, refresh_mental_model hardcoded
tags_match="all_strict", causing empty results when most memories
are untagged. Add configurable tags_match and tag_groups fields
to MentalModelTrigger so users can control refresh filtering.

- Add tags_match (any/all/any_strict/all_strict) to override default
- Add tag_groups for compound boolean tag expressions during refresh
- Default behavior unchanged (all_strict when tags present)
- Update both refresh paths (task-based and direct)
- Add UI controls in Create/Update mental model dialogs
- Regenerate OpenAPI spec and client SDKs
2026-03-31 18:09:01 +02:00
Nicolò Boschi baf5447de2 refactor: replace LLMProvider classmethods with from_env() and document missing config fields (#816)
CI failures are unrelated to this PR:
- test_mental_models_dimension_change_empty_table: database OID error (infrastructure flake)
- test_reflect_searches_mental_models_when_available: LLM-dependent assertion (flaky)
2026-03-31 18:00:41 +02:00
KaguraandClaude Opus 4.6 84985ee9bc fix(reranker): use httpx for Cohere Azure endpoints to avoid 404 errors (#790)
When using Azure AI Foundry Cohere rerank endpoints, the Cohere SDK
incorrectly appends /v1/rerank to the base_url, but Azure endpoints
already include the full path (e.g., /models/.../invoke). This causes
double-pathing and 404 errors.

This commit modifies CohereCrossEncoder to detect when base_url is
provided and use httpx directly for custom endpoints, while keeping
the native Cohere SDK for standard API usage. The Azure Cohere API
response format is compatible with the native format.

Fixes #783

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-31 17:44:42 +02:00
emirhan-gaziandEMIRHAN GAZI ecaa1ad1e0 feat(api): add HINDSIGHT_API_LLM_EXTRA_BODY config for custom model params (#781)
Enable passing arbitrary extra_body parameters to OpenAI-compatible API
calls via a JSON-encoded env var. This supports custom model servers
(e.g. vLLM) that need parameters like chat_template_kwargs to control
thinking mode.

Co-authored-by: EMIRHAN GAZI <[email protected]>
2026-03-31 17:03:33 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ea0c616240 chore(deps): bump dorny/paths-filter from 3 to 4 (#762)
Bumps [dorny/paths-filter](https://github.com/dorny/paths-filter) from 3 to 4.
- [Release notes](https://github.com/dorny/paths-filter/releases)
- [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md)
- [Commits](https://github.com/dorny/paths-filter/compare/v3...v4)

---
updated-dependencies:
- dependency-name: dorny/paths-filter
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-31 17:02:01 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 0b29378eb8 chore(deps): bump azure/setup-helm from 4 to 5 (#761)
Bumps [azure/setup-helm](https://github.com/azure/setup-helm) from 4 to 5.
- [Release notes](https://github.com/azure/setup-helm/releases)
- [Changelog](https://github.com/Azure/setup-helm/blob/main/CHANGELOG.md)
- [Commits](https://github.com/azure/setup-helm/compare/v4...v5)

---
updated-dependencies:
- dependency-name: azure/setup-helm
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-31 17:01:52 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c2e801ccb0 chore(deps): bump actions/deploy-pages from 4 to 5 (#763)
Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5.
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-31 17:01:44 +02:00
Ben 410c208746 What's New: multi-org support and credit transfers (March 29) (#815)
* Add What's New post: multi-org support and credit transfers
2026-03-31 10:19:08 -04:00
Nicolò Boschi c475c6bb56 ci: trigger full CI on PR approval instead of safe-to-test label (#813)
Replace the `pull_request_target` + `safe-to-test` label mechanism with
`pull_request_review` (submitted, approved). External contributor PRs now
get basic builds/lints on open, and full secret-dependent CI only after
a maintainer approves — no manual labeling needed.
2026-03-31 15:15:14 +02:00
Amin Bolakhrif f841bcb92d feat: add optional LiteLLM SDK embedding output dimensions (#809)
* feat: add optional LiteLLM SDK embedding output dimensions

Allow configuring an optional output dimension for litellm-sdk embeddings and pass it through only when set, while preserving default behavior.

Made-with: Cursor

* test: assert wrapped init error for invalid dimensions

Add a LiteLLM SDK embeddings test that verifies invalid OpenAI dimensions fail during initialize() and preserve provider error details in the wrapped RuntimeError.

Made-with: Cursor
2026-03-31 14:58:02 +02:00
Maxim Kremmnev fa82efc886 fix(claude-code): disable built-in tools to prevent MCP tool deferral (#784) 2026-03-31 14:20:17 +02:00
Nicolò Boschi 627ec5d524 feat: expose document_metadata in API and control plane (#798)
* feat: expose document_metadata in API and control plane

Add document_metadata (sourced from retain_params.metadata) to both
list and get document endpoints. Display it in the control plane
documents table and detail panel. Drop the unused metadata column
from the documents table (was always stored as empty {}).

* fix: code review fixes for document_metadata feature

- Remove unnecessary `import json as _json` (json already imported at module level)
- Simplify redundant truthiness checks in retain_params parsing
- Regenerate OpenAPI spec and client SDKs (Python, TypeScript, Go)
- Add tests for document_metadata in get_document and list_documents

* feat(ui): improve documents table and detail panel

- Relative timestamps with full date on hover
- Remove context column from table
- Metadata shown as k=v badges (blue, like tags)
- Size in bytes instead of chars
- Document IDs wrap instead of truncating
- Detail panel wider (560px)
- Retain params: context, event_date, metadata badges
2026-03-31 11:42:02 +02:00
Nicolò Boschi bdb33c58d1 feat: add /code-review skill with project standards (#806)
* feat: add /code-review skill for automated code quality checks

Adds a Claude Code skill that reviews changes against project standards:
missing tests, dead code, type safety, lint, and CLAUDE.md conventions.
CLAUDE.md now instructs contributors to run /code-review after implementation.

* refactor: move code standards from CLAUDE.md into /code-review skill

Single source of truth for coding conventions (Python style, type safety,
TypeScript style) is now .claude/skills/code-review.md. CLAUDE.md points
to the skill for reading before coding and running after implementation.

* feat: add code comments convention to /code-review skill

Require comments explaining non-trivial technical decisions, with history
of previous approaches. Review step checks for missing reasoning comments,
stale comments, and undocumented approach changes.

* fix: move skill to directory structure for Claude Code discovery

Claude Code requires .claude/skills/<name>/SKILL.md, not loose .md files.

* feat: add branch hygiene checks to /code-review skill

Review step 1 now verifies branch is based on recent origin/main and
all commits are relevant to the feature. Unrelated commits flagged as
must-fix.

* feat: strengthen code review rules and fix stale CLAUDE.md references

- Enforce no multi-item tuple returns and no raw dicts even for internal code
- Add mandatory /code-review gate before push/PR
- Add integration completeness checklist (tests, CI job, release-integration.sh)
- Fix stale references: remove hindsight/ dir, update integrations list,
  update LLM providers, remove hardcoded file sizes, fix _HIERARCHICAL_FIELDS
  -> _CONFIGURABLE_FIELDS

* docs: add ./scripts/dev/start.sh for local dev in CLAUDE.md
2026-03-31 11:09:41 +02:00
Nicolò Boschi 1dbbe39ea1 ci: report safe-to-test CI results on PR (#807)
* feat(api): warn on unknown request parameters via X-Ignored-Params header

Add middleware that detects unknown query params and JSON body fields,
logs a server-side warning, and returns an X-Ignored-Params response
header listing the ignored parameters. This surfaces silent parameter
ignoring (e.g. tag=source:slack on /memories/list) without breaking
forward compatibility between client and server versions.

Closes #792

* ci: report safe-to-test CI results on PR via status and comment

pull_request_target workflow runs are not linked to the PR by GitHub,
so the CI results are invisible on the PR page after adding safe-to-test.

Add a report-pr-status job that:
- Creates a commit status on the PR head SHA
- Posts/updates a summary comment with pass/fail counts and failed job names

* ci: skip secret-dependent jobs on fork pull_request events

Adds a has_secrets output to detect-changes that is false for fork PRs
via pull_request events. All 15 secret-dependent jobs now check this
output before running, avoiding guaranteed failures on fork PRs.

Fork contributors will see these jobs as skipped instead of failed,
and can use the safe-to-test label to run the full CI suite.
2026-03-31 11:09:24 +02:00
Nicolò Boschi cef42d8154 feat(api): warn on unknown request parameters via X-Ignored-Params header (#802)
Add middleware that detects unknown query params and JSON body fields,
logs a server-side warning, and returns an X-Ignored-Params response
header listing the ignored parameters. This surfaces silent parameter
ignoring (e.g. tag=source:slack on /memories/list) without breaking
forward compatibility between client and server versions.

Closes #792
2026-03-31 10:34:04 +02:00
Nicolò Boschi f8f62030e3 Add /code-review skill for automated code quality checks (#805)
* feat: add /code-review skill for automated code quality checks

Adds a Claude Code skill that reviews changes against project standards:
missing tests, dead code, type safety, lint, and CLAUDE.md conventions.
CLAUDE.md now instructs contributors to run /code-review after implementation.

* refactor: move code standards from CLAUDE.md into /code-review skill

Single source of truth for coding conventions (Python style, type safety,
TypeScript style) is now .claude/skills/code-review.md. CLAUDE.md points
to the skill for reading before coding and running after implementation.

* feat: add code comments convention to /code-review skill

Require comments explaining non-trivial technical decisions, with history
of previous approaches. Review step checks for missing reasoning comments,
stale comments, and undocumented approach changes.

* fix: move skill to directory structure for Claude Code discovery

Claude Code requires .claude/skills/<name>/SKILL.md, not loose .md files.

* feat: add branch hygiene checks to /code-review skill

Review step 1 now verifies branch is based on recent origin/main and
all commits are relevant to the feature. Unrelated commits flagged as
must-fix.
2026-03-31 10:21:25 +02:00
Nicolò Boschi 4768bf39ef fix(http): recall endpoint drops metadata in response (#797) (#803)
_fact_to_result was missing metadata=fact.metadata, so the HTTP recall
endpoint always returned metadata: null even though the engine preserved it.
2026-03-31 10:12:12 +02:00
Nicolò Boschi 865fb91298 fix(tests): use random port for pg0 in tests to avoid port conflicts (#801)
pg0 supports auto-assigning a free port when port=None. This avoids
test failures when port 5556 is already in use by another process.
2026-03-31 09:38:46 +02:00
Nicolò Boschi 1f5dc8bd15 ci: support running secret-dependent tests on fork PRs via safe-to-test label (#800)
Fork PRs don't have access to repository secrets, so integration tests
that need API keys (GCP, OpenAI, Cohere, etc.) are skipped. Maintainers
can now add the `safe-to-test` label after reviewing fork PR code to
trigger the full test suite with secrets via pull_request_target.
2026-03-31 09:31:39 +02:00
Nicolò Boschi d3d2684b11 fix(openclaw): add warn log and tests for CLI mode no-op in waitForReady (#799)
Follow-up to #764. Upgrades the silent debug log in waitForReady to
log.warn so unexpected calls before service.start() are visible, and
adds tests covering the CLI mode no-op path.
2026-03-31 09:25:52 +02:00
Kagura 41025c3b7c fix(openclaw): defer heavy init to service.start() to avoid CLI slowdown (#764)
OpenClaw loads plugins on every CLI command (status, models auth add,
config validate, etc.), not just gateway start. The plugin was starting
LLM detection, daemon initialization, and API health checks immediately
in the default export, causing unnecessary resource usage and terminal
noise on routine CLI operations.

Move all heavy initialization (detectLLMConfig, embedManager.start(),
checkExternalApiHealth, client creation) into service.start() which is
only called when the gateway starts. The default export now only does
lightweight config parsing and service/hook registration.

Hooks (before_prompt_build, agent_end) gracefully no-op when called
before service.start() via the waitForReady guard.

Closes #746
2026-03-31 09:10:13 +02:00
Volodymyr Prypeshniuk 1b5c262a8a fix(gemini): thought_signature read from wrong object and type in 3.1+ tool calls (#785) 2026-03-31 09:09:12 +02:00
Nicolò Boschi 0096115678 fix(engine): classify first-person agent experiences as 'experience' fact type (#775)
* fix(engine): classify first-person agent experiences as 'experience' fact type

The extraction prompt defined "assistant" too narrowly as only "interactions
with assistant (requests, recommendations)", causing the LLM to classify
first-person agent actions (code changes, debugging, discoveries) as "world".

Broadened the fact_type definition in the prompt and Pydantic model descriptions
to cover all first-person actions, experiences, and observations by the speaker.

* style: fix line length in fact_extraction.py
2026-03-31 09:06:36 +02:00
Nicolò Boschi b104bad02c fix(codex): merge new settings on upgrade instead of skipping (#780)
The installer skipped settings.json entirely if it already existed,
leaving version and new config keys stale. Now merges: updates version,
adds new upstream keys, preserves user customizations.

Also fixes pre-existing typo: RERANK_URL → rerank_url in ZeroEntropy
cross-encoder.
2026-03-31 09:05:49 +02:00
Chris Bartholomew 45ffc7fe90 SEO: add title and description to all integration pages (#787)
* SEO: add title and description to all integration pages

All 17 integration docs pages were missing title and description
frontmatter, causing Docusaurus to generate unhelpful titles like
"OpenClaw | Hindsight" and pull body text as meta descriptions.

- Add keyword-rich title and description frontmatter to all integration
  pages in both docs/ (current) and versioned_docs/version-0.4/
- Add scripts/check-integration-seo.mjs to enforce title + description
  on all future integration pages
- Wire the check into the build script so it runs locally and in CI

* Fix missing frontmatter on docs/sdks/integrations/openclaw.md

* Regenerate docs skill after integration page SEO updates
2026-03-30 17:53:43 -04:00
Chris Bartholomew 99122055f0 Improve OpenClaw post title, tags, and meta description
- Retitle to match search intent: "How to Add Persistent Memory to
  OpenClaw with Hindsight" targets openclaw memory/persistent memory queries
- Add intro paragraph before <!-- truncate --> so Docusaurus generates a
  proper meta description instead of "TL;DR"
- Expand tags from [openclaw] to include memory, agents, persistent-memory,
  knowledge-graph
2026-03-30 15:41:55 -04:00
Nicolò Boschi 75e2679cf1 release(llamaindex): v0.1.3 2026-03-30 18:34:25 +02:00
DK09876andClaude Opus 4.6 d93dfea8ce fix(llamaindex): document_id, memory API, and ReAct trace fixes (#777)
* fix(llamaindex): use uuid for document_id and sync version metadata

- Replace timestamp-based document_id with uuid4 hex to prevent
  collisions on rapid retains (timestamp_ms can duplicate in tight loops)
- Sync __version__ in __init__.py to match pyproject.toml (0.1.2)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(docs): pass memory to run() instead of ReActAgent constructor

LlamaIndex 0.14.x ReActAgent does not accept a memory parameter in
its constructor — it's silently dropped via **kwargs. Memory must be
passed to agent.run(memory=...) where AgentWorkflow picks it up.

Also fixes the undefined `tools` variable (now `tools=[]`).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(llamaindex): strip ReAct reasoning traces from retained assistant messages

HindsightMemory.put/aput now extracts only the final Answer: text from
assistant messages containing ReAct reasoning (Thought:/Action:/Observation:
prefixes), preventing internal reasoning traces from polluting long-term memory.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(llamaindex): fix docstring example to pass memory to run()

The HindsightMemory class docstring showed the broken pattern of passing
memory= to the ReActAgent constructor, which silently drops it. Updated
to show the correct pattern: pass memory to agent.run().

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-30 18:33:03 +02:00
Nicolò Boschi e5209b18b3 docs: add RERANKER_ZEROENTROPY_BASE_URL to configuration page (#779)
Document the new configurable base URL for the ZeroEntropy reranker
provider added in #766. Also fix a type error where RERANK_URL was
renamed to rerank_url but one usage was missed.
2026-03-30 17:51:04 +02:00
Nicolò Boschi a7adfbb0df release(codex): v0.2.0 2026-03-30 17:50:02 +02:00
Nicolò Boschi 3461398b52 feat(codex): add structured tool call retention from Codex rollout files (#778)
Parse all Codex rollout item types (function_call, local_shell_call,
exec_command_end, patch_apply_end, mcp_tool_call_end, web_search_call)
into structured JSON content blocks matching Claude Code's format.
Enabled by default via retainToolCalls setting.
2026-03-30 17:48:47 +02:00
Timur Iskhakov a915584e39 feat: add configurable base URL for ZeroEntropy reranker (#766) 2026-03-30 17:43:19 +02:00
Nicolò Boschi 2c72af5525 release(openclaw): v0.5.1 2026-03-30 16:58:18 +02:00
Nicolò Boschi 41bb6d710b Revert "Bump openclaw integration to v0.5.1"
This reverts commit a3e458ad43.
2026-03-30 16:57:39 +02:00
DK09876andClaude Opus 4.6 7af01e35e9 fix(docs): use tools=[] in BaseMemory example (#772)
The automatic memory example referenced an undefined `tools` variable.
Since HindsightMemory handles retain/recall transparently, no tools
are needed — use an empty list.

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-30 16:56:22 +02:00
Nicolò Boschi a3e458ad43 Bump openclaw integration to v0.5.1 2026-03-30 16:55:58 +02:00
Nicolò Boschi 704e41fa27 Fix trailing commas in openclaw.plugin.json and add JSON manifest CI tests (#774)
Fixes #771 — two trailing commas in openclaw.plugin.json caused OpenClaw's
strict JSON parser to reject the plugin manifest during installation.

Also adds JSON validation tests for both the openclaw plugin manifest and
the claude-code hooks.json so CI catches invalid JSON before release.
2026-03-30 16:55:06 +02:00
Ben f30ca3deda Fix blog homepage: Hindsight Cloud section always shows top 3 posts (#770)
* Fix blog homepage: show all posts so Cloud section always gets top 3
2026-03-30 10:40:57 -04:00
Ben d61517d502 Update MCP OAuth blog post date to 2026-03-30 (#769) 2026-03-30 13:53:41 +00:00
Ben df17570d8a blog: What's New in Hindsight Cloud — Native OAuth for MCP Clients (#731)
* Add MCP OAuth blog post
2026-03-30 09:33:58 -04:00
Nicolò Boschi 7a9e99998a docs: 0.4.21 release blog post and changelog (#765)
* docs: 0.4.21 release blog post and changelog

* fix(blog): align 0.4.21 code snippets with docs, add release image

* chore: regenerate docs skill references
2026-03-30 15:28:27 +02:00
Nicolò Boschi cc3cdc2f83 Release v0.4.21
- Update version to 0.4.21 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.4
2026-03-30 14:52:50 +02:00
Nicolò Boschi 88630f93d7 release(hermes): v0.5.0 2026-03-30 14:50:04 +02:00
Nicolò Boschi 73460fa4e6 chore: add hermes to release and changelog valid lists 2026-03-30 14:49:33 +02:00
Nicolò Boschi 6a28373ecf release(openclaw): v0.5.0 2026-03-30 14:47:01 +02:00
Nicolò Boschi 66071fec9d feat(scripts): support patch/minor/major bump keywords in release-integration 2026-03-30 14:43:43 +02:00
Nicolò Boschi b8b40e458b release(codex): v0.1.1 2026-03-30 14:40:58 +02:00
Nicolò Boschi e65bd361cc release(llamaindex): v0.1.2 2026-03-30 14:37:15 +02:00
Nicolò Boschi b8fa0e8cfc chore: add llamaindex and codex to changelog generator 2026-03-30 14:36:47 +02:00
Nicolò Boschi b739b9e36a chore: add llamaindex to release-integration valid list 2026-03-30 14:35:18 +02:00
Nicolò Boschi 5b16882a5e chore(llamaindex): bump version to 0.1.1 2026-03-30 14:32:10 +02:00
Nicolò Boschi 56489a58d1 refactor(llamaindex): merge into single hindsight-llamaindex package (#760)
* refactor(llamaindex): merge two packages into single hindsight-llamaindex

Merge `llama-index-tools-hindsight` and `llama-index-memory-hindsight` into
a single `hindsight-llamaindex` package following our naming convention.

- Rename package to `hindsight-llamaindex` (Python module: `hindsight_llamaindex`)
- Move HindsightToolSpec and HindsightMemory into the same package
- Delete `llamaindex-memory/` directory
- Add CI test job for llamaindex integration
- Update docs, blog post, and integrations.json

* fix(blog): update llamaindex blog post for merged package

- Move date to 2026-03-30
- Add HindsightMemory (automatic BaseMemory) pattern
- Fix "bank must exist first" pitfall — mission auto-creates
- Align all code examples with docs page
- Update architecture diagram to show both patterns

* fix(docs): add llamaindex/openai icons, rename Codex

- Add llamaindex.png and openai.png icons
- Rename "OpenAI Codex CLI" to "Codex" in integrations.json and docs
- Use openai.png icon for Codex integration
2026-03-30 14:30:48 +02:00
Nicolò Boschi a8a63818c7 feat(api): add duration_ms to audit log entries (#758)
* feat(api): add duration_ms to audit log entries

Server-computed duration in milliseconds (started_at → ended_at) on
the list audit logs endpoint. Null when ended_at is not set.

Closes #749

* feat(api): add duration_ms to audit log entries and type audit endpoints

- Add server-computed duration_ms (started_at → ended_at) to audit log
  list response. Null when ended_at is not set.
- Add typed Pydantic response models for both audit log endpoints
  (list and stats) so they appear in the OpenAPI spec.
- Regenerate OpenAPI spec and all client SDKs.

Closes #749

* chore: regenerate docs skill after audit log response models
2026-03-30 12:32:02 +02:00
Nicolò Boschi d8050387e4 fix(mcp): handle Claude Code GET probe and make stateless_http configurable (#757)
* fix(mcp): handle Claude Code GET probe and make stateless_http configurable (#751)

Claude Code v2.1.84+ sends a GET to /mcp/ before POST initialize,
which fails with 405 (stateless) or 400 (stateful). Intercept
sessionless GET requests in MCPMiddleware and return 200 OK so the
client proceeds to POST initialize.

Also make stateless_http configurable via HINDSIGHT_API_MCP_STATELESS
(default: false/stateful) instead of hardcoding true.

Closes #751

* docs: add HINDSIGHT_API_MCP_STATELESS to configuration reference
2026-03-30 12:15:06 +02:00
Nicolò Boschi 38e03e419d Convert codex tool_choice test to pytest style (#752)
* Convert codex tool_choice test to pytest style

Follow-up to #734: replace unittest.TestCase + manual sys.path
manipulation with idiomatic pytest + @pytest.mark.asyncio,
matching the rest of the test suite.

* Fix test_hierarchical_fields_categorization for new configurable fields

Update expected count from 20 to 21 and add assertions for fields
added by recent PRs: retain_default_strategy, retain_strategies,
max_observations_per_scope, reflect_source_facts_max_tokens,
llm_gemini_safety_settings, mcp_enabled_tools.

* Add LlamaIndex doc to v0.4 versioned docs and sidebars

The LlamaIndex integration doc was added to docs/ (next version) in
#672 but not to versioned_docs/version-0.4/, causing a broken link
on the /integrations page which resolves to the latest version.

* Regenerate docs skill references

Run generate-docs-skill.sh to pick up new integration pages
(codex, llamaindex) and updated configuration docs.

* Add Codex integration doc to v0.4 versioned docs and sidebar

Same issue as LlamaIndex: doc was added to docs/ (next) but not
versioned_docs/version-0.4/, causing broken link on /integrations.
2026-03-30 11:58:59 +02:00
Nicolò Boschi 6488c9bc77 fix: per-bank index creation respects HINDSIGHT_API_VECTOR_EXTENSION config (#755)
create_bank_hnsw_indexes() hardcoded USING hnsw regardless of the configured
vector extension, causing "column cannot have more than 2000 dimensions for
hnsw index" when using pgvectorscale or vchord with high-dimensional embeddings.

Now reads get_config().vector_extension and uses the appropriate index type:
- pgvector → USING hnsw
- pgvectorscale → USING diskann
- vchord → USING vchordrq

Closes #738
2026-03-30 11:37:26 +02:00
Nicolò Boschi d2965e64e6 fix(retain): inject retain_mission into verbose extraction mode (#745) (#754)
Verbose mode was the only extraction mode that skipped injecting the
retain_mission FOCUS section into its prompt template. Users who set a
retain_mission got no filtering when using verbose mode.
2026-03-30 11:36:36 +02:00
Nicolò Boschi ecf16ea1e6 fix(codex): cleanup dead code, add release lifecycle and docs (#753)
* fix(codex): cleanup dead code and add to release lifecycle

- Remove orphaned reflect() method from client.py (leftover from dropped auto-mode)
- Remove dead retainToolCalls config default (never wired through)
- Add codex to release-integration.sh valid integrations
- Add settings.json version fallback to release script
- Add codex CI test job in test.yml
- Add codex to integrations.json registry

* docs(codex): add changelog page and link from integration docs

* feat(codex): add hosted installer script (get-codex)

Add self-contained installer at hindsight.vectorize.io/get-codex that
downloads scripts from GitHub, configures hooks, and supports local/cloud
mode selection — no git clone required.

Update docs and README to use the one-liner install:
  curl -fsSL https://hindsight.vectorize.io/get-codex | bash

* chore(codex): remove install.sh in favor of hosted get-codex

* fix(docs): use /next/ prefix for codex changelog link

* fix(docs): use GitHub link for codex changelog back-link
2026-03-30 11:34:43 +02:00
Ben 0b17a67c70 feat: add Hindsight memory integration for OpenAI Codex CLI (#730)
* feat(codex): add Hindsight memory integration for OpenAI Codex CLI

Hooks-based integration that gives Codex CLI long-term memory via Hindsight.
Three hooks keep memory in sync: SessionStart (daemon pre-warm), UserPromptSubmit
(recall + context injection), Stop (retain conversation to memory).

Key differences from the Claude Code integration:
- Codex transcript format: JSONL with {msg: {type, message}} (user_message/agent_message)
- No CODEX_PLUGIN_ROOT env var — install.sh writes hooks.json with absolute paths
- State stored in ~/.hindsight/codex/state/ (not CLAUDE_PLUGIN_DATA)
- No async: true in hooks (not supported by Codex)
- No SessionEnd event
- hooks.json written to ~/.codex/hooks.json with codex_hooks = true in config.toml

* fix(codex): fix transcript parser for actual Codex disk format

Codex stores sessions as rollout-*.jsonl with response_item entries:
  User:      {type:response_item, payload:{type:message, role:user, content:[{type:input_text, text:...}]}}
  Assistant: {type:response_item, payload:{type:message, role:assistant, phase:final_answer, content:[{type:output_text, text:...}]}}

Previous parser expected an undocumented {msg:{type:user_message}} format from the Rust protocol spec
that does not match the actual on-disk storage format.

* feat(codex): add reflect mode to UserPromptSubmit hook

Add recallMode config option (default: 'recall') that switches the
UserPromptSubmit hook between:
- 'recall': existing behavior, fast raw facts list
- 'reflect': agentic synthesis loop, returns coherent prose answer

Also adds reflect() method to HindsightClient and HINDSIGHT_RECALL_MODE
env var override. Reflect uses a 25s timeout (vs 10s for recall).

* feat(codex): auto mode for recall/reflect selection

Add recallMode: 'auto' (new default) that picks the operation per-query:
- Synthesis patterns (what do you know, what's my, summarize, etc.) → reflect
- All other prompts → recall (fast, raw facts, better for code tasks)

* feat(codex): add automated test suite and finalize recall-only mode

* docs(codex): add docs page and sidebar entry for Codex CLI integration
2026-03-30 10:51:53 +02:00
Nicolò Boschi e7c9a6832d fix(hermes): sync lifecycle hooks for hermes-agent 0.5.0 (#741)
* fix(hermes): convert lifecycle hooks to sync for hermes-agent 0.5.0 compatibility

hermes-agent 0.5.0 calls plugin hooks synchronously via invoke_hook(),
but our pre_llm_call/post_llm_call were async — coroutines were never
awaited, so recall context injection and auto-retain silently did nothing.

Switch hooks to sync client methods and add integration tests using
the real hermes-agent PluginManager.

* fix(hermes): use proper hermes-agent dep with uv source override

Replace inline git URL with standard `hermes-agent>=0.5.0` version
constraint plus `[tool.uv.sources]` to resolve from the git tag until
0.5.0 lands on PyPI.
2026-03-30 10:44:54 +02:00
DK09876andClaude Opus 4.6 2d787c4ffd feat: add LlamaIndex integration (#672)
* feat: add LlamaIndex integration for Hindsight

Add hindsight-llamaindex package providing persistent memory tools for
LlamaIndex agents via the native BaseToolSpec pattern. Includes retain,
recall, and reflect tools, a convenience factory, global config, full
test suite, docs page, blog post, and integrations.json entry.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review feedback for llamaindex integration

- Fix ReActAgent API: from_tools() → constructor, chat() → await run()
- Add create_bank step to all quickstart examples
- Add production patterns section to docs (tags, error handling, bank lifecycle)
- Add memory scoping recommendation to README
- Add when-not-to-use section to blog post
- Add LlamaIndex compatibility tests (agent acceptance, FunctionTool.call)
- Fix self-hosted auth wording in cookbook notebook

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: use async client methods and asyncio.run() for runnable examples

- Use await client.acreate_bank() instead of sync create_bank() to
  avoid "event loop already running" errors in notebooks and async contexts
- Wrap plain Python examples in async def main() + asyncio.run(main())
  so they are copy-paste runnable as scripts
- Add Jupyter notebook tip to docs showing top-level await pattern
- Bank lifecycle example in docs now uses async acreate_bank

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: add async tool methods to avoid event loop conflicts

HindsightToolSpec now provides both sync and async tool implementations
using LlamaIndex's (sync_fn, async_fn) tuple pattern in spec_functions.
Async agents (ReActAgent, etc.) use aretain/arecall/areflect natively,
avoiding the "Timeout context manager should be used inside a task"
error that occurred when sync _run_async() was called from within an
active event loop.

- Add aretain_memory, arecall_memory, areflect_on_memory async methods
- Extract shared kwargs builders (_retain_kwargs, _recall_kwargs, etc.)
- spec_functions now uses tuples: [("retain_memory", "aretain_memory"), ...]
- Tests verify tools have both sync fn and async fn set
- Notebook verified end-to-end with nbclient against local Hindsight

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: remove blog post from integration PR

The blog post will be pulled in separately from its own PR.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Address PR review: add context label, document_id auto-gen, bank mission, graceful errors

- Add `retain_context` param (default: "llamaindex") as source label on retain ops
- Auto-generate `document_id` as `{session_id}-{timestamp_ms}` when not provided
- Add `retain_async` param (default: True) for non-blocking retain processing
- Add `mission` param for automatic bank creation/management on first use
- Change error handling from raising HindsightError to graceful log + return message
- Add per-operation timeout constants in _client.py
- Add `context` and `mission` fields to config.py and configure()
- Update docs: document as standalone package (not LlamaHub), new params, patterns
- Tests: 51 passing (up from 34), covering all new features

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Restructure to LlamaIndex namespace packages + add BaseMemory implementation

Tools package (llama-index-tools-hindsight):
- Restructured from hindsight_llamaindex/ to llama_index/tools/hindsight/
- Import: from llama_index.tools.hindsight import HindsightToolSpec
- Follows PEP 420 implicit namespace package convention
- Removed retain_async param (client.retain() doesn't support async_processing)

Memory package (llama-index-memory-hindsight):
- New package: llama_index/memory/hindsight/
- HindsightMemory(BaseMemory) for automatic memory
- put() auto-retains user/assistant messages to Hindsight
- get(input) auto-recalls relevant memories, prepends as system message
- Graceful error handling, bank mission management, document_id generation
- 28 unit tests passing

Both packages follow LlamaIndex community conventions for future LlamaHub submission.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-30 10:43:35 +02:00
111e8c70a2 fix(codex): don't crash on startup when quota is exhausted (429) (#744)
A 429 usage_limit_reached response during verify_connection() caused the
server to refuse to start entirely. Quota exhaustion is not a configuration
error — the server should start and serve retain/recall requests normally,
it just can't make LLM calls until the quota resets.

Co-authored-by: Marco Rutsch <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-30 10:39:12 +02:00
d441ab814d feat(openclaw): configurable logging with structured output (#739)
* feat(openclaw): configurable logging with structured output

Replace raw console.log/warn/error spam with a structured logger.
New plugin settings: logLevel, logSummaryIntervalMs, logCompact.
Bank mission log demoted to verbose-only. Retain/recall batched
into periodic summaries. Each recall now shows memory count injected.

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

* use api.logger for framework-consistent output, show autoRecall/autoRetain on init

Route all log output through OpenClaw's api.logger instead of raw console
calls. Matches mem0 plugin style. Startup now shows mode + feature flags.
Dropped logCompact setting (framework handles formatting). Added subtle
slate-blue color to hindsight prefix for visual differentiation.

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

* add bank name to init and summary logs, fix singular/plural consistency

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

* rename log levels to standard: off, error, warning, info, debug

Per review feedback — use standard level names instead of custom ones.

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

---------

Co-authored-by: billy <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-03-30 10:38:02 +02:00
Mr. Khachaturov f8285b7b90 feat(mcp): add filter_mcp_tools hook for per-user tool visibility (#737)
Add optional filter_mcp_tools() method to OperationValidatorExtension.
Called during tools/list after bank-level mcp_enabled_tools filtering.
Extensions can override to hide MCP tools per-user-per-bank based on
access policies. Default returns all tools unchanged.

- Add filter_mcp_tools to OperationValidatorExtension with default pass-through
- Wire into _get_enabled_tools in _apply_bank_tool_filtering
- Move _ALL_TOOLS to mcp_tools.py to avoid circular import (re-exported from mcp.py)
- Fail-open: if filter raises, log warning and return unfiltered tools
- Enforce ceiling: validator can narrow but never expand beyond bank config
- Add 8 tests: default, filtering, empty set, integration, composition,
  can't-add-tools, exception fail-open, no-validator passthrough
2026-03-30 10:33:09 +02:00
akhaterandAntoine Khater a209ef1ae2 fix: parse query params from base_url in OpenAI embeddings client (#735)
* fix: parse query params from base_url in OpenAI embeddings client

The OpenAI-compatible LLM provider already parses query parameters
(e.g. ?api-version=xxx for Azure OpenAI) from the base_url and passes
them as default_query to the OpenAI client. However, the OpenAI
embeddings provider did not do this, causing Azure OpenAI embeddings
to fail with 404 errors at runtime.

This applies the same URL parsing logic from the LLM provider to the
embeddings provider, enabling Azure OpenAI embeddings to work correctly.

* ci: add workflow to build fork Docker image

* ci: add slim image build (no local models)

* ci: remove fork build workflow per review request

---------

Co-authored-by: Antoine Khater <[email protected]>
2026-03-30 10:32:10 +02:00
Daoyang ShanandSapientropic 3573e53b1d Fix Codex named tool_choice in reflect (#734)
Co-authored-by: Sapientropic <[email protected]>
2026-03-30 10:31:17 +02:00
KaguraandClaude Opus 4.6 585ac76f39 fix(claude-code): implement tool_choice support for forced tool calls (#733)
* fix(claude-code): implement tool_choice support for forced tool calls

The call_with_tools() method now properly handles the tool_choice parameter
to force specific tool calls. Previously, the parameter was accepted but ignored,
causing the reflect agent to fail when trying to force specific tools on each
iteration.

Fixes #732

Changes:
- When tool_choice forces a specific function: filter allowed_tools to only
  that tool (with mcp prefix) and add a strong system prompt instruction
- When tool_choice is 'required': add instruction that model must call at
  least one tool
- When tool_choice is 'none': clear allowed_tools and mcp_servers to disable
  all tools
- When tool_choice is 'auto' (default): no change (existing behavior)

This matches the approach used in the OpenAI provider while adapting to the
Claude Agent SDK's lack of native tool_choice parameter by using allowed_tools
filtering and system prompt instructions.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: fix ruff formatting in alembic migration

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-30 10:30:53 +02:00
Nicolò Boschi b32767caa8 feat: add max_observations_per_scope bank config (#729)
* feat: add max_observations_per_scope bank config

Adds a configurable limit on the number of observations per tag scope.
When the limit is reached, consolidation only updates/deletes existing
observations — no new ones are created. Enforcement is done via a
constrained Pydantic response model (max_length on creates list) so the
LLM structurally cannot exceed the limit, plus prompt guidance.

- Config: HINDSIGHT_API_MAX_OBSERVATIONS_PER_SCOPE (-1 = unlimited)
- Reorder action execution: deletes → updates → creates
- Dynamic _ConsolidationBatchResponse with max_length constraint
- Prompt CAPACITY CONSTRAINT section when near/at limit
- Observations with no tags skip the limit entirely
- Control plane UI field + docs

* fix: strengthen max_observations tests with mock LLM + defensive truncation

- Rewrite integration tests to use MockLLM with deterministic responses
  (one observation per fact) instead of relying on real LLM behavior
- Add defensive truncation in _consolidate_batch_with_llm as belt-and-
  suspenders — catches LLM providers that ignore JSON schema max_length
- Tests now assert exact counts, not just upper bounds
2026-03-30 10:29:56 +02:00
cd4d449f8e fix(openclaw): add recallTimeoutMs config option for auto-recall (#736)
The auto-recall timeout was hardcoded to 10s but recall with budget=high
can take 13s+. This adds a configurable recallTimeoutMs option (default:
10000ms) so users can increase the timeout when using higher recall budgets.

Also adds recallInjectionPosition to the plugin schema (it was already
implemented in code but missing from the JSON schema validation, causing
config rejection).

Co-authored-by: Marco Rutsch <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-28 17:59:23 +01:00
Nicolò Boschi 7a3dbc1958 refactor(embedded): replace UI programmatic API with constructor flags (#728)
Replace start_ui()/stop_ui()/is_ui_running() methods with declarative
constructor flags (ui, ui_port, ui_hostname). UI lifecycle now follows
the daemon automatically - starts in _ensure_started, stops in _cleanup.

Add integration test verifying UI starts and can reach the dataplane
via the control plane's /api/health endpoint. Add Node.js setup to
test-hindsight-all CI job to support the UI test.
2026-03-27 18:01:04 +01:00
a69bdbb55f How We Built a 4-Way Hybrid Search System That Actually Runs in Parallel (#708)
* Add blog: How We Built a 4-Way Parallel Hybrid Search System

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* Add cover image for parallel hybrid search post

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* Update parallel hybrid search post date to 2026-03-27

* Set author to chrislatimer

* Update recall docs link

* review: align blog post to actual retrieval code

- Reframe as evolutionary narrative (V1 asyncio.gather → connection sharing)
- Add missing reranker section (cross-encoder + multiplicative boost scoring)
- Replace MPFP references with LinkExpansion (3-signal CTE)
- Fix SQL to match actual UNION ALL approach, explain CTE planner issue
- Fix acquire_with_retry, index types (ivfflat→HNSW), fusion code
- Remove fabricated perf numbers
- Add alpha calibration rationale and connection contention insight

* add nicoloboschi and benfrank241 as co-authors

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-03-27 11:21:01 -04:00
Nicolò Boschi f50cc25dfb perf(stats): add bank_id to memory_links for direct filtering (#718)
The stats endpoint JOINs memory_links to memory_units just to filter
by bank_id.  With 8.2M+ links per bank this takes 18+ seconds, and
the control plane polls every 10s — perpetually blocking the server.

Add bank_id column directly to memory_links so the query can filter
on ml.bank_id instead of mu.bank_id, letting Postgres push the filter
down before the JOIN.

- Migration: add bank_id TEXT NOT NULL, backfill from memory_units
- All 4 INSERT paths (temporal, semantic, entity, causal) now write bank_id
- Stats query filters on ml.bank_id instead of mu.bank_id
2026-03-27 16:12:13 +01:00
Kagura 6e90df9818 fix(docker): add graceful shutdown handler to prevent pg0 data loss on restart (#698)
* fix(docker): add graceful shutdown handler to prevent pg0 data loss on restart (#675)

- Trap SIGTERM/SIGINT in start-all.sh to forward signals to child processes
- pg0 (embedded PostgreSQL) now gets a clean shutdown with WAL flush
- 30-second timeout before force-killing unresponsive processes
- Add startup data integrity check: warn if pg0 data dir exists but PG_VERSION missing
- Improve wait loop robustness: trigger cleanup when any child exits unexpectedly

Fixes #675

* fix: address review feedback — re-entrant guard, timeout docs, cleaner glob

- Add SHUTTING_DOWN guard to prevent concurrent cleanup runs
- Document Docker stop_grace_period mismatch (30s cleanup vs 10s default)
- Replace find subprocess with compgen glob for PG_VERSION check
- Add comment explaining wait -n && true idiom
2026-03-27 16:03:10 +01:00
Chris BartholomewandNicolò Boschi dffb87080f fix(migrations): bypass PgBouncer for advisory locks via MIGRATION_DATABASE_URL (#726)
* fix(migrations): use HINDSIGHT_API_MIGRATION_DATABASE_URL when set

Session-level advisory locks are broken when the database URL goes
through PgBouncer in transaction mode: the backend connection is
returned to the pool on COMMIT, orphaning the lock, so multiple pods
can simultaneously run migrations for the same schema.

When HINDSIGHT_API_MIGRATION_DATABASE_URL is set, use it for both
the advisory lock connection and the Alembic run.  Callers should
point this at the direct PostgreSQL endpoint (bypassing the pooler)
so the session-level lock is held for the full migration duration.

* refactor(migrations): move MIGRATION_DATABASE_URL to standard config

Wire HINDSIGHT_API_MIGRATION_DATABASE_URL through HindsightConfig
instead of reading os.getenv() directly in migrations.py. Add the
field to the dataclass, from_env(), log_config(), all call sites,
.env.example, and the configuration docs page.

* fix: update test mocks for migration_database_url kwarg and regenerate docs skill

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-03-27 16:01:38 +01:00
Nicolò Boschi 1cac35728f fix: silence noisy google_genai.models INFO logging (#727)
* fix: silence noisy google_genai.models INFO logging

The google-genai SDK logs "AFC is enabled with max remote calls: 10"
at INFO level on every initialization. Set its logger to WARNING.

* fix: regenerate docs skill in release-integration script

The release script generates changelog/SDK pages but never re-ran
generate-docs-skill.sh, causing CI to fail with out-of-sync skill
files after every integration release. Now it regenerates the skill
and includes the output in the release commit.

Also adds the missing ag2 skill files from the latest release.
2026-03-27 16:01:23 +01:00
Chris Bartholomew 26e6877b53 fix(migration): use IF EXISTS when dropping chunk FK constraint (#725)
* fix(migration): use IF EXISTS when dropping chunk FK constraint

The migration unconditionally dropped memory_units_chunk_fkey, but
depending on the order in which migrations were applied the constraint
may not exist. Use raw SQL with IF EXISTS so the drop is safe regardless.

* fix(migration): make chunk FK add idempotent with DO block

The previous fix only handled the DROP side with IF EXISTS. The ADD side
could still fail with DuplicateObject when the FK already existed on a
schema that was provisioned after the base migration ran.

Wrap the ADD CONSTRAINT in a DO block to catch duplicate_object and
continue, making the migration fully idempotent in both directions.
2026-03-27 14:56:43 +01:00
1ac80bda6f fix(codex): resolve JSON serialization and logging exception propagation in codex_llm (#724)
Port fixes from #461 (claude_code_llm) to codex_llm:
- Replace json.dumps(result) with result.model_dump_json() for Pydantic models to fix TypeError during consolidation
- Wrap record_llm_call tracing block in try/except so logging failures never propagate to retry handler

Co-authored-by: Marco Rutsch <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-27 13:37:30 +01:00
Nicolò Boschi 3c78b717b0 docs: add AG2 integration page (#723)
- Add AG2 integration doc with quick start, configuration, GroupChat example, and API reference
- Add to sidebar, versioned sidebar, and integrations hub
- Add AG2 icon
2026-03-27 10:58:42 +01:00
Nicolò Boschi 9321c59bf1 release(ag2): v0.1.1 2026-03-27 10:12:30 +01:00
Nicolò Boschi 696d99ca1e chore(dev): add ag2 package name mapping for changelog generator 2026-03-27 10:12:14 +01:00
Nicolò Boschi 4b584e4d0b chore(dev): add ag2 to changelog generator valid integrations 2026-03-27 10:11:21 +01:00
Nicolò Boschi e5c7e166c5 fix(ag2): code cleanup and CI/release integration (#721)
- Remove unnecessary `pass` in HindsightError
- Add `Callable` return type annotations to create/register functions
- Use lazy logger formatting instead of f-strings
- Add test-ag2-integration CI job in test.yml
- Add ag2 to release-integration.sh valid integrations
2026-03-27 10:10:14 +01:00
Nicolò Boschi 083295dc6f feat: add audit log for feature usage tracking (#717)
* feat: add audit log for feature usage tracking

Add full auditability for all mutating and core API operations across
HTTP, MCP, and system (worker) transports. Audit entries record raw
request/response as JSONB, timing (started_at/ended_at), action, and
transport type.

Backend:
- New audit_log table with JSONB columns for expandability without
  future migrations (merge migration of 3 existing heads)
- AuditLogger with fire-and-forget writes via asyncio.create_task
- @audited decorator on 28 HTTP route handlers
- MCP tool audit wrapping for 16 auditable tools
- Worker task execution wrapped with audit_context
- List endpoint with action, transport, date range filters + pagination
- Stats endpoint with per-day counts for charting
- Configurable retention sweep (concurrent-safe DELETE)

Config (env-only, static):
- HINDSIGHT_API_AUDIT_LOG_ENABLED (default: false)
- HINDSIGHT_API_AUDIT_LOG_ACTIONS (comma-separated allowlist, empty=all)
- HINDSIGHT_API_AUDIT_LOG_RETENTION_DAYS (default: -1, keep forever)

Control Plane:
- New "Audit Logs" tab on bank configuration page
- Line chart showing request volume (today/7d/30d) with action filter
- Filterable table with action, transport, date range filters
- Paginated list with detail dialog showing raw request/response JSON

Tests:
- 13 tests covering list, filters, pagination, stats, disabled mode,
  action allowlist, and ordering

* fix: split 3-way merge migration into two 2-way merges

Alembic doesn't support 3-parent merge migrations. Split into a no-op
merge of 2 heads (b1c2d3e4f5g6) followed by the audit_log table
migration merging the third head.

* fix: correct merge migration to merge actual 2 heads

The original analysis incorrectly identified 3 heads. There were only 2
(a3b4c5d6e7f8 and c8e5f2a3b4d1). Remove the unnecessary intermediate
merge migration and fix the audit_log migration to merge these 2 heads.

* fix: use 'heads' instead of 'head' in migration runner

Alembic's upgrade('head') fails when multiple heads exist (e.g. from
namespace package overlaps between hindsight-api and hindsight-api-slim).
Using 'heads' (plural) handles this gracefully by upgrading all branches.

* chore: regenerate OpenAPI spec with audit log endpoints

* chore: regenerate TypeScript client and docs skill OpenAPI spec

Python and Go clients still need regeneration (requires Docker).

* chore: regenerate all client SDKs (Python, Go, TypeScript)

Adds generated audit log API clients for Python (audit_api.py),
Go (api_audit.go), and TypeScript client type updates.
2026-03-27 09:52:03 +01:00
Faridun Mirzoev 731238707d feat(integrations): add AG2 framework integration (#720)
Add hindsight-ag2 package providing persistent memory tools for AG2 agents via retain/recall/reflect operations.
2026-03-27 09:41:37 +01:00
Ben 62c0992075 Teaching the Llama to Remember (#707)
Llama index blog
2026-03-26 15:44:18 -04:00
Nicolò Boschi 02b0f7799d docs: add Volcano Engine as supported LLM provider (#715)
* docs: add Volcano Engine as supported LLM provider

Follow-up to #714. Add Volcano Engine (ByteDance) to the documentation:
- LLM providers grid component
- Provider list in configuration docs
- Provider example with base URL and default model
- Default model table in models page

* chore: regenerate docs skill references
2026-03-26 18:21:34 +01:00
Nicolò Boschi 7c18723fd9 fix(python-client): expose all configurable fields in update_bank_config() (#712)
Add 10 missing bank-configurable fields to update_bank_config():
- entity_labels, entities_allow_free_form
- consolidation_llm_batch_size, consolidation_source_facts_max_tokens,
  consolidation_source_facts_max_tokens_per_observation
- retain_default_strategy, retain_strategies
- reflect_source_facts_max_tokens
- mcp_enabled_tools
- llm_gemini_safety_settings

Previously these could only be set via raw PATCH to /config.
All new params are keyword-only with None defaults (backwards compatible).
2026-03-26 17:20:10 +01:00
shun yiandyishun.eason 417fac61e4 feat: add support for ark and volcano LLM providers (#714)
- Add 'ark' and 'volcano' as valid LLM providers (both are aliases for Volcano Engine)
- Set default model to 'doubao-pro-32k' for both providers
- Add them to OpenAICompatibleLLM provider list
- Exclude from json_object response format support

Co-authored-by: yishun.eason <[email protected]>
2026-03-26 17:14:13 +01:00
Nicolò Boschi 105cdf1fbf fix(python-client): expose all configurable fields in update_bank_config() (#712)
Add 10 missing bank-configurable fields to update_bank_config():
- entity_labels, entities_allow_free_form
- consolidation_llm_batch_size, consolidation_source_facts_max_tokens,
  consolidation_source_facts_max_tokens_per_observation
- retain_default_strategy, retain_strategies
- reflect_source_facts_max_tokens
- mcp_enabled_tools
- llm_gemini_safety_settings

Previously these could only be set via raw PATCH to /config.
All new params are keyword-only with None defaults (backwards compatible).
2026-03-26 16:29:09 +01:00
Nicolò Boschi a0cea84d82 docs(python-client): async-first pydoc + low-level API access + missing params (#711)
* docs(python-client): improve pydoc strings for async-first usage and low-level API access

- Class docstring now clearly documents async-first pattern: a* methods
  preferred, sync wrappers for scripts/REPLs only
- Every sync method docstring points to its async counterpart
- Every async method docstring says "preferred"
- Expose 10 low-level API properties (documents, entities, operations,
  webhooks, monitoring, etc.) so agents/users can discover the full API
  surface without guessing at _-prefixed internals
- Add missing API parameters: tag_groups (recall/reflect), fact_types,
  exclude_mental_models, exclude_mental_model_ids (reflect),
  observation_scopes/strategy (retain items), background (create_bank)
- Fix areflect missing include_facts param that sync reflect already had
- Sync recall/reflect now delegate to async counterparts (no logic duplication)

* style(retain): format long function call arguments one-per-line
2026-03-26 16:09:58 +01:00
Nicolò Boschi 200bab233e feat(openclaw): add recallInjectionPosition config to preserve prompt cache (#710)
* feat(openclaw): add recallInjectionPosition config to preserve prompt cache

Add configurable injection position for recalled memories to avoid
breaking prefix-based prompt caching (Anthropic/Google) when agents
have large static system prompts.

Options: 'prepend' (default, current behavior), 'append' (end of
system prompt, preserves cache), 'user' (before user message).

Closes #703

* docs(openclaw): document all plugin config flags

Add missing config options to the OpenClaw docs: recallTopK,
recallTypes, recallContextTurns, recallMaxQueryChars,
recallPromptPreamble, recallInjectionPosition, recallRoles,
retainEveryNTurns, retainOverlapTurns, and debug.
2026-03-26 16:09:25 +01:00
Nicolò Boschi c9ff37dcbf fix(python-client): async=true silently ignored on retain (#709)
* docs(claude-code): tidy configuration reference and sync README

Add missing settings (retainMode, retainToolCalls, retainTags,
retainMetadata, embedPackagePath, llmApiKeyEnv, agentName, and
several recall options) that existed in code but not in docs.
Restructure config tables with prose introductions, clearer
descriptions, and consistent layout across both files.

* refactor(claude-code): remove recallTopK setting

Unused client-side cap — Hindsight server already controls result
count via recallBudget and recallMaxTokens.

* fix(python-client): async=true was silently ignored on retain calls

The hand-written client wrapper passed `async_=retain_async` to
RetainRequest, but the generated Pydantic model uses `var_async` as the
Python field name (with `alias="async"`). The `async_` kwarg didn't
match either the field name or the alias, so Pydantic silently ignored
it — every retain call ran synchronously regardless of the flag.

This has been broken since the client was first introduced (6073ac4f),
not a regression.

Also adds unit tests that verify the async field serializes correctly
in the request JSON, preventing future regressions.
2026-03-26 15:21:43 +01:00
Nicolò Boschi 91397190c0 docs(claude-code): tidy configuration reference and sync README (#706)
* docs(claude-code): tidy configuration reference and sync README

Add missing settings (retainMode, retainToolCalls, retainTags,
retainMetadata, embedPackagePath, llmApiKeyEnv, agentName, and
several recall options) that existed in code but not in docs.
Restructure config tables with prose introductions, clearer
descriptions, and consistent layout across both files.

* refactor(claude-code): remove recallTopK setting

Unused client-side cap — Hindsight server already controls result
count via recallBudget and recallMaxTokens.
2026-03-26 14:07:35 +01:00
Nicolò Boschi fd88c0efa5 feat(retain): delta retain — skip LLM for unchanged chunks on upsert (#701)
* feat(retain): delta retain — skip LLM re-extraction for unchanged chunks on upsert

When upserting a document (same document_id), instead of deleting all
facts and re-extracting from scratch, compare chunk content hashes
and only process changed/new chunks. Unchanged chunks keep their
existing facts, entities, and links.

- Add content_hash column to chunks table (migration b3c4d5e6f7a8)
- Add chunk delta comparison functions in chunk_storage.py
- Add delta_mode to fact_storage.handle_document_tracking (skip full delete)
- Add update_memory_units_tags for propagating tag changes to existing facts
- Refactor orchestrator into _try_delta_retain and _full_retain paths
- Automatic fallback to full retain for pre-migration data or all-changed scenarios
- Fix ty type error in metrics.py (resource module import on Windows)
- 16 new tests covering entities, links, tags, metadata, edge cases

* refactor(retain): deduplicate delta and full retain paths

Extract shared _insert_facts_and_links() and _extract_and_embed()
functions used by both the full retain and delta retain paths.
Remove delta_mode flag from handle_document_tracking — delta path
uses dedicated upsert_document_metadata() instead.

* chore: regenerate clients, openapi spec, and lockfile

* chore: regenerate docs skill
2026-03-26 13:50:55 +01:00
Nicolò Boschi ea4df8dbb5 fix: resolve remaining Dependabot security alerts (#705)
- python-multipart: pin >=0.0.22 (arbitrary file write via non-default config)
- requests: pin >=2.33.0 in litellm, langgraph, crewai integrations (insecure temp file reuse)

Remaining unfixable alerts: diskcache (<=5.6.3, no patch) and Pygments (<=2.19.2, no patch).
2026-03-26 13:43:27 +01:00
Nicolò Boschi b6a4f17cbe fix: resolve all Dependabot security alerts (#702)
- requests: bump minimum to >=2.33.0 (CVE temp file reuse)
- streamlit: bump minimum to >=1.54.0 (SSRF/NTLM exposure)
- picomatch: add npm override for >=2.3.2/<3 || >=4.0.4 (ReDoS + method injection)
- flatted: tighten override to >=3.4.2 (prototype pollution)
- yaml: add npm override for >=1.10.3 (stack overflow)
- rustls-webpki: cargo update to 0.103.10 (CRL distribution point)
- Also fix pre-existing ty lint error in metrics.py (type: ignore for Windows resource import)
- Pygments: no patch available (<=2.19.2 vulnerable, no fix released)
2026-03-26 13:15:36 +01:00
Nicolò Boschi ffc96bec97 release(claude-code): v0.3.0 2026-03-26 12:58:25 +01:00
Nicolò Boschi 8cb8b9128e feat(claude-code): retain tool calls as structured JSON (#704)
When retainToolCalls is enabled (new default), the retention transcript
is output as JSON with full message structure including tool_use blocks
(Edit, Read, Bash, Grep, etc.) and their complete input dicts, plus
tool_result blocks (truncated at 2k chars). This preserves the context
of what actions the assistant actually took, not just its narration.

Hindsight MCP tools (recall/retain/reflect) are excluded to prevent
feedback loops. Channel message tools still get their text extracted
inline. Setting retainToolCalls=false falls back to the legacy text
format.
2026-03-26 12:58:09 +01:00
Nicolò Boschi 64d96a9c53 release(claude-code): v0.2.0 2026-03-26 12:11:28 +01:00
Nicolò Boschi 9dedac1dbd chore: add claude-code package name and display name to changelog generator 2026-03-26 12:10:42 +01:00
Nicolò Boschi 413ddbb45d chore: add claude-code to changelog generation valid integrations 2026-03-26 12:09:32 +01:00
Nicolò Boschi 246912f596 chore: add claude-code to release-integration script
Support plugin.json version bumping for Claude Code plugin releases.
2026-03-26 12:08:23 +01:00
Nicolò Boschi 2d31b67d0c feat(claude-code): full-session retain with document upsert and configurable tags (#695)
* feat(claude-code): full-session retain mode with document upsert and configurable tags

Switch default retain behavior from per-turn chunks to full-session upsert.
Each session is now retained as a single document (document_id = session_id)
that gets updated on every Stop event, instead of creating fragmented
documents with timestamp-suffixed IDs.

New config options:
- retainMode: "full-session" (default) or "chunked" (legacy)
- retainTags: list with template variable support ({session_id}, {bank_id}, {timestamp})
- retainMetadata: extra metadata dict merged with built-in fields, supports templates

* fix(claude-code): respect retainEveryNTurns in full-session mode

The turn-count gating was only applied in chunked mode, meaning
full-session mode would re-ingest the entire transcript on every
single Stop event. Now retainEveryNTurns gates both modes.

Also fix test isolation: resolve ~/.hindsight/claude-code.json at
call time (not module load) so HOME override in tests works correctly.

* fix(claude-code): fix config tests after USER_CONFIG_PATH removal

Update tests to use HOME env var override instead of monkeypatching
the removed USER_CONFIG_PATH constant. Add autouse fixture to
TestLoadConfig to isolate all config tests from real user config
and HINDSIGHT_* env vars.
2026-03-26 12:06:30 +01:00
Nicolò Boschi 349c112c61 docs: add supported platforms and Windows installation guide (#700)
* docs: add supported platforms section and Windows installation guide

Adds a platform compatibility table (Linux, macOS, Windows) and a
dedicated Windows setup section with step-by-step instructions for
installing PostgreSQL + pgvector and running Hindsight natively.
Follows up on #699 which added Windows native support.

Also fixes a ty type-check error in metrics.py for the conditional
resource module import.

* chore: sync generated clients and lock file after #699

Regenerate client SDKs to pick up ValidationError model changes
and update uv.lock with platform-specific uvloop/winloop deps.

* docs: update Windows section — pg0 now supports Windows

pg0 v0.12.0 added Windows support, so embedded DB works everywhere.
Restructure Windows section to show simple install-and-run first,
with external PostgreSQL as an optional alternative.

* chore: sync generated docs skill and openapi references
2026-03-26 12:01:30 +01:00
Mr. Khachaturov 939cb40a73 fix: include Pydantic v2 fields in ValidationError OpenAPI schema (#697)
FastAPI generates the ValidationError schema with only loc, msg, and
type, but Pydantic v2 actually returns input, ctx, and url as well.
Generated clients with strict JSON decoding (Go's DisallowUnknownFields)
cannot parse real 422 responses — the actual validation message gets
replaced by a confusing JSON decoding error.

- Patch the OpenAPI schema in create_app() to add input, ctx, url
- Regenerate spec and Go client
2026-03-26 11:25:46 +01:00
grimmjoww578andClaude Opus 4.6 c5700ff5b4 feat: Windows native support — run Hindsight without Docker (#699)
* feat: Windows native support — run Hindsight without Docker on Windows

Four compatibility fixes that allow Hindsight to run natively on Windows
with an external PostgreSQL + pgvector installation:

1. **pyproject.toml**: Conditional event loop dependency
   - `winloop` on Windows (sys_platform == 'win32')
   - `uvloop` on Linux/macOS (sys_platform != 'win32')

2. **main.py**: winloop integration via `winloop.install()`
   - Patches asyncio event loop policy globally before uvicorn starts
   - uvicorn sees "asyncio" but runs winloop underneath (same perf as uvloop)
   - Falls back to default asyncio if winloop unavailable

3. **metrics.py**: Guard `resource` module import
   - `resource` is Unix-only (getrusage, getrlimit)
   - Conditional import with None fallback
   - Skip process metrics collection on Windows

4. **fact_storage.py**: Cross-platform strftime
   - `%-d` (no-padding day) is glibc-only, fails on Windows
   - Replaced with `%d` + `.replace(" 0", " ")` for same output

## Windows Setup Guide

### Prerequisites
- Python 3.11+
- PostgreSQL 17 with pgvector extension
- Ollama (for local embeddings) or external embedding provider

### Install PostgreSQL + pgvector on Windows
```bash
winget install PostgreSQL.PostgreSQL.17

# Build pgvector from source (requires Visual Studio Build Tools)
git clone https://github.com/pgvector/pgvector.git
# In x64 Native Tools Command Prompt:
set PGROOT=C:\Program Files\PostgreSQL\17
nmake /F Makefile.win
nmake /F Makefile.win install

# Enable extension
psql -U postgres -d hindsight -c "CREATE EXTENSION IF NOT EXISTS vector;"
```

### Install and Run Hindsight
```bash
pip install -e ".[embedded-db]"

# Set environment variables
set HINDSIGHT_API_LLM_PROVIDER=openai
set HINDSIGHT_API_LLM_API_KEY=your-api-key
set HINDSIGHT_API_LLM_BASE_URL=https://your-llm-endpoint/v1
set HINDSIGHT_API_LLM_MODEL=your-model
set HINDSIGHT_API_DATABASE_URL=postgresql://postgres@localhost:5432/hindsight
set HINDSIGHT_API_EMBEDDING_PROVIDER=ollama
set HINDSIGHT_API_PORT=8889

hindsight-api
```

Data persists in PostgreSQL on your local disk — survives reboots,
updates, and anything that would wipe a Docker volume.

Tested on Windows 11 with PostgreSQL 17.9, pgvector 0.8.2,
Python 3.11, RTX 5080 (CUDA embeddings + reranking).

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

* fix: handle strftime ValueError on Windows in fact_storage

The strftime call on occurred_start/occurred_end can raise ValueError
on Windows when the datetime object has unexpected format properties.
Wrap in try/except to gracefully skip date signal rather than crash
the entire retain batch.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-26 11:23:03 +01:00
Nicolò Boschi 6bb83f4600 fix: control plane UI fixes for recall and data view (#693)
* fix: control plane UI fixes for recall and data view

- Sanitize NaN cross-encoder scores to 0.0 in reranking pipeline
  (Pydantic serializes NaN as JSON null, breaking UI score display)
- Add null-coalesce for score in search debug view to prevent crash
- Switch data view text filter from debounced onChange to Enter key
  (avoids slow ILIKE queries on every keystroke for large banks)
- Show loading spinner in search icon during filter requests
- Preserve search/tag filters when clicking "Load more"

* chore: sync generated files after rebase
2026-03-25 18:42:57 +01:00
Ben a94a90ea3f fix(claude-code): make fcntl import conditional for Windows compatibility (#694)
fcntl is a Unix-only module — importing it unconditionally causes an
ImportError on Windows, breaking the entire plugin. Guard the import with a
sys.platform check and fall back to a no-op lock path in
increment_turn_count() so Windows users get correct behaviour without
crashing.
2026-03-25 18:20:27 +01:00
Nicolò Boschi 9e5a066d26 feat: add 'none' LLM provider for chunk-only storage mode (#691)
Adds a proper 'none' provider option so users can run Hindsight as a
chunk store with semantic search but without any LLM dependency, replacing
the hacky workaround of setting provider to 'mock'.

When HINDSIGHT_API_LLM_PROVIDER=none:
- Retain automatically uses chunks mode (no fact extraction)
- Recall works normally (semantic search, BM25, graph retrieval)
- Reflect returns HTTP 400 with clear error message
- Consolidation/observations are disabled
- Mental model refresh returns HTTP 400
- No API key required
2026-03-25 18:01:20 +01:00
Nicolò Boschi 5095d5e36f feat(reflect): make source facts in search_observations configurable (#688)
* feat(reflect): make source facts in search_observations configurable

The recent fix (#669) hardcoded include_source_facts=False in
search_observations to prevent context overflow. This makes it
configurable via HINDSIGHT_API_REFLECT_SOURCE_FACTS_MAX_TOKENS
(env/tenant/bank), defaulting to -1 (disabled).

- -1: source facts disabled (current behavior, default)
- 0: source facts enabled with no token limit
- >0: source facts enabled with a token budget

* docs: add reflect_source_facts_max_tokens to configuration reference

* fix: update configurable fields count in tests and regenerate docs skill
2026-03-25 17:54:49 +01:00
Ben 22ca6a8d73 fix: add setup_hooks.py and hindsight:setup skill for hook registration (#690)
Claude Code's plugin installer does not merge hooks.json into settings.json
automatically. This adds a setup script and skill that users can run once
after installing the plugin to register the hooks manually.
2026-03-25 16:59:25 +01:00
Nicolò Boschi 0ff36548e0 feat(hermes): file-based config + updated docs (#686)
* feat(hermes): file-based config + updated docs

Replace the old dataclass/configure() singleton with a plain dict
config loaded from ~/.hindsight/hermes.json — same field names and
conventions as the openclaw and claude-code integrations.

Loading order: defaults → config file → env var overrides.

- config.py: rewritten with load_config() returning a plain dict,
  DEFAULTS matching openclaw/claude-code fields, ENV_OVERRIDES with
  typed casting
- tools.py: register() uses load_config() instead of raw env vars
- __init__.py: clean exports (removed configure/get_config/reset_config)
- README.md: full rewrite with config file examples, tables by category
- docs/hermes.md: full rewrite with quick start, architecture, all
  config tables, gateway section, troubleshooting
- tests: updated for new config pattern, 46 tests pass

* ci: add test job for hermes integration

* chore: regenerate docs skill for hermes integration
2026-03-25 16:11:38 +01:00
Ben d344ef26da blog: Your AWS Strands Agent Forgets Everything Between Runs. Here's the Fix. (#685)
* blog: add Strands persistent memory post
2026-03-25 10:37:37 -04:00
Nicolò Boschi 4fed005662 ci: skip unrelated jobs based on changed paths (#687)
Add a detect-changes job using dorny/paths-filter to determine which
parts of the monorepo changed, then gate each CI job with appropriate
conditions. This avoids running all ~30 jobs for docs-only or
integration-only changes.

Key behaviors:
- Docs/README-only changes only run build-docs and test-doc-examples
- Integration package changes only run their specific test job
- Client SDK changes only run their build/test + dependent jobs
- Core API changes run all API-dependent jobs
- CI config changes (.github/**) run everything as a safety net
- workflow_dispatch (manual) always runs everything
- verify-generated-files always runs unconditionally
2026-03-25 15:34:02 +01:00
Nicolò Boschi b42b35bf93 feat(embed): add programmatic UI (control plane) management (#683)
* feat(embed): add programmatic UI (control plane) management

Add ability to start/stop the web UI from hindsight-embed, with
configurable port (default: daemon_port + 10000) and hostname
(default: 0.0.0.0). Uses npx to run the published control plane
package, or node directly in dev mode.

New CLI commands:
  hindsight-embed ui start [--port PORT] [--hostname HOST]
  hindsight-embed ui stop [--port PORT]
  hindsight-embed ui status [--port PORT]
  hindsight-embed ui logs [-f] [-n N]

New programmatic API:
  daemon_client.start_ui(profile, ui_port, hostname)
  daemon_client.stop_ui(profile, ui_port)
  daemon_client.is_ui_running(profile, ui_port)
  daemon_client.get_ui_url(profile, ui_port)

* feat(embed): expose UI management on HindsightEmbedded

Add start_ui(), stop_ui(), is_ui_running(), and ui_url property
to HindsightEmbedded so the UI can be started programmatically:

  client = HindsightEmbedded(profile="myapp", ...)
  client.start_ui()  # starts daemon + UI
  print(client.ui_url)
2026-03-25 14:38:32 +01:00
Nicolò Boschi db70fdbe5e feat: add LiteLLM LLM provider for Bedrock and 100+ providers (#679)
* feat: add LiteLLM LLM provider for Bedrock and 100+ providers

Add a new `litellm` LLM provider that uses the LiteLLM SDK for chat
completions and tool calling, enabling AWS Bedrock and 100+ other
providers for Hindsight's core engine (retain, recall, reflect).

- New LiteLLMLLM provider in engine/providers/litellm_llm.py
- Registered in factory, valid providers list, and no-api-key set
- Refactored API key validation to use requires_api_key() helper
- Added boto3 dependency for Bedrock auth
- Updated docs: configuration, models, monitoring, providers grid

* feat: add bedrock as first-class LLM provider alias

Add `bedrock` as a dedicated provider name that auto-prepends the
`bedrock/` prefix to model names and delegates to LiteLLMLLM under
the hood. This makes Bedrock support more discoverable — users set
`HINDSIGHT_API_LLM_PROVIDER=bedrock` with plain Bedrock model IDs.

* test: add Bedrock to CI provider tests

- Add bedrock/us.amazon.nova-lite-v1:0 to MODEL_MATRIX in test_llm_provider.py
- Add AWS credential check in should_skip_provider()
- Pass AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION_NAME secrets to test-api job
- Update default bedrock model to amazon.nova-2-lite-v1:0

* fix: regenerate docs skill files and bump memory test timeout

- Regenerate skills/hindsight-docs references after docs changes
- Bump test_llm_provider_memory_operations timeout to 600s for slower
  providers like Bedrock via LiteLLM

* test: skip bedrock lite models in memory operations test

Nova Lite has a 10K output token limit which is too low for fact
extraction (requires 64K). The api_methods test (completion, tools,
structured output) already validates the provider works correctly.

* test: use Nova Pro for bedrock CI tests to cover full memory pipeline

Nova Lite only supports 10K output tokens, too low for fact extraction.
Switch to Nova Pro which supports the full 64K output needed for
retain/reflect operations. This ensures bedrock is tested on all
Hindsight functionalities, not just basic API methods.

* test: switch bedrock CI to Nova 2 Lite (supports 64K output tokens)

Nova v1 models (Pro, Lite) have a 10K output token limit which is
too low for fact extraction. Nova 2 Lite supports 64K+ output tokens,
enabling full memory pipeline testing (retain + reflect).
2026-03-25 14:17:38 +01:00
Philipp OppolzerandPhilipp c5273f5fd4 fix: coerce JSON-string tags to list in MemoryItem and MCP tools (#682)
MCP tool bridges sometimes serialize JSON arrays as strings during
transport, e.g. '["a", "b"]' arrives as the literal string '["a", "b"]'
instead of a native JSON array. This causes Pydantic to reject the
input with a validation error.

Add defensive coercion at two layers:

1. HTTP API (http.py): Pydantic field_validator on MemoryItem.tags
   with mode="before" that parses JSON strings back into lists.
2. MCP tools (mcp_tools.py): Same coercion in build_content_dict
   before tags reach the Pydantic model.

A plain non-JSON string is wrapped in a single-element list.
Correctly-formatted input is passed through unchanged.

Co-authored-by: Philipp <[email protected]>
2026-03-25 14:16:47 +01:00
Philipp OppolzerandPhilipp 4285e94406 feat(mcp): add strategy parameter to retain tool (#684)
Expose the named retain strategy on the MCP retain tool, matching the
HTTP API's per-item strategy support. This allows MCP clients (Claude
Code, Claude Desktop, etc.) to specify extraction behavior per memory:

  strategy: "exact"   → verbatim storage, no LLM processing
  strategy: "verbose" → detailed extraction
  strategy: "concise" → default compressed extraction

Strategies are defined in bank config under retain_strategies.
Unknown strategy names are logged and ignored (bank default applies).

Changes:
- Add strategy param to both retain function signatures (with/without bank_id)
- Add strategy to build_content_dict
- Strategy is set in the content dict, which the engine already handles per-item

Co-authored-by: Philipp <[email protected]>
2026-03-25 14:16:24 +01:00
Nicolò Boschi 35dfd3aa0c fix(hermes): use async client methods to prevent event loop deadlock (#677) (#681)
Tool handlers and lifecycle hooks now use the native async client API
(aretain, arecall, areflect, acreate_bank) instead of sync wrappers
that call loop.run_until_complete(), which deadlocks in async contexts
like Discord/Telegram gateways.
2026-03-25 11:25:06 +01:00
Nicolò Boschi 0bcbf8491b fix: return metadata in recall responses (#680)
* fix: return metadata in recall responses (#674)

Metadata stored during retain was never retrieved during recall.
Add metadata to all SQL SELECT queries, the RetrievalResult dataclass,
ScoredResult.to_dict(), and MemoryFact construction in the recall pipeline.

* test: add metadata round-trip test for retain→recall

Replace placeholder metadata test with one that actually passes
metadata via retain_batch_async and asserts it is returned on recall.

* fix: parse metadata JSON string from database in MemoryFact

asyncpg may return JSONB columns as strings. Add a field_validator
to MemoryFact.metadata to handle JSON string deserialization.
2026-03-25 11:24:18 +01:00
Nicolò Boschi f0f0d554f2 security: exclude litellm 1.82.8 (supply chain compromise) (#673)
* security: exclude litellm 1.82.8 (supply chain compromise)

litellm 1.82.8 on PyPI contains a malicious .pth file that
automatically steals credentials on Python startup (no import needed).
See: https://github.com/BerriAI/litellm/issues/24512

Our Docker images ship 1.82.6 and are unaffected, but the open version
constraints (>=1.0.0, >=1.40.0) would allow resolving to 1.82.8 on
fresh installs or lockfile refreshes.

* security: cap litellm at <=1.82.6 (1.82.7 also compromised)

* chore: regenerate uv.lock and openapi spec

* fix: update test to match claude-haiku-4-5 default model name and regenerate docs skill

* chore: fix ruff formatting in generate_changelog.py
2026-03-25 10:21:02 +01:00
Ben 0ad6ee3156 Blog: Adding Long-Term Memory to LangGraph and LangChain Agents (#637)
* Add blog post: Adding Long-Term Memory to LangGraph and LangChain Agents

* blog: update langgraph post date to 2026-03-24 and add cover image

* blog: fix claude-code-telegram filename to match frontmatter date (2026-03-25)

* blog: set claude-code-telegram date to 2026-03-23

* blog: fix date timezone offset by adding T12:00 to all post dates

* ci: trigger fresh CI run

* blog: fix broken docs link (routeBasePath is /)
2026-03-24 13:51:27 -04:00
Nicolò Boschi 39bf6820d6 release(strands): v0.1.1 2026-03-24 17:42:52 +01:00
Nicolò Boschi 8ef9c48a62 fix: add strands to changelog generator valid integrations 2026-03-24 17:42:41 +01:00
Ben 7fe773c0ee feat: add Strands Agents SDK integration with Hindsight memory tools (#659)
* feat: add Strands Agents SDK integration with Hindsight memory tools

* fix: add strands docs to versioned docs so build link check passes

* fix(strands): run hindsight client calls in thread pool to avoid event loop conflict with Strands
2026-03-24 17:21:30 +01:00
Nicolò Boschi 58e68f3e4a feat: remove hardcoded default models from integrations (#670)
* feat(openclaw): remove hardcoded default models, rely on Hindsight API defaults

* feat(claude-code): remove hardcoded default models, rely on Hindsight API defaults

* feat(claude-code,docs): remove hardcoded default models from claude-code integration and docs

* feat: use claude-haiku-4-5 as default Anthropic model
2026-03-24 17:20:15 +01:00
Nicolò Boschi 4f533dde94 docs: 0.4.20 release blog post and changelog (#671)
* docs: add 0.4.20 release blog post and changelog

Add release notes blog post covering Claude Code integration, LangGraph
integration, NemoClaw integration, independent integration versioning,
and reflect improvements. Auto-generated changelog entry included.

* docs: add 0.4.20 release blog cover image
2026-03-24 10:03:33 +01:00
Nicolò Boschi 08d2c78ae7 Release v0.4.20
- Update version to 0.4.20 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.4
2026-03-24 09:19:14 +01:00
KaguraandKagura Chen 8e2e2d5bf2 fix(reflect): disable source facts in search_observations to prevent context overflow (#669)
search_observations in the reflect agent hardcoded include_source_facts=True
with max_source_facts_tokens=-1 (unlimited). For banks with many observations
backed by thousands of facts, a single tool call could produce 300K+ tokens,
exceeding the default 100K context budget and causing forced synthesis with
an empty 'Retrieved Data' section.

The reflect agent synthesizes from observations, not raw backing facts.
Disable source facts to keep payloads proportional to observation count
(~6K vs ~310K in the reporter's case).

The consolidation path already has configurable source fact limits (PR #509,
v0.4.17). The reflect path was not updated.

Fixes #668

Co-authored-by: Kagura Chen <[email protected]>
2026-03-24 09:12:54 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 4a55068db7 chore(deps): bump actions/setup-python from 5 to 6 (#654)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 07:48:44 +01:00
Ben e1f539c612 blog: add cover images to AMB, Claude Code Telegram, and NemoClaw posts (#667)
* blog: add cover images to AMB, Claude Code Telegram, and NemoClaw posts

* blog: remove redundant landing image from AMB post
2026-03-23 16:23:02 -04:00
Nicolò Boschi 742f212b2f docs: update blog 2026-03-23 18:14:04 +01:00
Nicolò Boschi f2b0ff7d38 Update author in agent memory benchmark blog post 2026-03-23 17:57:13 +01:00
Nicolò Boschi 8ae3ae13a6 Update 2026-03-23-agent-memory-benchmark.mdx 2026-03-23 17:56:44 +01:00
Nicolò Boschi 546d595c9f feat(blog): Agent Memory Benchmark launch post (#657)
* feat(blog): launch Agent Memory Benchmark post and ImageCarousel component

* feat(blog): remove RAG terminology, add agentic eval framing
2026-03-23 17:51:51 +01:00
Nicolò Boschi 26944e25bc fix(claude-code): pre-start daemon in background on SessionStart hook (#663)
Daemon cold start takes ~25s but hooks have short timeouts, causing
retain to time out on first use. Fix by firing daemon startup as a
detached background process in SessionStart so it warms up before the
first recall/retain hook fires.

Also bumps the daemon start timeout in _ensure_daemon_running from 10s
to 30s as a fallback for when retain fires before pre-start completes.
2026-03-23 16:11:57 +01:00
Nicolò Boschi e6333719ee fix(entity_resolver): prevent _pending_stats/_pending_cooccurrences memory leak (#662)
* fix(entity_resolver): prevent _pending_stats/_pending_cooccurrences memory leak

Add discard_pending_stats() to EntityResolver to clean up both pending dicts
for the current task key. Call it at the start of each _run_db_work attempt so
that exceptions between accumulation and flush_pending_stats() — including
deadlock retries — never leave stale entries keyed by recycled task IDs.

Fixes #660

* test(entity_resolver): add unit tests for discard_pending_stats()

Covers: clears both dicts for current task, is idempotent when empty,
and does not touch entries belonging to other task keys.
No database required — purely in-memory logic.
2026-03-23 16:06:04 +01:00
Nicolò BoschiandBen d886d3acb9 doc: Claude Code + Telegram + Hindsight blog post (#656)
* doc: add Claude Code + Telegram + Hindsight blog post

* doc: add fabioscarsi to blog authors

* doc: update fabioscarsi title to Contributor

* doc: remove horizontal rule dividers from blog post

* doc: update cover image and add image frontmatter for claude-code-telegram blog post

* doc: remove horizontal rule dividers

* doc: align Hindsight setup steps with PR #661 README

* fix: move marketplace.json to repo root and update source path

* doc: add Claude Code integration page, sidebar, and integrations hub entry

* doc: update versioned docs to 0.4.19

---------

Co-authored-by: Ben <[email protected]>
2026-03-23 15:44:07 +01:00
Nicolò Boschi 35b2cbb6ed fix(claude-code): fix plugin installation, config UX, and release workflow (#661)
* fix(claude-code): fix plugin installation and release workflow

- Fix plugin.json author field (string → object) to pass claude plugin validate
- Add hindsight-integrations/.claude-plugin/marketplace.json so users can install
  via: claude plugin marketplace add vectorize-io/hindsight --sparse hindsight-integrations
- Update README and install.sh with correct two-command install flow
- Fix release-integration.yml: add explicit package.json check for typescript type
  and add plugin type for integrations with neither pyproject.toml nor package.json
  (prevents claude-code from incorrectly falling into the typescript build path)
- Add CHANGELOG.md for the claude-code integration

* remove install.sh — users install via claude plugin commands directly

* test(claude-code): add 116 unit tests for plugin hooks and lib modules

* feat(claude-code): user settings.json at CLAUDE_PLUGIN_DATA for stable config

Plugin now checks CLAUDE_PLUGIN_DATA/settings.json after the versioned
plugin default, giving users a path that persists across updates:
  ~/.claude/plugins/data/hindsight-memory-hindsight/settings.json

Loading order: defaults → plugin settings.json → user settings.json → env vars

* fix(claude-code): use ~/.hindsight/claude-code.json for user config

Matches the ~/.openclaw/openclaw.json convention. Removes the confusing
CLAUDE_PLUGIN_DATA path whose name depends on marketplace+plugin identifiers.

* docs(claude-code): add ToS hint for claude-code LLM provider option

* fix(claude-code): set author to Hindsight Team in plugin.json

* ci: add test-claude-code-integration job to run plugin unit tests
2026-03-23 15:15:16 +01:00
Fabio ScarsiandClaude Opus 4.6 f4390bdc2e feat: Add Claude Code integration plugin (#651)
* feat: Add Claude Code integration plugin

Complete port of hindsight-openclaw (v0.4.19) adapted to Claude Code's
hook-based plugin architecture. Pure Python stdlib, no external dependencies.

- Auto-recall via UserPromptSubmit hook (additionalContext injection)
- Auto-retain via async Stop hook (chunked retention with sliding window)
- Daemon management (auto-start/stop hindsight-embed via uvx)
- Dynamic bank IDs with per-agent/project/channel/user granularity
- All 34 configuration options with env var overrides
- File-based state persistence with fcntl locking
- Graceful degradation on all error paths

Works with Claude Code Channels (Telegram, Discord, Slack) and
interactive sessions.

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

* fix: Set correct chunked retention defaults (10/2, not 1/0)

retainEveryNTurns=10 and retainOverlapTurns=2 are the production-tested
values — every 10 turns, retain a 12-turn sliding window. The previous
defaults (1/0) would retain every single turn with no overlap, defeating
the chunked retention design that prevents API bombardment.

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

* fix: Align recallBudget and daemonIdleTimeout with Openclaw defaults

recallBudget: "low" → "mid" (Openclaw default)
daemonIdleTimeout: 300 → 0 (Openclaw default, never auto-stop)

As an official Hindsight integration, defaults should match Openclaw.
Users can optimize locally via settings.json or env vars.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-23 12:06:54 +01:00
Mr. Khachaturov e0f0da5d2d docs: update HindClaw integration listing (#653)
Rename hindsight-openclaw-pro → HindClaw and update description to
reflect the current architecture: server-side Hindsight extensions
(hindclaw-extension on PyPI), Terraform provider for infrastructure
management, and the hindclaw-openclaw gateway plugin.

Link points to https://github.com/mrkhachaturov/hindclaw.
2026-03-23 11:11:00 +01:00
Nicolò Boschi a9e6d9f731 test: add unit tests for pg_trgm auto-detection and ValidationResult.accept_with() enrichment (#650)
* test: add unit tests for pg_trgm auto-detection and ValidationResult.accept_with() enrichment

Two recent PRs landed without dedicated tests:
- #626/#649 (pg_trgm fallback in EntityResolver): add 5 mocked unit tests
  covering the trigram→full fallback, single-check guarantee, and sticky
  downgrade behaviour.
- #639 (accept_with() enrichment): add 7 pure unit tests for the factory
  method plus 5 integration tests verifying the engine applies enriched
  contents (retain) and tags/tag_groups (recall) returned by validators.
  Also verifies RecallContext carries tag filter state.

* fix: remove 504 from reflect OpenAPI spec to fix progenitor Rust client build

progenitor-impl-0.11.2 panics with `assertion failed: response_types.len() <= 1`
when an endpoint declares more than one response type. PR #643 added
`responses={504: ...}` to the reflect decorator, which injected a second
response type into the generated OpenAPI spec and broke the Rust client build.

Remove the `responses=` kwarg — the 504 is still raised at runtime via
JSONResponse(status_code=504), it just won't appear in the OpenAPI schema.
Regenerate openapi.json accordingly.

* chore: sync generated files and ruff formatting (lint + docs skill)
2026-03-23 10:33:09 +01:00
8ce06e3e7c Add wall-clock timeout to reflect operations (#643)
* Initial plan

* feat: add wall-clock timeout to reflect operations (fixes vectorize-io/hindsight#642)

Add a configurable wall-clock timeout (default: 300s / 5 minutes) for
the entire reflect operation. This prevents reflect calls from hanging
for up to 40 minutes when LLM calls are slow or iteration counts are
high.

Changes:
- Add DEFAULT_REFLECT_WALL_TIMEOUT (300s) config constant
- Add HINDSIGHT_API_REFLECT_WALL_TIMEOUT env variable support
- Wrap run_reflect_agent() with asyncio.wait_for() in reflect_async()
- Return HTTP 504 on timeout in the reflect HTTP endpoint
- Add unit test for wall-clock timeout enforcement

Co-authored-by: ThePlenkov <[email protected]>
Agent-Logs-Url: https://github.com/ThePlenkov/hindsight/sessions/a123d68b-aca1-4040-8bba-8c4f0fab2e2c

* fix: address PR review findings (OpenAPI 504, docs, type hints, main.py TypeError, overlapping exceptions, lazy logging)

Co-authored-by: ThePlenkov <[email protected]>
Agent-Logs-Url: https://github.com/ThePlenkov/hindsight/sessions/dd574a88-53a3-4f9e-bba7-5a40b0eddb99

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: ThePlenkov <[email protected]>
2026-03-23 09:19:26 +01:00
Coderandcoder999999999 365fa3ce50 Fix pg_trgm unavailability causing startup crash and silent retain failures (#626) (#649)
On managed PostgreSQL services (e.g. Azure Flexible Server), the pg_trgm
extension may not be available, causing two failures:

1. Migration c1a2b3d4e5f6 crashes on CREATE EXTENSION
2. Even if migration is bypassed, the default 'trigram' entity lookup
   strategy uses the % operator which requires pg_trgm, causing retain
   background tasks to fail silently

Changes:
- Migration now gracefully skips pg_trgm and index creation if the
  extension cannot be loaded
- EntityResolver auto-detects pg_trgm availability on first use and
  falls back to 'full' lookup strategy with a warning log

Co-authored-by: coder999999999 <[email protected]>
2026-03-23 09:18:59 +01:00
Mr. Khachaturov 2eb1019da9 feat(extensions): add context enrichment to OperationValidatorExtension (#639)
Validators can now return enriched data via ValidationResult.accept_with()
instead of only accepting or rejecting operations. The engine applies
returned fields (contents, tags, tag_groups) to the operation parameters.

- Add accept_with() factory to ValidationResult with optional enrichment
  fields: contents, tags, tags_match, tag_groups
- Add tags, tags_match, tag_groups to RecallContext so validators can
  see current filter state
- Update _validate_operation to return ValidationResult
- Apply enrichment from result at all retain (2 sites) and recall call
  sites in MemoryEngine
- Existing validators using accept()/reject() work unchanged
2026-03-23 08:57:02 +01:00
Sebastian B Otaeguiandfeniix 2f2db2a6e2 fix: strip markdown code fences from all LLM providers, not just local (#646)
LLM providers like MiniMax wrap JSON responses in markdown code fences
(```json ... ```), causing JSON parse failures and 5-11 retries per
extraction. The existing fence stripping logic was gated to only
"lmstudio" and "ollama" providers (and for Ollama, unreachable due to
the _call_ollama_native redirect).

Changes:
- Extract _strip_code_fences() helper function
- Apply fence stripping to all providers in call() (not just local)
- Add fence stripping safety net to _call_ollama_native()
- Add 10 tests covering bare JSON, fenced JSON, malformed fences,
  and real-world MiniMax response format

Fixes vectorize-io/hindsight#645

Co-authored-by: feniix <feniix@desktop>
2026-03-22 21:29:16 +01:00
Vitali Avagyan caa53ee370 docs: add gitcgr code graph badge (#648) 2026-03-22 21:28:29 +01:00
Nicolò Boschi 5cdc714a38 fix(recall): reject empty queries with 400 and fix SQL parameter gap (#632)
* fix(recall): reject empty queries with 400 and fix SQL parameter gap causing IndeterminateDatatypeError

When query text contains only punctuation/symbols (no word characters after
normalization), the BM25 arms are skipped but the old code still placed `limit`
at \$3 in the params list. If tags or tag_groups were also set, their params
(\$4+) were referenced in the SQL while \$3 was a gap, causing PostgreSQL to
raise IndeterminateDatatypeError.

Fix the parameter layout so `limit` is only appended to params when tokens are
present (i.e. when BM25 arms actually use LIMIT \$3), and shift tags_param_idx
from 4 to 3 in the no-tokens path.

Also add a field_validator on RecallRequest.query that rejects empty-after-
normalization queries at the API layer with a 400 before they reach the DB.

* refactor: extract tokenize_query helper and reuse in RecallRequest validator
2026-03-21 20:24:36 +01:00
Simon Oberreuterandsoberreu <soberreu> 78aa7c537e Fix: POST files/retain uses authentication headers (#636)
Co-authored-by: soberreu <soberreu>
2026-03-21 20:24:12 +01:00
Andrew Barnes 3f31cbf505 fix: allow claude-agent-sdk installation on Linux/Docker (#644)
Remove the sys_platform == 'darwin' constraint that prevented
claude-agent-sdk from installing on Linux, breaking the claude-code
provider in Docker containers.

Fixes #640
2026-03-21 20:23:38 +01:00
Nicolò Boschi b7abf8565a release(litellm): v0.5.0 2026-03-21 09:18:40 +01:00
Nicolò Boschi 682cbf38ee chore(litellm): update uv.lock 2026-03-21 09:18:26 +01:00
Nicolò Boschi 5e8952c54a fix(litellm): fall back to last user message when hindsight_query not provided (#641)
* fix(litellm): fall back to last user message when hindsight_query not provided

inject_memories=True no longer requires an explicit hindsight_query. The
injection path now falls back to extracting the last user message, matching
the documented Quick Start behavior that was broken since #167 (v0.4.18).

* test(litellm): add regression tests for inject_memories without hindsight_query
2026-03-21 09:16:58 +01:00
DK09876andClaude Opus 4.6 8364b9c5d5 fix: MCP tool calls fail when MCP_AUTH_TOKEN and TENANT_API_KEY differ (#635)
* fix: MCP tool calls fail when MCP_AUTH_TOKEN and TENANT_API_KEY differ

When both HINDSIGHT_API_MCP_AUTH_TOKEN and ApiKeyTenantExtension are
configured with different values, MCP transport auth passes but tool
execution fails because the MCP token gets re-validated against the
tenant API key in the engine layer.

Add mcp_authenticated flag to RequestContext so the engine skips tenant
re-validation when MCP transport auth already succeeded.

Fixes #627

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* test: strengthen assertion to verify no auth error in tool response

The original test only checked that "banks" key existed in the response,
which was true even for error responses like {"error": "...", "banks": []}.
Now asserts "error" not in parsed to properly catch auth failures.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-20 18:36:05 +01:00
DK09876andClaude Opus 4.6 5a486883e8 fix: add readme field to integration pyproject.toml files for PyPI (#634)
PyPI was not displaying package READMEs because the `readme` field
was missing from pyproject.toml. Hatchling requires this to be
explicitly declared. Fixes langgraph, agno, hermes, and pydantic-ai.

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-20 17:11:20 +01:00
BenandClaude Sonnet 4.6 d2c32cb8e4 blog: Give NemoClaw the Best Agent Memory Available In One Command (#631)
* docs(blog): add NemoClaw persistent memory blog post

Covers external API mode, OpenShell network egress policy pattern,
and the LaunchAgent symlink gotcha from the live test run.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* docs(blog): update NemoClaw blog post with SEO-optimized draft

- Add slug, TL;DR, pitfalls, tradeoffs table, recap, next steps sections
- Restructure into numbered implementation steps
- Remove internal blog links that don't exist yet

* docs(blog): fix docs link to include /recall/ path

* docs(blog): add correct internal links to NemoClaw blog post

* docs(blog): make hindsight-nemoclaw setup command the primary path

One-command setup is now the default; manual 4-step process moved to
'Manual Alternative' section for reference.

* docs(blog): update title to lead with NemoClaw and best-in-class memory

* Add cover image to NemoClaw memory blog post

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-20 16:29:08 +01:00
Nicolò Boschi ce691549ba doc: add langgraph and nemoclaw (#633) 2026-03-20 16:18:05 +01:00
Nicolò Boschi 72b61214f6 release(nemoclaw): v0.1.1 2026-03-20 15:21:26 +01:00
Nicolò Boschi 103994c25f fix nemoclaw release 2026-03-20 15:21:15 +01:00
Nicolò Boschi 36b5627d2c fix nemoclaw release 2026-03-20 15:18:44 +01:00
Ben d284de28c7 feat(nemoclaw): add hindsight-nemoclaw setup CLI package (#630)
* feat(nemoclaw): add hindsight-nemoclaw setup CLI package

Automates the full NemoClaw sandbox setup:
- Installs @vectorize-io/hindsight-openclaw plugin
- Configures external API mode in ~/.openclaw/openclaw.json
- Reads current openshell sandbox policy, merges Hindsight egress rule, re-applies
- Restarts the OpenClaw gateway

Options: --dry-run, --skip-policy, --skip-plugin-install
36 unit tests passing

* docs: add NEMOCLAW.md setup guide

* feat(nemoclaw): add README, docs page, and release pipeline

* revert: remove release.yml changes from nemoclaw PR
2026-03-20 15:16:55 +01:00
Nicolò Boschi 93609f74ab release(langgraph): v0.1.1 2026-03-20 13:45:56 +01:00
Nicolò Boschi 9a5f83adb4 fix: release integrations 2026-03-20 13:45:46 +01:00
DK09876andClaude Opus 4.6 b4320254b2 feat: add LangGraph integration (#610)
* feat: add LangGraph integration with tools, nodes, and store patterns

Add hindsight-langgraph SDK providing three integration patterns:
- Tools: retain/recall/reflect as LangChain tools for ReAct agents
- Nodes: automatic memory injection and storage as graph steps
- Store: LangGraph BaseStore implementation for checkpoint-based memory

Fix: remove `from __future__ import annotations` in nodes.py which
prevented LangGraph from passing RunnableConfig to node functions
(runtime type inspection saw string annotations instead of actual types).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: register langgraph with independent versioning system

- Set version to 0.1.0 (integrations are versioned independently)
- Add langgraph to VALID_INTEGRATIONS in release-integration.sh
- Add changelog page for langgraph integration

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: remove manual cookbook recipe page

The sync-cookbook script will auto-generate this from the notebook
in hindsight-cookbook once PR #17 is merged.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: comprehensive improvements to langgraph integration

Code fixes:
- Retain node only stores latest messages instead of all history (prevents duplicates)
- Handle multimodal msg.content (list type) in nodes
- Fix store docstring separator "/" → "."
- Apply search filters before pagination in store
- Add ttl parameter to store.aput for LangGraph BaseStore compat
- Fix _ensure_bank to not cache failed bank creations
- Fix falsy value bugs (or → is not None) in tools
- Remove from __future__ import annotations from all files
- Consistent default budget="mid" across tools/nodes/store
- Bump langgraph floor to >=0.3.0, remove duplicate dev deps

Docs fixes:
- Fix broken Cloud client example (base_url is required)
- Complete API reference tables with all parameters
- Add Limitations and Notes section (async-only store, etc.)
- Add Requirements section
- Fix broken cookbook link and Cloud claim in blog post

All 61 unit tests pass. E2E tested against Hindsight Cloud:
tools, nodes, store, configure(), multimodal content.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: remove blog post (lives in hindsight-marketing-content)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: remove Hindsight Cloud section from langgraph docs

Keep OSS docs self-hosted-first, consistent with other integration
docs (crewai, pydantic-ai, agno). Cloud setup details live in the
cookbook notebooks.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* docs: explicitly mention LangChain compatibility in langgraph integration

The tools pattern (create_hindsight_tools) only depends on
langchain-core and works with plain LangChain via bind_tools() —
no LangGraph required. Update docs to make this clear with both
LangGraph and LangChain quick start examples.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review findings

1. Guard manual test files with if __name__ == "__main__" so pytest
   doesn't collect and execute them during test runs
2. Remove semantic fallback in HindsightStore.aget() — only return
   exact document_id matches, not unrelated semantic search hits
3. Make langgraph an optional dependency — tools pattern only needs
   langchain-core. Install with pip install hindsight-langgraph[langgraph]
   for nodes and store patterns. Lazy imports with clear error messages.
4. Clean up README to be self-hosted-first, consistent with other
   integration docs
5. Update docs requirements section to reflect optional langgraph dep

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review feedback for langgraph integration

- Fix #2: Add per-bank asyncio.Lock to _ensure_bank for concurrency safety
- Fix #3: Clamp search score to max(0.0, ...) to prevent negative values
- Fix #4: Implement suffix matching in _handle_list_namespaces
- Fix #5: Truncate namespaces to max_depth instead of filtering (per BaseStore contract)
- Fix #6: Remove list_namespaces/alist_namespaces overrides — let base class handle prefix=/suffix= kwargs
- Fix #7: Document ephemeral namespace tracking and get() limitations in class docstring
- Fix #8: Add stable ID to recall node SystemMessage, document ordering behavior
- Fix #9: Change budget/max_tokens/recall_tags_match defaults to None so global config fallback works
- Fix #10: Conditionally populate __all__ so import * works without langgraph installed
- Fix #11: Bump langgraph lower bound from >=0.3.0 to >=0.5.0
- Fix #12: Extract _resolve_client to shared _client.py module

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address remaining review gaps for langgraph integration

- Add output_key parameter to create_recall_node for prompt ordering control
- Add prefix/suffix/combined filter tests for list_namespaces
- Add output_key unit tests (memory text, none on empty, none on error)
- Remove unused imports and backward-compat alias in tools.py
- Update docs with output_key usage example and API reference

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: relax langgraph version constraint to >=0.3.0

Research confirmed all required APIs (BaseStore, SearchItem, Result,
GetOp, PutOp, SearchOp, ListNamespacesOp) are available since
langgraph-checkpoint 2.0.7, which maps to langgraph >=0.2.63.
Using >=0.3.0 as a clean semver boundary — >=0.5.0 was unnecessarily
conservative and excluded many compatible versions.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-20 13:36:57 +01:00
Nicolò Boschi 97f7a365e8 fix(hindsight-api): add script entry points so uvx hindsight-api works directly (#629)
The hindsight-api meta-package was missing [project.scripts], causing
`uvx hindsight-api@{version}` to fail with exit code 28 when used in
hindsight-embed's daemon launcher.

Re-export the same scripts defined in hindsight-api-slim so uvx can
resolve the executable without requiring --from.
2026-03-20 13:26:34 +01:00
Christian Navolskyi 20e17f28ad Enhance OpenAI client initialization with query params (#623)
Extract query parameters from base_url when creating the OpenAI client.
2026-03-19 20:46:51 +01:00
Nicolò Boschi 80b1badf74 feat(docs): Integrations Hub + unified page hero (#620)
* fix(security): address all Dependabot vulnerability alerts

Python (uv.lock, pyproject.toml):
- authlib 1.6.6 → 1.6.9 (JWS header injection, OIDC hash binding, Bleichenbacher padding oracle)
- pyasn1 0.6.2 → 0.6.3 (unbounded recursion DoS)
- pyjwt 2.10.1 → 2.12.1 (unknown crit header extensions - also in integration-tests and crewai)
- orjson 3.11.4 → 3.11.7 (deeply nested JSON recursion DoS)
- tornado 6.5.2 → 6.5.5 (multipart DoS, incomplete cookie validation)

npm (package.json, package-lock.json):
- next ^16.1.6 → ^16.1.7 (HTTP smuggling, CSRF bypass, cache DoS, null origin bypass)
- fast-xml-parser override updated to >=5.5.6 (numeric entity expansion bypass)
- undici override added >=7.24.0 (WebSocket overflow, smuggling, CRLF injection, DoS)
- flatted override added >=3.4.0 (unbounded recursion DoS)
- svgo override added >=3.3.3 (DOCTYPE entity expansion DoS)
- dompurify override added >=3.3.2 (XSS vulnerability)

* feat(docs): add Integrations Hub and unified page hero

- Add /integrations page with search, type filter, and card grid
- Integrations defined in a single JSON file (src/data/integrations.json)
  supporting official and community entries with icon, author, and link
- Scrolling integrations banner moved from global navbar to /integrations only
- Remove IntegrationsGrid component; replace all usages with link to hub
- Add PageHero component with full-bleed gradient background, shared across
  Cookbook, FAQ, Best Practices, Changelog, and Blog index pages
- Remove FAQ from top navbar (already in Resources dropdown)
- Move integration changelogs table to bottom of changelog page
2026-03-19 20:29:55 +01:00
Nicolò Boschi ea662d062e feat: fact_types and mental model exclusion filters for reflect (#615)
* feat: add fact_types and mental model exclusion filters to reflect and mental models

Adds three new filtering options to both the reflect endpoint and mental model creation/refresh:

- `fact_types`: restrict which fact types (world, experience, observation) are retrieved.
  Disables irrelevant agent tools entirely (no wasted tokens).
- `exclude_mental_models`: skip the search_mental_models tool altogether.
- `exclude_mental_model_ids`: exclude specific mental models by ID (merged with the
  existing self-exclusion logic during mental model refresh).

For mental models, options are persisted in the existing `trigger` JSONB column so they
are automatically applied on every refresh. The `UpdateMentalModelRequest` already
proxies `trigger`, so no extra endpoint changes are needed.

Also fixes the test fixture (`pg0_db_url` in conftest.py) to correctly resolve pg0://
URLs and run migrations before tests, which was causing all DB-dependent tests to fail
with "relation public.banks does not exist" when HINDSIGHT_API_DATABASE_URL=pg0://uuuu.

* fix: guard against disabled-tool hallucination and regenerate clients

- Add enabled_tools guard in reflect agent: if an LLM calls a tool that
  was excluded (e.g. recall when fact_types=["observation"]), return an
  error result instead of executing it
- Regenerate OpenAPI spec and all SDK clients (Go, Python, TypeScript)
  to include new fact_types / exclude_mental_models fields

* fix: add missing ReflectRequest fields in Rust CLI struct initializers

* fix: filter hallucinated tool calls before trace to prevent disabled tools appearing in results

* chore: merge main, fix lint formatting and update skills openapi.json

* feat: expose fact_types, exclude_mental_models, exclude_mental_model_ids in control plane UI

* fix: add missing trigger fields to MentalModel type in control plane api.ts

* fix: add missing trigger fields to local MentalModel interface in mental-models-view

* feat: tabbed mental model dialogs (Basic / Options tabs)

* refactor: shared FactTypeFilter component, tabbed mental model dialogs use General tab, clean up labels

* feat: pill-style toggle buttons for fact type filter (blue/emerald/amber per type)

* fix: add spacing between Fact Types label and pills, rename to Exclude all mental models
2026-03-19 17:03:41 +01:00
Chris Bartholomew 94cf89b570 Fix non-atomic async operation creation (#619)
* Fix non-atomic async operation creation in _submit_async_operation

Previously the method performed two separate database round-trips:
1. INSERT into async_operations with no task_payload (null)
2. submit_task → UPDATE to set task_payload

A process crash or network error between steps 1 and 2 left a row with
task_payload IS NULL permanently. The worker's claim query requires
task_payload IS NOT NULL, so these orphaned rows could never be picked up
and the queue appeared degraded indefinitely.

Fix: build full_payload before the INSERT and include task_payload in the
same INSERT statement, making operation creation atomic. submit_task is
still called afterwards — for SyncTaskBackend it executes the task
immediately (unchanged behaviour); for BrokerTaskBackend it becomes an
idempotent UPDATE (payload already set) kept for symmetry.

* Preserve datetime payloads in atomic async insert
2026-03-19 16:38:04 +01:00
Chris Bartholomew 439424559e Fix orphaned batch_retain parents when child fails via unhandled exception (#618)
* Fix orphaned batch_retain parents when child fails via unhandled exception

When a child retain operation fails with an unhandled exception (e.g. a DB
constraint violation), the memory engine's transaction is rolled back entirely,
including any call to _maybe_update_parent_operation. The poller's fallback
_mark_failed then updates the child status but leaves the parent batch_retain
permanently stuck in 'pending'.

Fix: wrap _mark_failed in a transaction and call a new poller-level
_maybe_update_parent_operation after marking the child failed. This mirrors
the memory engine's own parent-update logic and ensures the parent is
resolved to completed/failed regardless of how the child failure was detected.

The poller's implementation locks the parent row, checks all siblings, and
only finalises the parent once all siblings have reached a terminal state.
Errors in parent propagation are logged but do not affect the child failure
path, which is the critical state change.

* Add tests for _mark_failed parent propagation in WorkerPoller

Tests cover the new _maybe_update_parent_operation logic:
- Last sibling fails → parent batch_retain becomes failed
- Sole child fails → parent becomes failed
- Sibling still pending → parent stays pending (no premature resolution)
- No parent in result_metadata → safe no-op
- End-to-end: unhandled exception via execute_task propagates to parent
2026-03-19 14:55:26 +01:00
Nicolò Boschi 4c4b3568db fix(security): address all Dependabot vulnerability alerts (#617)
Python (uv.lock, pyproject.toml):
- authlib 1.6.6 → 1.6.9 (JWS header injection, OIDC hash binding, Bleichenbacher padding oracle)
- pyasn1 0.6.2 → 0.6.3 (unbounded recursion DoS)
- pyjwt 2.10.1 → 2.12.1 (unknown crit header extensions - also in integration-tests and crewai)
- orjson 3.11.4 → 3.11.7 (deeply nested JSON recursion DoS)
- tornado 6.5.2 → 6.5.5 (multipart DoS, incomplete cookie validation)

npm (package.json, package-lock.json):
- next ^16.1.6 → ^16.1.7 (HTTP smuggling, CSRF bypass, cache DoS, null origin bypass)
- fast-xml-parser override updated to >=5.5.6 (numeric entity expansion bypass)
- undici override added >=7.24.0 (WebSocket overflow, smuggling, CRLF injection, DoS)
- flatted override added >=3.4.0 (unbounded recursion DoS)
- svgo override added >=3.3.3 (DOCTYPE entity expansion DoS)
- dompurify override added >=3.3.2 (XSS vulnerability)
2026-03-19 14:27:52 +01:00
Nicolò Boschi a706905653 feat(skill): validate links, strip images, include openapi.json and changelog (#614)
* feat(skill): validate links, strip images, include openapi.json and changelog

- Add post-processing step to rewrite Docusaurus site-root paths (e.g.
  /developer/foo) to proper relative .md paths within the skill
- Strip markdown and HTML images from all generated files since assets
  are not bundled with the skill
- Copy hindsight-docs/static/openapi.json into references/openapi.json
  and map /api-reference links to it
- Include changelog.md from src/pages/ alongside faq and best-practices
- Add final validation step that fails the build if any link still
  points outside the skill directory

* ci: run generate-docs-skill in verify-generated-files job

* fix(skill): strip unresolvable site-root links instead of leaving them broken

* fix(skill): write file when images stripped but no links rewritten

* chore(skill): regenerate with fixed links, stripped images, changelog and openapi

* fix(skill): handle changelog as directory, add agno/hermes integrations, rebase on main
2026-03-19 12:32:33 +01:00
Nicolò Boschi fe12be47a0 feat: add scrolling integrations banner to all doc pages (#616)
- Add IntegrationsBanner component with infinite left-to-right CSS scroll animation showing all clients, integrations, and LLM providers
- Place banner below the navbar on every page via Navbar theme wrapper
- Add Agno and Hermes to both the IntegrationsGrid and the banner
- Remove right border from doc sidebar via custom.css
2026-03-19 12:32:23 +01:00
Nicolò Boschi a56cd044e5 feat: 4-tab code parity across all documentation examples (#613)
* feat: independent versioning for integrations

- Add per-integration changelog pages at /changelog/integrations/<name>
- Move main changelog to changelog/index.md (URL unchanged)
- Add --integration flag to generate-changelog for LLM-based per-integration changelog generation
- Add scripts/release-integration.sh <name> <version> for cutting integration releases
- Add .github/workflows/release-integration.yml to publish on integrations/** tags
- Remove integrations from main release.sh and release.yml cycle

* fix: add agno and hermes integration docs to version-0.4 for production build

* chore: apply ruff formatting to generate_changelog.py

* feat: add 4-tab code parity across all documentation examples

Every code snippet Tabs block now has Python, Node.js, CLI, and Go variants.
Raw HTTP/curl tabs replaced with proper SDK calls.

New example files:
- Go: retain.go, recall.go, reflect.go, memory-banks.go, directives.go,
  mental-models.go, documents.go, main-methods.go
- Shell: memory-banks.sh, directives.sh, mental-models.sh
- Node.js: mental-models.mjs

Extended example files with missing sections:
- recall.mjs/sh: world/experience/observation types, token-budget, all tag modes
- reflect.sh: reflect-with-params, reflect-disposition, reflect-sources, reflect-with-tags
- reflect.mjs: reflect-with-tags, fixed reflect-sources API usage
- retain.mjs/sh: retain-conversation, retain-batch, retain-files-batch

SDK/CLI additions:
- TypeScript: getMentalModelHistory method
- CLI recall: --tags, --tags-match flags
- CLI reflect: --tags, --tags-match, --include-facts flags
- CLI directive update: --is-active flag
- CLI bank set-config: --retain-mission, --retain-extraction-mode,
  --observations-mission, --reflect-mission, --disposition-* flags

Build validation:
- scripts/check-code-parity.mjs validates 4-tab parity across all MDX files
- Integrated into npm run build — fails if any Tabs block is missing a variant

* fix: fix doc examples for Go, Node.js, CLI + add mental model with-id examples

- Fix Go Budget constants: BUDGET_HIGH/LOW/MID → HIGH/LOW/MID
- Fix Go documents.go: ListDocuments returns []map[string]interface{}, use map access
- Fix Go retain.go: use correct relative path for sample.pdf
- Fix Node.js createMentalModel: use positional args (name, sourceQuery) not object
- Add CLI 'history' subcommand for mental models (api.rs, main.rs, mental_model.rs)
- Rebuild TypeScript/Python clients to support id param in createMentalModel
- Add create-mental-model-with-id examples across all 4 languages and docs

* fix: move id param to end of create_mental_model signature for backwards compat
2026-03-19 11:31:51 +01:00
Chris Bartholomew 438ce98b40 Fix entity_id null constraint for non-ASCII entity names (#612)
* Fix entity_id null constraint for non-ASCII entity names (Turkish İ etc.)

Python's str.lower() and PostgreSQL's LOWER() produce different results for
some Unicode characters. The most common case is Turkish İ (U+0130):
  Python:     'İstanbul'.lower() == 'i\u0307stanbul' (i + combining dot, 2 chars)
  PostgreSQL: LOWER('İstanbul') == 'istanbul' (plain i, 1 char)

In _resolve_from_candidates, the fallback SELECT for conflicted entity names
passed Python-lowercased strings to LOWER(canonical_name) = ANY($names), so
PostgreSQL couldn't match them. entity_ids[idx] stayed None, which then
caused a NOT NULL violation on unit_entities.entity_id, failing the entire
retain.

Fix: pass original mixed-case names to the fallback SELECT and use
LOWER(canonical_name) = ANY(SELECT LOWER(n) FROM unnest($2) AS n) so
PostgreSQL lowercases both sides identically. The query also returns the
original input_name so we can add a Python-lowercased key to id_by_name
for the assignment loop that uses Python-lowercased keys.

* Add regression test for Unicode entity conflict
2026-03-19 10:32:47 +01:00
Nicolò Boschi 446c75f3e2 fix: correctly map LLM fact_type \"assistant\" to \"experience\" for DB storage (#609)
The Pydantic model extraction paths (batch API and parallel extraction) used
fact_from_llm.fact_type directly, bypassing the \"assistant\" → \"experience\"
conversion and causing DB CHECK constraint violations.

Unified the conversion logic across all paths:
- \"assistant\" → \"experience\"
- \"world\" → \"world\"
- anything else: fall back to fact_kind (\"assistant\" → \"experience\"), else \"world\"
2026-03-19 10:32:08 +01:00
Ben 276a4ba7e8 blog: Hermes Agent persistent memory (#599)
* blog: add Hermes Agent persistent memory integration post
2026-03-18 17:00:35 -04:00
Nicolò Boschi 31f1c53c8f feat: independent versioning for integrations (#565)
* feat: independent versioning for integrations

- Add per-integration changelog pages at /changelog/integrations/<name>
- Move main changelog to changelog/index.md (URL unchanged)
- Add --integration flag to generate-changelog for LLM-based per-integration changelog generation
- Add scripts/release-integration.sh <name> <version> for cutting integration releases
- Add .github/workflows/release-integration.yml to publish on integrations/** tags
- Remove integrations from main release.sh and release.yml cycle

* fix: add agno and hermes integration docs to version-0.4 for production build

* chore: apply ruff formatting to generate_changelog.py
2026-03-18 17:53:46 +01:00
Nicolò Boschi c10c9c89e9 docs: add 0.4.19 release blog post, Agno and Hermes integration pages (#608) 2026-03-18 17:35:54 +01:00
OctopusandPR Bot 1f1462a5f6 feat: upgrade MiniMax default model from M2.5 to M2.7 (#606)
* feat: upgrade MiniMax default model from M2.5 to M2.7

MiniMax has released MiniMax-M2.7, their latest model with a 1M context
window (up from 204K). This updates the default model across config,
docs, and examples. M2.5 remains fully compatible for users who prefer it.

- Update PROVIDER_DEFAULT_MODELS to MiniMax-M2.7
- Update .env.example and documentation references
- Add test_minimax_provider.py with M2.7 and backward compat tests

* chore: remove test file per review feedback

---------

Co-authored-by: PR Bot <[email protected]>
2026-03-18 17:15:20 +01:00
Nicolò Boschi 0727f2d069 Release v0.4.19
- Update version to 0.4.19 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm, hindsight-crewai, hindsight-pydantic-ai, hindsight-hermes, hindsight-agno, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- OpenClaw integration: hindsight-integrations/openclaw
- AI SDK integration: hindsight-integrations/ai-sdk
- Chat SDK integration: hindsight-integrations/chat
- Helm chart
- Sync documentation to version-0.4
2026-03-18 14:29:15 +01:00
Nicolò Boschi 72c25c97e3 feat(typescript-client): Deno compatibility (#607)
* feat(typescript-client): add Deno compatibility

- Switch build from tsc to tsup for dual CJS + ESM output with proper exports field
- Add deno_setup.ts preload that injects Jest-compatible globals (describe/test/expect) via @std/testing/bdd and @std/expect
- Fix generated client.gen.ts: exclude hey-api internal `client` field from RequestInit spread to avoid conflict with Deno.HttpClient
- Add test:deno npm script using --unstable-sloppy-imports and --preload
- Add test-typescript-client-deno CI job using denoland/setup-deno@v2 (v2.x)
- Update docs: rename page to TypeScript / JavaScript Client, add Deno installation section

* feat: add Deno compatibility to ai-sdk and chat integrations

- Switch ai-sdk and chat builds from tsc to tsup (ESM bundle, eliminates
  extension-less import issues in Deno)
- Add deno.json import map to ai-sdk redirecting 'vitest' to a custom
  vitest-compat.ts shim and bare npm specifiers to npm: URLs
- Add vitest-compat.ts shim implementing vi.fn()/vi.spyOn()/vi.mocked()
  using @std/expect's Symbol.for("@MOCK") interface so toHaveBeenCalledWith
  and other mock matchers work under Deno
- Add test:deno script to ai-sdk (all 30 tests pass under Deno)

* ci: add Deno test job for ai-sdk integration

Adds a new test-ai-sdk-integration-deno CI job that runs the ai-sdk
unit tests under Deno LTS, verifying Deno compatibility of the package.

* fix: remove broken link to non-existent n8n blog post in streamlit post

* fix: patch client.gen.ts for Deno compatibility during generation

Add a post-generation patch step to generate-clients.sh that removes
the hey-api internal 'client' field from the RequestInit spread in
client.gen.ts. Deno's Request constructor rejects 'client' because it
conflicts with the Deno.HttpClient option name.
2026-03-18 14:25:35 +01:00
BenandClaude Opus 4.6 8c378b981a feat: add Agno integration with Hindsight memory toolkit (#596)
* feat: add Agno integration with Hindsight memory toolkit

Add hindsight-agno package providing Hindsight memory tools (retain,
recall, reflect) as an Agno Toolkit, following the same pattern as
Agno's Mem0Tools. Includes per-user bank isolation, global config,
bank auto-creation, and memory_instructions() for system prompt
injection.

Also adds cookbook documentation page with architecture diagrams,
quick start examples, and configuration reference.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: remove n8n blog post, add Agno icon, bind to release process

- Remove n8n blog post from the agno integration branch
- Add Agno logo icon and map hindsight-agno SDK tag in CookbookGrid
- Add hindsight-agno to release.sh PYTHON_PACKAGES array
- Add build, publish, artifact upload, and release asset steps in release.yml

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: remove cookbook page (moved to hindsight-cookbook repo)

The Agno cookbook application now lives in
vectorize-io/hindsight-cookbook/applications/agno-memory.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-18 11:17:23 +01:00
Ben e2b19d3b38 blog: fix internal links in streamlit post (#605) 2026-03-17 15:33:08 -04:00
Ben 210a40665d blog: fix streamlit post slug and add cover image (#604) 2026-03-17 15:16:29 -04:00
Nicolò Boschi 28dac7c7f8 fix: prevent silent memory loss on consolidation LLM failure (#601)
* fix: prevent silent memory loss on consolidation LLM failure

When all LLM retries are exhausted during consolidation, memories were
being marked consolidated_at unconditionally, permanently excluding them
from future consolidation runs without producing any observations.

Fix with two complementary mechanisms:
- Adaptive batch splitting: on LLM failure, the batch is halved and
  retried recursively down to batch_size=1, recovering most transient
  failures (rate limits, Pydantic validation on long prompts) without
  operator intervention
- consolidation_failed_at column: only single-memory batches that still
  fail after all retries are marked here instead of consolidated_at, so
  they remain visible and retryable
- New API endpoint POST /v1/default/banks/{bank_id}/consolidation/retry-failed
  resets these memories for the next consolidation run

* chore: regenerate OpenAPI spec

* fix: rename consolidation endpoint from /retry-failed to /recover

* fix: add consolidation_failed_at column, adaptive batch splitting, and recovery API

- Migration a3b4c5d6e7f8: add consolidation_failed_at TIMESTAMPTZ column to
  memory_units with an index for efficient failure queries; properly chains off
  g7h8i9j0k1l2 (backsweep_orphan_observations)
- Consolidator: filter pending memories with consolidation_failed_at IS NULL
  so failed memories are not re-fetched in an infinite loop
- Consolidator: adaptive batch splitting — when a batch exhausts all 3 LLM
  retries, halve it and retry sub-batches recursively; only single-memory
  batches that also exhaust all retries get consolidation_failed_at set
- New tests (9 total) covering: adaptive splitting recovers all memories,
  larger batch splitting, single-memory permanent failure, exclusion from
  next run, partial batch failure, recover resets columns, recover returns
  0 when none failed, recover-then-consolidate succeeds, HTTP endpoint

* chore: regenerate Go, Python, TypeScript clients with recover consolidation endpoint

* feat: add Recover Consolidation action to bank Actions dropdown

* style: apply ruff formatting to http.py and config.py

* fix: handle consolidation scope in large batch test mock LLM

The mock LLM was returning {"facts": ...} for ALL calls including consolidation.
Consolidation doesn't use skip_validation=True so it expects a _ConsolidationBatchResponse
instance, not a raw dict. Before this PR consolidation silently swallowed the AttributeError
(failed=False was returned); now failed=True triggers adaptive splitting and timeouts.

Fix: return _ConsolidationBatchResponse() when scope=="consolidation".

* fix: restrict claude-agent-sdk to macOS platform only (no Linux wheel available)

Also fix pre-existing type errors: use setattr for XLM-RoBERTa monkey-patch
and add missing reranker_local_fp16/bucket_batching/batch_size fields to main.py config constructor.

* fix: add UV_INDEX_STRATEGY=unsafe-best-match to fix markupsafe cp314 wheel conflict

PyTorch CPU index serves markupsafe==3.0.3 with only cp314 wheels.
uv's default first-index strategy stops at the first index with any version
even if no compatible wheel exists. unsafe-best-match searches all indices
for the best compatible wheel, falling back to PyPI for markupsafe.

* fix: use explicit pytorch index to prevent markupsafe wheel conflict

Configure the pytorch CPU index as explicit=true in pyproject.toml so it is
ONLY used for torch (via [tool.uv.sources]). All other packages (including
markupsafe) are resolved exclusively from PyPI, preventing the pytorch index
from serving incompatible cp314-only wheels for non-pytorch packages.

Remove UV_INDEX and UV_INDEX_STRATEGY from CI workflow (no longer needed
since the index is now configured in pyproject.toml).

* ci: trigger CI run

* ci: retry trigger

* ci: trigger after remote URL fix

* ci: add workflow_dispatch to unblock manual trigger

* fix: remove empty env blocks left after UV_INDEX removal

* fix: add type: ignore for optional claude_agent_sdk imports (macOS-only)

* fix: correct type: ignore rules for claude_agent_sdk and fix utcnow deprecation
2026-03-17 20:15:33 +01:00
Ben f88f0a3b26 blog: Streamlit chatbot with persistent memory (#602)
* blog: add Streamlit chatbot with persistent memory post

* fix
2026-03-17 14:45:54 -04:00
Nicolò Boschi e4f8a157c2 feat(retain): verbatim, chunks modes and named retain strategies (#593)
* feat(retain): add verbatim extraction mode

Adds retain_extraction_mode="verbatim" that stores each chunk as-is
without LLM summarization. The LLM still runs to extract entities,
temporal info, and location for full indexability — only the fact text
is replaced with the original chunk content (one memory per chunk).

Useful for RAG-style indexing and benchmarks where original text
must be preserved in memory.

- Add "verbatim" to RETAIN_EXTRACTION_MODES in config.py
- Add VERBATIM_FACT_EXTRACTION_PROMPT with instructions to preserve text
- Add _collapse_to_verbatim() post-processing to enforce 1 fact/chunk
- Expose in bank config UI dropdown with updated description
- Update configuration.md docs with verbatim mode description
- Add unit test for _collapse_to_verbatim and integration test via LLM
- Fix pre-existing main.py CLI override missing new reranker fields
- Fix pre-existing cross_encoder.py ty type error via setattr

* refactor(retain): verbatim mode skips 'what' field entirely

Instead of asking the LLM to echo the chunk text back into 'what' and
then discarding it, verbatim mode now uses a dedicated schema
(VerbatimExtractedFact) that omits the 'what' field altogether.
The LLM only returns metadata (entities, temporal info, location, who),
saving output tokens and avoiding any risk of paraphrasing before the
backfill.

- Add VerbatimExtractedFact / VerbatimFactExtractionResponse models
- Verbatim mode skips causal-relations section (nothing to relate causally)
- _extract_facts_from_chunk: allow missing 'what' in verbatim mode,
  set combined_text="" (backfilled by _collapse_to_verbatim)
- Update verbatim prompt to say DO NOT include 'what'

* feat(retain): add index_only extraction mode

Zero-LLM retain mode: chunks are stored as-is with no LLM call, no
entity extraction, and no temporal indexing. Embeddings still run for
semantic search. User-provided entities via RetainContent.entities
are the sole source of entity data.

Early return placed before the batch-API check so no LLM queue or
concurrency locks are acquired.

- Add "index_only" to RETAIN_EXTRACTION_MODES
- Add _extract_facts_index_only() with pure Python chunking path
- Add to UI dropdown and update description
- Update configuration.md with index_only docs and table entry
- Add unit test asserting zero token usage and exact text preservation

* feat(retain): add named retain strategies

Allows mixing extraction modes in a single bank via named strategies.
Each strategy is a set of hierarchical config overrides (extraction_mode,
chunk_size, entity_labels, entities_allow_free_form, etc.) applied on
top of the resolved bank config at retain time.

- retain_strategies: dict of strategy_name → config overrides (bank config)
- retain_default_strategy: default strategy when none specified (bank config)
- strategy field on /retain request: per-call override
- apply_strategy() in config_resolver applies overrides via dataclasses.replace()
- strategy propagates through retain_batch_async → _retain_batch_async_internal
  and through the async worker task payload
- Any hierarchical field is overridable per strategy, including entity_labels
  and entities_allow_free_form
- Docs updated with strategy configuration example and RRF fairness note
- Unit test for apply_strategy covering overrides, unknown strategy, and
  non-hierarchical field filtering

* feat(retain): add per-item strategy and strategy tests

- Add `strategy` field to `MemoryItem` so individual items in a retain
  request can override the request-level strategy
- Add `strategy` field to `FileRetainMetadata` for per-file strategy
  override in file retain requests
- Group memory items by effective strategy in `api_retain`; each group
  is processed as a separate batch, results are aggregated
- Thread strategy through `submit_async_file_retain` →
  `_handle_file_convert_retain` → retain task payload
- Add `operation_ids` to `RetainResponse` for async requests with
  mixed per-item strategies
- Add `test_strategy_overrides_extraction_mode_for_index_only`: unit
  test verifying a named strategy with index_only bypasses the LLM
- Add `test_retain_request_per_item_strategy_field`: unit test for
  per-item strategy grouping logic

* feat(ui): add retain strategies and default strategy to bank config UI

- Add StrategiesEditor component: per-strategy cards with name input and
  JSON overrides textarea; supports add/remove; validates JSON inline
- Add Default Strategy text input (retain_default_strategy)
- Update RetainEdits type and retainSlice() to include both new fields
- Regenerate OpenAPI spec (retain_strategies, retain_default_strategy,
  per-item strategy on MemoryItem/FileRetainMetadata, operation_ids on
  RetainResponse)

* refactor(ui): move retain strategies into its own dedicated config section

* feat(ui): improve retain strategies UX and add strategy to document dialog

- Strategy form now includes entity section (free form toggle + entity labels editor)
- Default strategy selector moved outside tab panel, above strategy chips
- Strategy tabs redesigned with underline indicator style for clarity
- Remove strategy confirms with AlertDialog
- Fix tab re-render bug when typing strategy name (skipSyncRef)
- Add strategy field to Add New Document dialog (text + per-file for uploads)
- File upload collapsible uses same Document/Tags/Source tabbed layout
- API: validate empty strategy names in config_resolver
- api.ts: add strategy field to retain and uploadFiles types

* fix: forward strategy through HTTP layer and SDK; add integration test

- route.ts: extract and forward `strategy` from request body to retainBatch
- TypeScript SDK: accept and forward `strategy` in retainBatch options and per-item
- config_resolver.py: validate empty strategy name keys on update
- bank-config-view.tsx: merge entity fields into RetainStrategyForm, redesign strategy tabs with underline style, add confirmation dialog for removal, fix tab-reset-on-typing with skipSyncRef, move default strategy selector outside panel
- bank-selector.tsx: add strategy field to Add Document dialog (per-file in tabbed collapsible)
- test_retain.py: add end-to-end integration test verifying named strategy application (index_only = 0 LLM tokens)

* fix: regenerate TypeScript client with strategy field in RetainRequest/MemoryItem

- Regenerate OpenAPI spec to include strategy field in RetainRequest and MemoryItem
- Regenerate TypeScript client from updated spec
- Add strategy to MemoryItemInput interface
- Remove (item as any) cast now that strategy is properly typed

* rename: index_only extraction mode → chunks

* remove top-level strategy from RetainRequest; strategy is per-item only

* fix(clients): update Go and Python generated clients with strategy/operation_ids fields

* fix(ci): update hierarchical field count, add strategy to Rust MemoryItem initializers

* fix(go-client): minimal targeted YAML updates for strategy/operation_ids fields
2026-03-17 18:08:25 +01:00
BenandClaude Opus 4.6 ef90842f87 feat: hindsight-hermes integration for Hermes Agent (#600)
* feat: add hindsight-hermes integration for Hermes Agent

* chore: add Hermes docs page, icon, and release process bindings

- Add cookbook page for Hermes integration (synced with README)
- Add Hermes icon and map hindsight-hermes SDK tag in CookbookGrid
- Add cookbook entry to index.mdx
- Add hindsight-hermes to release.sh PYTHON_PACKAGES array
- Add build, publish, artifact upload, and release asset steps in release.yml

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-17 18:06:55 +01:00
Nicolò Boschi f68e2e2851 docs: add Best Practices unversioned page (#598)
* docs: revamp sidebar with icon grid components and language support

- Merge Clients and Integrations sections into the developer sidebar
  (removed top-level SDKs navbar item)
- Reorder sidebar: Architecture → API → Clients → Integrations → Hosting
- Unify icon system using react-icons (LuXxx/SiXxx) via customProps.icon
- Add uppercase section titles with increased spacing and reduced indentation
- Rename Node.js → "JavaScript / TypeScript" with TypeScript icon
- Add reusable IconGrid and SupportedGrids components (ClientsGrid,
  IntegrationsGrid, LLMProvidersGrid)
- Use grids in FAQ, Models, Overview, and Quick Start pages
- Convert developer/index.md, models.md, faq.md to MDX for JSX support

* docs: add Best Practices page as unversioned standalone page

- Add src/pages/best-practices.mdx covering core concepts (memory banks,
  taxonomy, memory types), bank configuration (missions, dispositions,
  entity labels), retain (formats, context, document_id, tags, observation
  scopes), recall (budget, tag filtering, include options), reflect
  (recall vs reflect decision, response_schema, auditing), mental models,
  and anti-patterns
- Add Resources section to sidebar with Best Practices and FAQ links
- Update generate-docs-skill.sh to include standalone pages (best-practices,
  faq) from src/pages/ into the agent skill references
- SKILL.md now surfaces best-practices.md as the recommended starting point

* fix: remove leftover merge conflict markers in DocSidebarItem Link

* fix: add missing lu-star, lu-circle-help, lu-file-text icons to sidebar map

* fix: remove duplicate LuFileText import

* fix: add Best Practices and FAQ to Resources navbar dropdown

* docs: hide right TOC and add manual TOC to best practices page

* docs: hide right TOC and add manual TOC to FAQ page

* fix: add lu-star icon to navbar item icon map

* fix: correct broken anchor in best practices TOC
2026-03-17 14:03:38 +01:00
BenandClaude Opus 4.6 61b01cc040 blog: add n8n persistent memory workflows post (#585)
* blog: add n8n persistent memory workflows post

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* blog: add cover image for n8n memory workflows post

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* blog: update n8n cover image

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* blog: remove broken screenshot references from n8n post

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* blog: add Hindsight Cloud option and n8n Cloud guidance

- Add Cloud vs self-hosted setup paths in Step 1
- Show both Cloud and self-hosted URLs for retain/recall/reflect nodes
- Note that Cloud eliminates the localhost IP gotcha
- Mention n8n Cloud compatibility (requires Hindsight Cloud or public endpoint)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* blog: update n8n post date to 2026-03-16

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* blog: update n8n post with optimized content and fix accuracy

- Use optimized version of the blog post
- Fix blog cross-links to use date-prefixed URLs
- Fix retain response to match actual API (success, bank_id, items_count, async)
- Fix recall response to match actual API (text, type, entities — not confidence/source)
- Update title to "How to Add Persistent Memory to n8n Workflows"

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* blog: update n8n post title

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-16 14:56:56 -04:00
Nicolò Boschi bbcfe2f5ab docs(skills): encourage rich context over pre-summarized strings in retain (#594)
* docs: add config vars for local reranker FP16 and bucket batching (#588)

* fix: add missing reranker local fields to CLI config override and fix ty type error

- Add reranker_local_fp16, reranker_local_bucket_batching, reranker_local_batch_size
  to the manual HindsightConfig() constructor call in main.py (CLI override block)
- Replace direct module attribute assignment with setattr() in the transformers 5.x
  monkey-patch so ty can resolve it without raising unresolved-attribute

* docs(skills): encourage rich context over pre-summarized strings in retain

The previous guidance told agents to distill content before calling
retain (e.g. "Be specific: store X not Y"). This misrepresents the
actual architecture: the server runs a full extraction pipeline (fact
extraction, entity linking, embeddings) on whatever is passed in.

- Add "How Hindsight Works" section explaining the server-side pipeline
- Update retain examples to pass full-context observations
- Replace "Be specific" with "Pass rich context"
- Clarify that --context is metadata labeling, not a content filter

Closes #592

* docs(skills): add raw conversation transcript example for retain
2026-03-16 18:37:12 +01:00
Nicolò Boschi d2bfa84bca docs: add config vars for local reranker FP16 and bucket batching (#589)
* docs: add config vars for local reranker FP16 and bucket batching (#588)

* fix: add missing reranker local fields to CLI config override and fix ty type error

- Add reranker_local_fp16, reranker_local_bucket_batching, reranker_local_batch_size
  to the manual HindsightConfig() constructor call in main.py (CLI override block)
- Replace direct module attribute assignment with setattr() in the transformers 5.x
  monkey-patch so ty can resolve it without raising unresolved-attribute
2026-03-16 17:35:09 +01:00
abix5andSisyphus 8a64dc8db6 fix(docker): honor HINDSIGHT_CP_HOSTNAME for control-plane startup (#590)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <[email protected]>
2026-03-16 16:19:40 +01:00
Fabio Scarsi e7da7d0e4f feat: local reranker FP16, bucket batching, and transformers 5.x compatibility (#588)
Three independent, cumulative improvements to LocalSTCrossEncoder:

1. transformers 5.x compatibility patch for XLM-RoBERTa models (Jina v2)
2. FP16 inference (opt-in via HINDSIGHT_API_RERANKER_LOCAL_FP16)
3. Length-sorted bucket batching (opt-in via HINDSIGHT_API_RERANKER_LOCAL_BUCKET_BATCHING)

All behind .env switches with conservative defaults preserving current behavior.

Fixes #586, Closes #587
2026-03-16 15:38:33 +01:00
Nicolò Boschi f09ad9deac fix(migration): backsweep orphaned observation memory units (#584)
* fix(migration): backsweep orphaned observation memory units

Delete observation rows whose every source_memory_id points to a
deleted memory unit, left behind before PR #580 fixed the chunk FK
cascade and before delete_document() called
_delete_stale_observations_for_memories.

Closes #572 (data cleanup for pre-existing installs).

* fix(migration): broaden backsweep to cover all fact types and bank-level orphans

- Pass 1: delete any memory_units row (all fact_types) whose bank_id no
  longer exists in banks — catches orphans from bank deletions that
  predate a FK cascade between the two tables.
- Pass 2: delete observation rows whose every source_memory_id points to
  a deleted memory unit, regardless of document_id/chunk_id anchors.

* test(migration): verify backsweep removes orphans and preserves legit rows

Adds a focused migration test that:
- Starts a fresh pg0 instance at revision f6g7h8i9j0k1
- Seeds orphaned rows for both backsweep passes (ghost-bank + all-dead-sources)
- Seeds legitimate rows that must survive
- Applies the backsweep migration to head
- Asserts the expected rows are deleted/preserved
2026-03-16 14:06:33 +01:00
jnMetaCode f27bd95382 fix: change chunk FK to CASCADE so doc deletion removes linked memory units (#580)
The foreign key from memory_units.chunk_id to chunks.chunk_id used
ON DELETE SET NULL, which left ghost memory_units rows (chunk_id nulled
out, no parent document) after a document was deleted.  Switching to
ON DELETE CASCADE lets the existing document -> chunks -> memory_units
cascade clean up everything in one pass.

Closes #572

Signed-off-by: JiangNan <[email protected]>
2026-03-16 12:24:22 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 7eabe5e168 chore(deps): bump actions/checkout from 4 to 6 (#581)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-16 12:01:07 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 33565a8236 chore(deps): bump actions/download-artifact from 4 to 8 (#582)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-16 12:00:56 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 51f05365a1 chore(deps): bump actions/setup-python from 5 to 6 (#583)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-16 12:00:46 +01:00
Salman Chishti 1e6cb15e99 Upgrade GitHub Actions to latest versions (#576)
Signed-off-by: Salman Muin Kayser Chishti <[email protected]>
2026-03-14 12:22:05 +01:00
BenandClaude Opus 4.6 bd6348aa08 blog: add disposition-aware agents post (#566)
* blog: add disposition-aware agents post

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-13 17:53:27 -04:00
DK09876andClaude Opus 4.6 836fd81e19 fix: inject Accept header in MCP middleware to prevent 406 errors (#571)
Some MCP clients (e.g., Claude Code) don't send an Accept header,
causing the MCP SDK to reject requests with 406 Not Acceptable. The
middleware now ensures Accept includes application/json and
text/event-stream when missing.

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-13 21:33:30 +01:00
陈家名and陈家名 32b00cea4f docs: improve type hints and documentation in client_wrapper (#570)
- Add comprehensive docstrings to all API namespace classes
- Add return type annotations (Any) to all methods
- Add detailed Args and Returns sections to method docstrings
- Improve HindsightClient class docstring with Attributes section
- Add type annotations to __init__ parameters

Co-authored-by: 陈家名 <[email protected]>
2026-03-13 17:42:54 +01:00
Nicolò Boschi 21f9f46ca3 fix: support gemini-3.1-flash-lite-preview by preserving thought_signature in tool calls (#568)
Gemini 3.1+ thinking models include a thought_signature field in functionCall
parts. When reconstructing conversation history for subsequent turns, this
signature must be preserved or the API returns 400 INVALID_ARGUMENT.

- Add optional thought_signature field to LLMToolCall
- Capture thought_signature from Gemini response parts
- Pass thought_signature back when reconstructing multi-turn history
- Add gemini-3.1-flash-lite-preview to the LLM provider test matrix
2026-03-13 16:43:01 +01:00
Nicolò Boschi c7db770281 doc: add 0.4.18 release blog post (#567)
* doc: add 0.4.18 release blog post

* doc: include changelog and blog image for 0.4.18
2026-03-13 16:00:59 +01:00
Nicolò Boschi 5fdb0e863f Release v0.4.18
- Update version to 0.4.18 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm, hindsight-crewai, hindsight-pydantic-ai, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- OpenClaw integration: hindsight-integrations/openclaw
- AI SDK integration: hindsight-integrations/ai-sdk
- Chat SDK integration: hindsight-integrations/chat
- Helm chart
- Sync documentation to version-0.4
2026-03-13 15:21:03 +01:00
Nicolò Boschi 4a69a422a0 doc: fix build 2026-03-13 15:19:56 +01:00
Nicolò Boschi 26472df166 doc: improve link icons and structure 2026-03-13 15:09:13 +01:00
Nicolò Boschi 5de793eec7 feat: compound tag filtering via tag_groups (#562)
* feat: add compound tag filtering via tag_groups

Adds tag_groups to RecallRequest and ReflectRequest to express arbitrary
boolean tag predicates: leaf {tags, match}, and/or/not compounds.
Top-level groups are AND-ed. Existing tags/tags_match unchanged.

Examples:
  Step filter AND user scope:
    tag_groups: [{tags: ["step:5","step:8"], match: "any_strict"},
                 {tags: ["user:alice"], match: "all_strict"}]
  Exclusion:
    tag_groups: [{tags: ["user:alice"], match: "all_strict"},
                 {not: {tags: ["archived"], match: "any_strict"}}]

- Recursive SQL builder (build_tag_groups_where_clause) threads through
  all 4 retrieval strategies (semantic/BM25, temporal, graph, MPFP)
- Python-side filter (filter_results_by_tag_groups) for post-traversal
- 22 new unit tests
- OpenAPI spec + all clients regenerated (Rust, Python, TypeScript, Go)

* fix: add tag_groups: None to Rust CLI struct initializers

* fix: add tag_groups: None to Rust client test RecallRequest initializer

* feat: reject tags+tag_groups together, add tag_groups integration tests

- Add model_validator to RecallRequest and ReflectRequest that returns 422
  when both `tags` and `tag_groups` are set (mutually exclusive)
- Add 5 integration tests for tag_groups compound filtering:
  * validation: 422 when both fields are set
  * AND filter: two leaf groups (step scope AND user scope)
  * OR compound: user:alice OR user:bob
  * NOT compound: user:alice AND NOT archived
  * Nested: user:alice AND (step:5 OR step:8)

* ci: trigger CI run
2026-03-13 14:30:11 +01:00
1100 changed files with 160715 additions and 21386 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "hindsight",
"description": "Official Hindsight integrations for Claude Code",
"owner": {
"name": "vectorize-io"
},
"plugins": [
{
"name": "hindsight-memory",
"description": "Automatic long-term memory for Claude Code via Hindsight",
"source": "./hindsight-integrations/claude-code"
}
]
}
+205
View File
@@ -0,0 +1,205 @@
---
name: code-review
description: Review changed code against project standards. Checks for missing tests, dead code, type safety, lint issues, and coding conventions. Run after completing any implementation work.
user_invocable: true
---
# Code Review
Review all changed code against the project's quality standards and coding conventions.
## Code Standards
Read and internalize these standards before writing code. The review steps below verify compliance.
### Python Style
- Python 3.11+, type hints required
- Async throughout (asyncpg, async FastAPI)
- Pydantic models for request/response
- Ruff for linting (line-length 120)
- No Python files at project root - maintain clean directory structure
- **Never use multi-item tuple return values** — not even for internal/private functions. Always use a dataclass or Pydantic model. No exceptions, no "it's just two values" shortcuts. If a function returns more than one value, define a named type for it.
### Type Safety with Pydantic Models
**NEVER use raw `dict` types for structured data** — this applies to all code, including internal helpers and private functions. If the dict has known keys, it must be a dataclass or Pydantic model:
- Use Pydantic `BaseModel` for all data structures passed between functions
- Use `@dataclass` for lightweight internal data containers when Pydantic validation isn't needed
- Add `@field_validator` for type coercion (e.g., ensuring datetimes are timezone-aware)
- Avoid `dict.get()` patterns - use typed model attributes instead
- Parse external data (JSON, API responses) into Pydantic models at the boundary
- This catches type errors at parse time, not deep in business logic
- The only acceptable `dict` usage is for truly dynamic/unknown keys (e.g., arbitrary metadata, JSON blobs with no fixed schema)
```python
# BAD - error-prone dict access
def process(data: dict) -> str:
return data.get("name", "") # No validation, silent failures
# GOOD - typed and validated
class UserData(BaseModel):
name: str
created_at: datetime
@field_validator("created_at", mode="before")
@classmethod
def ensure_tz_aware(cls, v):
if isinstance(v, str):
v = datetime.fromisoformat(v.replace("Z", "+00:00"))
if v.tzinfo is None:
return v.replace(tzinfo=timezone.utc)
return v
def process(data: UserData) -> str:
return data.name # Type-safe, validated at construction
```
### TypeScript Style
- Next.js App Router for control plane
- Tailwind CSS with shadcn/ui components
### Code Comments
- **Always comment non-trivial technical decisions** with the reasoning behind the choice. If someone would ask "why is it done this way?", there should be a comment.
- **Keep comments up to date with history** — when changing an approach, update the comment to explain what was tried before and why it was changed. Comments serve as a tracker of previous implementations that likely had problems.
- Don't comment obvious code — only where the "why" isn't self-evident from the code itself.
```python
# BAD - no context for future readers
results = await asyncio.gather(*tasks, return_exceptions=True)
# GOOD - explains the non-obvious choice
# Use return_exceptions=True to avoid cancelling sibling tasks on failure.
# Previously we used TaskGroup but it cancelled all tasks when one failed,
# causing partial writes that left orphaned entity links (see #412).
results = await asyncio.gather(*tasks, return_exceptions=True)
```
### Branch Hygiene
- **Always start new feature branches from `origin/main`** — rebase to ensure a clean base.
- **Only include commits relevant to the PR/branch/feature** — no unrelated changes. If the branch contains commits that don't belong, they must be removed before merging.
### General Principles
- Don't add features, refactor code, or make "improvements" beyond what was asked
- Don't add unnecessary error handling for impossible scenarios
- Don't create helpers or abstractions for one-time operations
- No backwards-compatibility hacks (unused vars, re-exports, "removed" comments)
- Three similar lines of code is better than a premature abstraction
## Review Steps
### 1. Check branch hygiene
- Run `git log --oneline main..HEAD` to list all commits on the branch.
- Verify every commit is relevant to the feature/PR. Flag any unrelated commits.
- Check the branch is based on a recent `origin/main` (no stale base).
### 2. Identify changed files
Run `git diff --name-only HEAD` (unstaged) and `git diff --cached --name-only` (staged) to get all changed files. If there are no local changes, diff against the base branch using `git diff main...HEAD --name-only` and `git diff main...HEAD` to review all commits on the current branch.
### 3. Run linters
```bash
./scripts/hooks/lint.sh
```
Report any failures. Do NOT fix them yourself — just report.
### 4. Check for dead code
For each changed Python file, check for:
- Unused imports (Ruff should catch these, but verify)
- Functions/methods/classes that were added but are never called from anywhere
- Variables assigned but never read
- Commented-out code blocks that should be removed
For each changed TypeScript file, check for:
- Unused imports
- Unused variables or functions
- Commented-out code
### 5. Check type safety (Python)
For each changed Python file, check for violations:
- **No raw `dict` for structured data** — must use Pydantic model or dataclass, even for internal/private functions (only exception: truly dynamic/unknown keys)
- **No multi-item tuple returns** — must use dataclass or Pydantic model, even for internal/private functions (no exceptions)
- **Missing type hints** on function parameters and return types
- **Missing `@field_validator`** for datetime fields that should be timezone-aware
### 6. Check for missing tests
For each new or significantly changed function/endpoint/class:
- Check if there is a corresponding test addition or update
- New API endpoints MUST have integration tests
- New utility functions MUST have unit tests
- Bug fixes SHOULD have a regression test
Flag any new logic that lacks test coverage.
### 7. Check API consistency
If any files in `hindsight-api-slim/hindsight_api/api/` were changed:
- Were the OpenAPI specs regenerated? (`./scripts/generate-openapi.sh`)
- Were the client SDKs regenerated? (`./scripts/generate-clients.sh`)
- Were the control plane proxy routes updated? (`hindsight-control-plane/src/app/api/`)
### 8. Check code comments
For each non-trivial change:
- **New non-obvious logic** — is there a comment explaining the reasoning?
- **Changed approach** — does the comment include what was done before and why it changed?
- **Stale comments** — do existing comments near the changed code still accurately describe the behavior?
### 9. Check integration completeness
If any files in `hindsight-integrations/` were added or changed, verify:
- **Tests exist** — the integration must have tests that simulate/exercise the external framework (not just pure unit tests of helpers). Check for a `tests/` directory with meaningful test files.
- **CI job exists** — check `.github/workflows/test.yml` for a corresponding `test-<name>-integration` job. If missing, flag it.
- **Release process** — check that the integration name is in the `VALID_INTEGRATIONS` array in `scripts/release-integration.sh`. If missing, flag it.
- **Code standards** — the integration code must follow all Python style rules (type hints, no raw dicts, no tuple returns, etc.).
### 10. Check MCP tool registration completeness
If any new MCP tools were added or existing tools renamed in `hindsight-api-slim/hindsight_api/mcp_tools.py`:
- **`_ALL_TOOLS` set** in `mcp_tools.py` — must include the new tool name
- **`tools_to_register` default set** in `register_mcp_tools()` in `mcp_tools.py` — must include the new tool name
- **`_SINGLE_BANK_TOOLS` set** in `hindsight-api-slim/hindsight_api/api/mcp.py` — must include the new tool if it is bank-scoped (not a bank-management tool like `list_banks`/`create_bank`)
- **`MCP_TOOL_GROUPS`** in `hindsight-control-plane/src/components/bank-config-view.tsx` — must include the new tool in the appropriate group for the UI tool selector
- **Tool count assertions** in tests (e.g., `test_mcp_tools.py`) — must be updated to reflect the new count
### 11. Review against other coding standards
Check the diff for violations of the standards listed above:
- Python files at project root (not allowed)
- Missing async patterns (should be async throughout)
- Pydantic models for request/response
- Line length > 120 chars
- New features/code beyond what was asked (over-engineering)
- Unnecessary error handling for impossible scenarios
- Premature abstractions or speculative helpers
- Backwards-compatibility hacks (unused vars, re-exports, "removed" comments)
### 12. Report findings
Present a clear summary organized by severity:
**Must fix** — issues that will break CI or violate hard project rules:
- Unrelated commits on the branch
- Lint failures
- Missing type hints on public functions
- Raw dict usage for structured data (including internal code)
- Multi-item tuple returns (including internal code)
- Missing tests for new endpoints
- New integration missing tests, CI job, or release-integration.sh entry
**Should fix** — issues that hurt code quality:
- Dead code / unused imports missed by linter
- Missing tests for non-trivial utility functions
- Over-engineering beyond the task scope
**Note** — observations that may or may not need action:
- API changes that might need client regeneration
- Patterns that deviate from nearby code style
For each finding, include the file path, line number, and a brief explanation.
Do NOT auto-fix any issues. Report all findings and let the user decide what to address. If there are no findings, confirm the code looks good.
+4 -3
View File
@@ -2,7 +2,7 @@
# Copy this file to .env and fill in your values
# LLM Configuration (Required)
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai, minimax
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai, minimax, volcano
HINDSIGHT_API_LLM_PROVIDER=openai
HINDSIGHT_API_LLM_API_KEY=your-api-key-here
HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
@@ -20,10 +20,10 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# HINDSIGHT_API_LLM_VERTEXAI_REGION=us-central1
# HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY=/path/to/service-account-key.json # Optional, uses ADC if not set
# Example: MiniMax configuration (204K context window)
# Example: MiniMax configuration (1M context window)
# HINDSIGHT_API_LLM_PROVIDER=minimax
# HINDSIGHT_API_LLM_API_KEY=your-minimax-api-key
# HINDSIGHT_API_LLM_MODEL=MiniMax-M2.5
# HINDSIGHT_API_LLM_MODEL=MiniMax-M2.7
# Example: LM Studio local configuration (Qwen 2.5 32B recommended)
# HINDSIGHT_API_LLM_PROVIDER=lmstudio
@@ -44,6 +44,7 @@ HINDSIGHT_API_LOG_LEVEL=info
# Database (Optional - uses embedded pg0 by default)
# HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/db
# HINDSIGHT_API_MIGRATION_DATABASE_URL= # Direct PostgreSQL URL for migrations (bypasses PgBouncer). Falls back to DATABASE_URL.
# HINDSIGHT_API_DATABASE_SCHEMA=public # PostgreSQL schema name (default: public)
# Vector Extension (Optional - uses pgvector by default)
+1 -1
View File
@@ -44,5 +44,5 @@ jobs:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/deploy-pages@v4
- uses: actions/deploy-pages@v5
id: deployment
+120
View File
@@ -0,0 +1,120 @@
name: Release Integration
on:
push:
tags:
- 'integrations/**'
jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write # for PyPI trusted publishing
steps:
- uses: actions/checkout@v6
- name: Extract integration info
id: info
run: |
# refs/tags/integrations/litellm/v0.1.0 → integration=litellm, version=0.1.0
TAG="${GITHUB_REF#refs/tags/}"
INTEGRATION=$(echo "$TAG" | cut -d'/' -f2)
VERSION=$(echo "$TAG" | cut -d'/' -f3 | sed 's/^v//')
echo "integration=$INTEGRATION" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "Integration: $INTEGRATION, Version: $VERSION"
- name: Detect integration type
id: type
run: |
if [ -f "hindsight-integrations/${{ steps.info.outputs.integration }}/pyproject.toml" ]; then
echo "type=python" >> $GITHUB_OUTPUT
elif [ -f "hindsight-integrations/${{ steps.info.outputs.integration }}/package.json" ]; then
echo "type=typescript" >> $GITHUB_OUTPUT
else
echo "type=plugin" >> $GITHUB_OUTPUT
fi
# ── Python integrations (litellm, pydantic-ai, crewai) ──────────────────
- name: Install uv
if: steps.type.outputs.type == 'python'
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Set up Python
if: steps.type.outputs.type == 'python'
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build Python package
if: steps.type.outputs.type == 'python'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: uv build --out-dir dist
- name: Publish Python package to PyPI
if: steps.type.outputs.type == 'python'
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/${{ steps.info.outputs.integration }}/dist
skip-existing: true
# ── TypeScript integrations (ai-sdk, chat, openclaw) ────────────────────
# ── Plugin integrations (claude-code) — no package to publish ───────────
- name: Plugin release
if: steps.type.outputs.type == 'plugin'
run: |
echo "Plugin integration ${{ steps.info.outputs.integration }} v${{ steps.info.outputs.version }} — no package to publish."
echo "Users install via: claude plugin marketplace add vectorize-io/hindsight --sparse hindsight-integrations"
# ── TypeScript integrations (ai-sdk, chat, openclaw) ────────────────────
- name: Set up Node.js
if: steps.type.outputs.type == 'typescript'
uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
# Guard: fail fast if the integration's lockfile resolves any dep from a
# monorepo workspace (link=true) or a relative file path. The release
# runner has no pre-built workspace `dist/` so `npm run build` would
# later fail at tsc with "Cannot find module". See:
# https://github.com/vectorize-io/hindsight/issues/… (0.6.0 openclaw retry)
- name: Check integration lockfile
if: steps.type.outputs.type == 'typescript'
run: ./scripts/check-integration-lockfiles.sh
- name: Install dependencies
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: npm ci
- name: Build TypeScript package
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: npm run build
- name: Publish TypeScript package to npm
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+28 -177
View File
@@ -21,7 +21,7 @@ jobs:
enable-cache: true
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
@@ -46,22 +46,10 @@ jobs:
working-directory: ./hindsight-all-slim
run: uv build --out-dir dist
- name: Build hindsight-litellm
working-directory: ./hindsight-integrations/litellm
run: uv build --out-dir dist
- name: Build hindsight-embed
working-directory: ./hindsight-embed
run: uv build --out-dir dist
- name: Build hindsight-crewai
working-directory: ./hindsight-integrations/crewai
run: uv build --out-dir dist
- name: Build hindsight-pydantic-ai
working-directory: ./hindsight-integrations/pydantic-ai
run: uv build --out-dir dist
# Publish in order (client and api-slim first, then api/all wrappers which depend on them)
- name: Publish hindsight-client to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
@@ -93,30 +81,12 @@ jobs:
packages-dir: ./hindsight-all-slim/dist
skip-existing: true
- name: Publish hindsight-litellm to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/litellm/dist
skip-existing: true
- name: Publish hindsight-embed to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-embed/dist
skip-existing: true
- name: Publish hindsight-crewai to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/crewai/dist
skip-existing: true
- name: Publish hindsight-pydantic-ai to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/pydantic-ai/dist
skip-existing: true
# Upload artifacts for GitHub release
- name: Upload artifacts
uses: actions/upload-artifact@v7
@@ -128,10 +98,7 @@ jobs:
hindsight-api/dist/*
hindsight-all/dist/*
hindsight-all-slim/dist/*
hindsight-integrations/litellm/dist/*
hindsight-embed/dist/*
hindsight-integrations/crewai/dist/*
hindsight-integrations/pydantic-ai/dist/*
retention-days: 1
release-typescript-client:
@@ -183,7 +150,7 @@ jobs:
path: hindsight-clients/typescript/*.tgz
retention-days: 1
release-openclaw-integration:
release-hindsight-all-npm:
runs-on: ubuntu-latest
environment: npm
@@ -195,17 +162,17 @@ jobs:
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install dependencies
working-directory: ./hindsight-integrations/openclaw
run: npm ci
run: npm ci --workspace=hindsight-all-npm
- name: Build
working-directory: ./hindsight-integrations/openclaw
run: npm run build
run: npm run build --workspace=hindsight-all-npm
- name: Publish to npm
working-directory: ./hindsight-integrations/openclaw
working-directory: ./hindsight-all-npm
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
@@ -222,112 +189,14 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-integrations/openclaw
working-directory: ./hindsight-all-npm
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
name: openclaw-integration
path: hindsight-integrations/openclaw/*.tgz
retention-days: 1
release-ai-sdk-integration:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-integrations/ai-sdk
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/ai-sdk
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/ai-sdk
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-integrations/ai-sdk
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
name: ai-sdk-integration
path: hindsight-integrations/ai-sdk/*.tgz
retention-days: 1
release-chat-integration:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-integrations/chat
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/chat
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/chat
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-integrations/chat
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
name: chat-integration
path: hindsight-integrations/chat/*.tgz
name: hindsight-all-npm
path: hindsight-all-npm/*.tgz
retention-days: 1
release-control-plane:
@@ -562,7 +431,7 @@ jobs:
- uses: actions/checkout@v6
- name: Install Helm
uses: azure/setup-helm@v4
uses: azure/setup-helm@v5
with:
version: 'latest'
@@ -587,7 +456,7 @@ jobs:
create-github-release:
runs-on: ubuntu-latest
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-ai-sdk-integration, release-chat-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
needs: [release-python-packages, release-typescript-client, release-hindsight-all-npm, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
permissions:
contents: write
@@ -599,61 +468,49 @@ jobs:
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Download Python packages
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: python-packages
path: ./artifacts/python-packages
- name: Download TypeScript client
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: typescript-client
path: ./artifacts/typescript-client
- name: Download OpenClaw Integration
uses: actions/download-artifact@v4
with:
name: openclaw-integration
path: ./artifacts/openclaw-integration
- name: Download AI SDK Integration
uses: actions/download-artifact@v4
with:
name: ai-sdk-integration
path: ./artifacts/ai-sdk-integration
- name: Download Chat Integration
uses: actions/download-artifact@v4
with:
name: chat-integration
path: ./artifacts/chat-integration
- name: Download Control Plane
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: control-plane
path: ./artifacts/control-plane
- name: Download hindsight-embed npm wrapper
uses: actions/download-artifact@v8
with:
name: hindsight-all-npm
path: ./artifacts/hindsight-all-npm
- name: Download Rust CLI (Linux)
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: rust-cli-hindsight-linux-amd64
path: ./artifacts/rust-cli-linux
- name: Download Rust CLI (macOS Intel)
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: rust-cli-hindsight-darwin-amd64
path: ./artifacts/rust-cli-darwin-amd64
- name: Download Rust CLI (macOS ARM)
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: rust-cli-hindsight-darwin-arm64
path: ./artifacts/rust-cli-darwin-arm64
- name: Download Helm chart
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: helm-chart
path: ./artifacts/helm-chart
@@ -667,17 +524,11 @@ jobs:
cp artifacts/python-packages/hindsight-api/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-all/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-all-slim/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-integrations/litellm/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-integrations/pydantic-ai/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true
# TypeScript client
cp artifacts/typescript-client/*.tgz release-assets/ || true
# OpenClaw Integration
cp artifacts/openclaw-integration/*.tgz release-assets/ || true
# AI SDK Integration
cp artifacts/ai-sdk-integration/*.tgz release-assets/ || true
# Chat Integration
cp artifacts/chat-integration/*.tgz release-assets/ || true
# hindsight-embed npm wrapper
cp artifacts/hindsight-all-npm/*.tgz release-assets/ || true
# Control Plane
cp artifacts/control-plane/*.tgz release-assets/ || true
# Rust CLI binaries
@@ -689,7 +540,7 @@ jobs:
ls -la release-assets/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: release-assets/*
generate_release_notes: true
+1119 -47
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -50,7 +50,8 @@ hindsight-dev/benchmarks/perf/results/
benchmarks/results/
hindsight-cli/target
hindsight-clients/rust/target
.claude
.claude/*
!.claude/skills/
whats-next.md
TASK.md
# Changelog is now tracked in hindsight-docs/src/pages/changelog.md
+34 -51
View File
@@ -11,9 +11,15 @@ Hindsight is an agent memory system that provides long-term memory for AI agents
## Development Commands
### Local Development (API + UI)
```bash
# Start both API server and control plane UI
./scripts/dev/start.sh
```
### API Server (Python/FastAPI)
```bash
# Start API server (loads .env automatically)
# Start API server only (loads .env automatically)
./scripts/dev/start-api.sh
# Run all tests (parallelized with pytest-xdist)
@@ -73,17 +79,16 @@ cd hindsight-control-plane && npm run dev
### Monorepo Structure
- **hindsight-api-slim/**: Core FastAPI server with memory engine (Python, uv)
- **hindsight/**: Embedded Python bundle (hindsight-all package)
- **hindsight-control-plane/**: Admin UI (Next.js, npm)
- **hindsight-cli/**: CLI tool (Rust, cargo, uses progenitor for API client)
- **hindsight-clients/**: Generated SDK clients (Python, TypeScript, Rust)
- **hindsight-docs/**: Docusaurus documentation site
- **hindsight-integrations/**: Framework integrations (LiteLLM, OpenAI)
- **hindsight-integrations/**: Framework integrations (LiteLLM, CrewAI, LangGraph, Pydantic AI, AG2, Claude Code, etc.)
- **hindsight-dev/**: Development tools and benchmarks
### Core Engine (hindsight-api-slim/hindsight_api/engine/)
- `memory_engine.py`: Main orchestrator (~170KB) for retain/recall/reflect operations
- `llm_wrapper.py`: LLM abstraction supporting OpenAI, Anthropic, Gemini, Groq, MiniMax, Ollama, LM Studio
- `memory_engine.py`: Main orchestrator for retain/recall/reflect operations
- `llm_wrapper.py`: LLM abstraction supporting OpenAI, Anthropic, Gemini, VertexAI, Groq, MiniMax, Ollama, LM Studio, LiteLLM, Claude Code
- `embeddings.py`: Embedding generation (local sentence-transformers or TEI)
- `cross_encoder.py`: Reranking (local or TEI)
- `entity_resolver.py`: Entity extraction and normalization
@@ -96,13 +101,13 @@ cd hindsight-control-plane && npm run dev
**search/**: Multi-strategy retrieval
- `retrieval.py`: Main retrieval orchestrator
- `graph_retrieval.py`: Entity/relationship graph traversal
- `mpfp_retrieval.py`: Multi-Path Fact Propagation retrieval
- `graph_retrieval.py`: Graph retrieval abstract base class
- `link_expansion_retrieval.py`: Link expansion graph retrieval
- `fusion.py`: Reciprocal rank fusion for combining results
- `reranking.py`: Cross-encoder reranking
### API Layer (hindsight-api-slim/hindsight_api/api/)
- `http.py`: FastAPI HTTP routers (~80KB) for all REST endpoints
- `http.py`: FastAPI HTTP routers for all REST endpoints
- `mcp.py`: Model Context Protocol server implementation
Main operations:
@@ -164,11 +169,17 @@ Key tables: `banks`, `memory_units`, `documents`, `entities`, `entity_links`
## Key Conventions
### Code Quality
**Before writing code, read `.claude/skills/code-review/SKILL.md`** for the full coding standards (Python style, type safety, TypeScript style, general principles).
**Always run the lint script after making Python or TypeScript/Node changes:**
```bash
./scripts/hooks/lint.sh
```
This runs the same checks as the pre-commit hook (Ruff for Python, ESLint/Prettier for TypeScript).
**After completing any implementation work, run `/code-review`** to verify your changes against project standards (missing tests, dead code, type safety, etc.). Fix any "must fix" issues before considering the task done.
**MANDATORY: Run `/code-review` before pushing code or creating a pull request.** Do not push or create a PR until all "must fix" issues are resolved.
### Memory Banks
- Each bank is an isolated memory store (like a "brain" for one user/agent)
@@ -200,48 +211,20 @@ When adding or modifying parameters in the dataplane API (hindsight-api), you mu
- Update the client type definition in `lib/api.ts`
- Update any UI components that need to use the new parameter
### Python Style
- Python 3.11+, type hints required
- Async throughout (asyncpg, async FastAPI)
- Pydantic models for request/response
- Ruff for linting (line-length 120)
- No Python files at project root - maintain clean directory structure
- **Never use multi-item tuple return values** - prefer dataclass or Pydantic model for structured returns
### Adding New Integrations
### Type Safety with Pydantic Models
**NEVER use raw `dict` types for structured data.** Always use Pydantic models:
- Use Pydantic `BaseModel` for all data structures passed between functions
- Add `@field_validator` for type coercion (e.g., ensuring datetimes are timezone-aware)
- Avoid `dict.get()` patterns - use typed model attributes instead
- Parse external data (JSON, API responses) into Pydantic models at the boundary
- This catches type errors at parse time, not deep in business logic
Every new integration in `hindsight-integrations/` must satisfy all of the following before it can be merged:
```python
# BAD - error-prone dict access
def process(data: dict) -> str:
return data.get("name", "") # No validation, silent failures
1. **Tests are required** — tests must simulate or exercise the external system (mock the framework's interfaces and verify the integration actually calls Hindsight correctly). Pure unit tests of helper functions are not sufficient.
2. **CI job** — add a test job in `.github/workflows/test.yml` following the existing pattern (e.g., `test-crewai-integration`). The job must build, install deps, and run `uv run pytest tests -v`. Also add the integration to `detect-changes` outputs so it only runs when its files change.
3. **Release process** — add the integration name to the `VALID_INTEGRATIONS` array in `scripts/release-integration.sh` so it can be released via the standard release workflow.
4. **Follow project code standards** — Python style, type safety, no raw dicts for structured data, no multi-item tuple returns (see `.claude/skills/code-review/SKILL.md`).
# GOOD - typed and validated
class UserData(BaseModel):
name: str
created_at: datetime
If any of these are missing, the integration is incomplete and must not be pushed or merged.
@field_validator("created_at", mode="before")
@classmethod
def ensure_tz_aware(cls, v):
if isinstance(v, str):
v = datetime.fromisoformat(v.replace("Z", "+00:00"))
if v.tzinfo is None:
return v.replace(tzinfo=timezone.utc)
return v
### Changelogs
def process(data: UserData) -> str:
return data.name # Type-safe, validated at construction
```
### TypeScript Style
- Next.js App Router for control plane
- Tailwind CSS with shadcn/ui components
Never add "Unreleased" entries to changelogs (e.g. `hindsight-docs/src/pages/changelog/**`). Changelog entries are written by the release script (`./scripts/release-integration.sh`) when a version is actually cut. If a bug fix or feature needs documenting before release, describe it in the PR/commit — the release tooling will surface it in the published changelog section.
### Adding New API Configuration Flags
@@ -255,17 +238,17 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
- Add `ENV_*` constant for the environment variable name (e.g., `ENV_MY_SETTING = "HINDSIGHT_API_MY_SETTING"`)
- Add `DEFAULT_*` constant for the default value
- Add field to `HindsightConfig` dataclass with type annotation
- **Mark as hierarchical or static** by adding to `_HIERARCHICAL_FIELDS` set (hierarchical) or leaving it out (static)
- **Mark as configurable** by adding to `_CONFIGURABLE_FIELDS` set if the field should be overridable per-tenant/bank via API
- Add initialization in `from_env()` method
```python
# Hierarchical field (can be overridden per-bank)
_HIERARCHICAL_FIELDS = {
# Configurable field (can be overridden per-tenant/bank via API)
_CONFIGURABLE_FIELDS = {
...,
"my_setting", # Add here for hierarchical
"my_setting", # Add here for configurable
}
# Static field - just don't add to _HIERARCHICAL_FIELDS
# Static field - just don't add to _CONFIGURABLE_FIELDS
```
2. **main.py** (`hindsight-api-slim/hindsight_api/main.py`):
+1
View File
@@ -7,6 +7,7 @@
[![CI](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml/badge.svg)](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml)
[![Slack Community](https://img.shields.io/badge/Slack-Join%20Community-4A154B?logo=slack)](https://join.slack.com/t/hindsight-space/shared_invite/zt-3nhbm4w29-LeSJ5Ixi6j8PdiYOCPlOgg)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![gitcgr](https://gitcgr.com/badge/vectorize-io/hindsight.svg)](https://gitcgr.com/vectorize-io/hindsight)
![PyPI - Downloads](https://img.shields.io/pypi/dm/hindsight-api?label=PyPI)
![NPM Downloads](https://img.shields.io/npm/dm/%40vectorize-io%2Fhindsight-client?logoColor=orange&label=NPM&color=blue&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40vectorize-io%2Fhindsight-client)
<br/>
Generated
+139
View File
@@ -0,0 +1,139 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@^1.0.17": "1.0.19",
"jsr:@std/assert@^1.0.19": "1.0.19",
"jsr:@std/expect@*": "1.0.18",
"jsr:@std/internal@^1.0.12": "1.0.12",
"jsr:@std/path@^1.1.4": "1.1.4",
"jsr:@std/testing@*": "1.0.17"
},
"jsr": {
"@std/[email protected]": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/[email protected]": {
"integrity": "8566eab35200466f8609eb7e7aed062ed0db314e9a258d5d201b1b8997ce801a",
"dependencies": [
"jsr:@std/assert@^1.0.19",
"jsr:@std/internal",
"jsr:@std/path"
]
},
"@std/[email protected]": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
},
"@std/[email protected]": {
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/[email protected]": {
"integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681",
"dependencies": [
"jsr:@std/assert@^1.0.17",
"jsr:@std/internal"
]
}
},
"workspace": {
"members": {
"hindsight-clients/typescript": {
"packageJson": {
"dependencies": [
"npm:@hey-api/[email protected]",
"npm:@types/jest@29",
"npm:@types/node@20",
"npm:jest@29",
"npm:ts-jest@29",
"npm:tsup@^8.5.1",
"npm:typescript@5"
]
}
},
"hindsight-control-plane": {
"packageJson": {
"dependencies": [
"npm:@eslint/eslintrc@^3.3.3",
"npm:@eslint/js@^9.39.2",
"npm:@radix-ui/react-alert-dialog@^1.1.15",
"npm:@radix-ui/react-checkbox@^1.3.3",
"npm:@radix-ui/react-dialog@^1.1.15",
"npm:@radix-ui/react-dropdown-menu@^2.1.16",
"npm:@radix-ui/react-label@^2.1.8",
"npm:@radix-ui/react-popover@^1.1.15",
"npm:@radix-ui/react-radio-group@^1.3.8",
"npm:@radix-ui/react-select@^2.2.6",
"npm:@radix-ui/react-slider@^1.3.6",
"npm:@radix-ui/react-slot@^1.2.4",
"npm:@radix-ui/react-switch@^1.2.6",
"npm:@radix-ui/react-tabs@^1.1.13",
"npm:@radix-ui/react-tooltip@^1.2.8",
"npm:@tailwindcss/postcss@^4.1.17",
"npm:@tailwindcss/typography@~0.5.19",
"npm:@types/cytoscape@^3.21.9",
"npm:@types/node@^24.10.0",
"npm:@types/react-dom@^19.2.2",
"npm:@types/react@^19.2.2",
"npm:autoprefixer@^10.4.21",
"npm:class-variance-authority@~0.7.1",
"npm:clsx@^2.1.1",
"npm:cmdk@^1.1.1",
"npm:cytoscape-fcose@^2.2.0",
"npm:cytoscape@^3.33.1",
"npm:eslint-config-next@^16.0.1",
"npm:eslint-plugin-react-hooks@^7.0.1",
"npm:eslint-plugin-react@^7.37.5",
"npm:eslint@^9.39.1",
"npm:[email protected]",
"npm:next-themes@~0.4.6",
"npm:next@^16.1.6",
"npm:postcss@^8.5.6",
"npm:prettier@^3.7.4",
"npm:react-chrono@^2.9.1",
"npm:react-dom@^19.2.0",
"npm:react-markdown@^10.1.0",
"npm:react18-json-view@~0.2.9",
"npm:react@^19.2.0",
"npm:recharts@^3.5.1",
"npm:remark-gfm@^4.0.1",
"npm:sonner@^2.0.7",
"npm:tailwind-merge@^3.4.0",
"npm:tailwindcss-animate@^1.0.7",
"npm:tailwindcss@^4.1.17",
"npm:[email protected]",
"npm:typescript-eslint@^8.50.0",
"npm:typescript@^5.9.3"
]
}
},
"hindsight-docs": {
"packageJson": {
"dependencies": [
"npm:@docusaurus/[email protected]",
"npm:@docusaurus/[email protected]",
"npm:@docusaurus/[email protected]",
"npm:@docusaurus/theme-common@^3.9.2",
"npm:@docusaurus/theme-mermaid@^3.9.2",
"npm:@docusaurus/[email protected]",
"npm:@docusaurus/[email protected]",
"npm:@easyops-cn/docusaurus-search-local@~0.52.2",
"npm:@mdx-js/react@3",
"npm:clsx@2",
"npm:prism-react-renderer@^2.3.0",
"npm:raw-loader@^4.0.2",
"npm:react-dom@19",
"npm:react-icons@^5.6.0",
"npm:react@19",
"npm:redocusaurus@^2.5.0",
"npm:typescript@~5.6.2"
]
}
}
}
}
}
+98 -5
View File
@@ -1,6 +1,28 @@
#!/bin/bash
set -e
# =============================================================================
# Embedded pg0 data integrity check (#675)
#
# When using embedded pg0, check if the data directory has existing PostgreSQL
# data before starting. If the directory exists but appears empty/corrupt
# (e.g., missing PG_VERSION file), log a warning. This helps diagnose data
# loss scenarios where a container restart caused the data directory to be
# wiped despite a volume mount being present.
# =============================================================================
PG0_DATA_DIR="${HOME}/.pg0"
if [ -d "$PG0_DATA_DIR" ]; then
# Look for actual PostgreSQL data directories (pg0 creates subdirs per instance)
if compgen -G "$PG0_DATA_DIR"/*/PG_VERSION > /dev/null 2>&1; then
echo "✅ Existing pg0 data directory detected at $PG0_DATA_DIR"
elif [ "$(ls -A "$PG0_DATA_DIR" 2>/dev/null)" ]; then
echo "⚠️ WARNING: pg0 data directory exists at $PG0_DATA_DIR but no PG_VERSION found."
echo " This may indicate data corruption or an incomplete previous shutdown."
echo " If you see all migrations running from scratch after this, your data may have been lost."
echo " See: https://github.com/vectorize-io/hindsight/issues/675"
fi
fi
# Service flags (default to true if not set)
ENABLE_API="${HINDSIGHT_ENABLE_API:-true}"
ENABLE_CP="${HINDSIGHT_ENABLE_CP:-true}"
@@ -71,6 +93,63 @@ if [ "${HINDSIGHT_WAIT_FOR_DEPS:-false}" = "true" ]; then
done
fi
# =============================================================================
# Graceful shutdown handler (#675)
#
# Docker sends SIGTERM on `docker stop`/`docker restart`. Without a trap, child
# processes (hindsight-api + pg0, control-plane) are killed abruptly. For the
# embedded pg0 database this can cause data loss when the data directory is on
# a Docker volume that gets remounted after restart.
#
# The trap forwards SIGTERM to all tracked child PIDs so that:
# - hindsight-api receives the signal and can run its shutdown hooks
# - pg0 gets a clean PostgreSQL shutdown (checkpoint + WAL flush)
# - The control-plane Node.js process exits cleanly
# =============================================================================
# Guard against concurrent cleanup (e.g., child crash + SIGTERM arriving together)
SHUTTING_DOWN=false
cleanup() {
if $SHUTTING_DOWN; then return; fi
SHUTTING_DOWN=true
echo ""
echo "🛑 Received shutdown signal, stopping services gracefully..."
for pid in "${PIDS[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
kill -TERM "$pid" 2>/dev/null
fi
done
# Give processes time to shut down cleanly (pg0 needs to flush WAL).
# NOTE: Docker's default stop_grace_period is 10s. If you use the default,
# either set stop_grace_period: 30s in your compose file / docker stop -t 30,
# or Docker will SIGKILL the container before this timeout expires.
local timeout=30
for ((i=1; i<=timeout; i++)); do
local all_stopped=true
for pid in "${PIDS[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
all_stopped=false
break
fi
done
if $all_stopped; then
echo "✅ All services stopped cleanly"
exit 0
fi
sleep 1
done
# Force kill if still running after timeout
echo "⚠️ Timeout reached, forcing shutdown..."
for pid in "${PIDS[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
kill -9 "$pid" 2>/dev/null
fi
done
exit 1
}
trap cleanup SIGTERM SIGINT
# Track PIDs for wait
PIDS=()
@@ -111,6 +190,7 @@ fi
if [ "$ENABLE_CP" = "true" ]; then
echo "🎛️ Starting Control Plane..."
cd /app/control-plane
export HOSTNAME="${HINDSIGHT_CP_HOSTNAME:-0.0.0.0}"
PORT="${HINDSIGHT_CP_PORT:-9999}" node server.js &
CP_PID=$!
PIDS+=($CP_PID)
@@ -137,8 +217,21 @@ if [ ${#PIDS[@]} -eq 0 ]; then
exit 1
fi
# Wait for any process to exit
wait -n
# Exit with status of first exited process
exit $?
# Wait for any process to exit (use wait -n with trap-safe loop)
while true; do
# wait -n returns when any child exits; it also returns on signal delivery
# (the trap handler will run and exit, so this loop is just for robustness).
# `&& true` prevents `set -e` from killing the script when wait -n returns
# non-zero (child exited with error or no backgrounded children remain).
wait -n && true
# Check if any tracked PID has exited
for pid in "${PIDS[@]}"; do
if ! kill -0 "$pid" 2>/dev/null; then
wait "$pid" 2>/dev/null
exit_code=$?
echo "⚠️ Service (PID $pid) exited with code $exit_code"
# Trigger cleanup for remaining services
cleanup
fi
done
done
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.4.17
appVersion: "0.4.17"
version: 0.5.1
appVersion: "0.5.1"
keywords:
- ai
- memory
@@ -95,6 +95,27 @@ spec:
{{- toYaml .Values.api.readinessProbe | nindent 10 }}
resources:
{{- toYaml .Values.api.resources | nindent 10 }}
{{- if or .Values.api.persistence.modelCache.enabled .Values.api.extraVolumeMounts }}
volumeMounts:
{{- if .Values.api.persistence.modelCache.enabled }}
- name: model-cache
mountPath: /home/hindsight/.cache
{{- end }}
{{- with .Values.api.extraVolumeMounts }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- if or .Values.api.persistence.modelCache.enabled .Values.api.extraVolumes }}
volumes:
{{- if .Values.api.persistence.modelCache.enabled }}
- name: model-cache
persistentVolumeClaim:
claimName: {{ include "hindsight.fullname" . }}-api-model-cache
{{- end }}
{{- with .Values.api.extraVolumes }}
{{- toYaml . | nindent 6 }}
{{- end }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
@@ -0,0 +1,21 @@
{{- if and .Values.api.enabled .Values.api.persistence.modelCache.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "hindsight.fullname" . }}-api-model-cache
labels:
{{- include "hindsight.api.labels" . | nindent 4 }}
{{- with .Values.api.persistence.modelCache.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
{{- toYaml .Values.api.persistence.modelCache.accessModes | nindent 4 }}
{{- if .Values.api.persistence.modelCache.storageClass }}
storageClassName: {{ .Values.api.persistence.modelCache.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.api.persistence.modelCache.size }}
{{- end }}
@@ -95,6 +95,16 @@ spec:
{{- toYaml .Values.worker.readinessProbe | nindent 10 }}
resources:
{{- toYaml .Values.worker.resources | nindent 10 }}
{{- if or .Values.worker.persistence.modelCache.enabled .Values.worker.extraVolumeMounts }}
volumeMounts:
{{- if .Values.worker.persistence.modelCache.enabled }}
- name: model-cache
mountPath: /home/hindsight/.cache
{{- end }}
{{- with .Values.worker.extraVolumeMounts }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
@@ -107,4 +117,26 @@ spec:
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.worker.extraVolumes }}
volumes:
{{- toYaml . | nindent 6 }}
{{- end }}
{{- if .Values.worker.persistence.modelCache.enabled }}
volumeClaimTemplates:
- metadata:
name: model-cache
{{- with .Values.worker.persistence.modelCache.annotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
accessModes:
{{- toYaml .Values.worker.persistence.modelCache.accessModes | nindent 8 }}
{{- if .Values.worker.persistence.modelCache.storageClass }}
storageClassName: {{ .Values.worker.persistence.modelCache.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.worker.persistence.modelCache.size }}
{{- end }}
{{- end }}
+53
View File
@@ -67,6 +67,33 @@ api:
# Pod affinity/anti-affinity (overrides global affinity for this component)
# affinity: {}
# Persistent volume for local model cache (reranker, embeddings)
# Models are downloaded to /home/hindsight/.cache on first use.
# Without persistence, models are re-downloaded on every pod restart.
persistence:
modelCache:
enabled: false
size: 5Gi
storageClass: ""
accessModes:
- ReadWriteOnce
annotations: {}
# Extra volume mounts for the api container
# e.g.
# extraVolumeMounts:
# - name: my-volume
# mountPath: /mnt/my-volume
extraVolumeMounts: []
# Extra volumes for the api pod
# e.g.
# extraVolumes:
# - name: my-volume
# configMap:
# name: my-configmap
extraVolumes: []
# Environment variables
env:
#HINDSIGHT_API_LLM_PROVIDER: "groq"
@@ -140,6 +167,32 @@ worker:
# Pod affinity/anti-affinity (overrides global affinity for this component)
# affinity: {}
# Persistent volume for local model cache (reranker, embeddings)
# Uses volumeClaimTemplates since worker is a StatefulSet.
persistence:
modelCache:
enabled: false
size: 5Gi
storageClass: ""
accessModes:
- ReadWriteOnce
annotations: {}
# Extra volume mounts for the worker container
# e.g.
# extraVolumeMounts:
# - name: my-volume
# mountPath: /mnt/my-volume
extraVolumeMounts: []
# Extra volumes for the worker pod
# e.g.
# extraVolumes:
# - name: my-volume
# configMap:
# name: my-configmap
extraVolumes: []
# Secret environment variables (inherited from api.secrets if not specified)
secrets: {}
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
*.tgz
.DS_Store
+80
View File
@@ -0,0 +1,80 @@
# @vectorize-io/hindsight-all
Node.js equivalent of the Python [`hindsight-all`](https://pypi.org/project/hindsight-all/) package — programmatic lifecycle manager for a local Hindsight daemon. Use this when you want to embed Hindsight in a Node application without hand-rolling subprocess management.
This package deliberately does **not** ship an HTTP client. Once the daemon is running, talk to it with [`@vectorize-io/hindsight-client`](https://www.npmjs.com/package/@vectorize-io/hindsight-client) against `server.getBaseUrl()`. The two packages compose — one owns the daemon process, the other owns the HTTP API surface.
## Requirements
- **Node.js >= 22** — uses global `fetch` and `AbortSignal.timeout`.
- **`uv` / `uvx`** on `PATH` — used to download and run the underlying `hindsight-embed` daemon on first use. Install via <https://docs.astral.sh/uv/>.
## Install
```bash
npm install @vectorize-io/hindsight-all @vectorize-io/hindsight-client
```
## Example
```ts
import { HindsightServer, consoleLogger } from '@vectorize-io/hindsight-all';
import { HindsightClient } from '@vectorize-io/hindsight-client';
const server = new HindsightServer({
profile: 'my-app',
port: 9077,
env: {
HINDSIGHT_API_LLM_PROVIDER: 'anthropic',
HINDSIGHT_API_LLM_API_KEY: process.env.ANTHROPIC_API_KEY,
HINDSIGHT_API_LLM_MODEL: 'claude-sonnet-4-20250514',
HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT: '0',
},
logger: consoleLogger,
});
await server.start();
const client = new HindsightClient({ baseUrl: server.getBaseUrl() });
await client.retain('user-123', 'User prefers dark mode and concise answers.', {
documentId: 'pref-2026-04-01',
});
const recall = await client.recall('user-123', 'what are the user preferences?');
console.log(recall.results);
await server.stop();
```
For a remote Hindsight API, skip `HindsightServer` entirely and just point `HindsightClient` at the remote URL.
## Open config — forward-compatible with new daemon flags
`HindsightServerOptions` is designed so every new environment variable or CLI flag in the underlying Hindsight daemon can be used without waiting for a wrapper release:
- **`env`** accepts an arbitrary `Record<string, string>`. Every entry is exported into the daemon process and written into the profile config via `--env KEY=VALUE`.
- **`extraProfileCreateArgs`** / **`extraDaemonStartArgs`** append raw args to the respective commands.
## Development against a local checkout
If you're hacking on the Python `hindsight-embed` package in the same monorepo, point the server at the local path — it'll use `uv run --directory <path>` instead of `uvx`:
```ts
new HindsightServer({
embedPackagePath: '/path/to/hindsight-embed',
// ...
});
```
## API surface
- `HindsightServer` — daemon lifecycle (`start`, `stop`, `checkHealth`, `getBaseUrl`, `getProfile`).
- `Logger` interface plus `silentLogger` (default) and `consoleLogger` helpers.
- `getEmbedCommand(opts)` — low-level helper that returns the `[cmd, ...args]` tuple used to invoke the underlying Python CLI.
For memory operations (retain, recall, reflect, bank management, stats) use [`@vectorize-io/hindsight-client`](https://www.npmjs.com/package/@vectorize-io/hindsight-client).
## License
MIT
+57
View File
@@ -0,0 +1,57 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.5.1",
"description": "Node.js programmatic lifecycle manager for Hindsight — embeds a local hindsight daemon in a Node application. Pair with @vectorize-io/hindsight-client for memory operations.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"keywords": [
"hindsight",
"hindsight-all",
"memory",
"ai",
"agent",
"long-term-memory",
"llm",
"embedded-server"
],
"author": "Vectorize <[email protected]>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/vectorize-io/hindsight.git",
"directory": "hindsight-all-npm"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"clean": "rm -rf dist",
"test": "vitest run src",
"test:watch": "vitest src",
"prepublishOnly": "npm run clean && npm run build"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsup": "^8.5.1",
"typescript": "^5.7.0",
"vitest": "^4.1.2"
},
"engines": {
"node": ">=22"
},
"overrides": {
"rollup": "^4.59.0",
"picomatch": ">=2.3.2 <3.0.0 || >=4.0.4",
"vite": ">=8.0.5"
}
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { getEmbedCommand } from './command.js';
describe('getEmbedCommand', () => {
it('defaults to uvx hindsight-embed@latest', () => {
expect(getEmbedCommand()).toEqual(['uvx', 'hindsight-embed@latest']);
});
it('honours an explicit version', () => {
expect(getEmbedCommand({ embedVersion: '0.5.0' })).toEqual(['uvx', '[email protected]']);
});
it('treats an empty version as latest', () => {
expect(getEmbedCommand({ embedVersion: '' })).toEqual(['uvx', 'hindsight-embed@latest']);
});
it('uses uv run --directory when a local path is given', () => {
expect(getEmbedCommand({ embedPackagePath: '/abs/path' })).toEqual([
'uv',
'run',
'--directory',
'/abs/path',
'hindsight-embed',
]);
});
it('local path takes precedence over version', () => {
expect(
getEmbedCommand({ embedPackagePath: '/abs/path', embedVersion: '0.5.0' }),
).toEqual(['uv', 'run', '--directory', '/abs/path', 'hindsight-embed']);
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* Resolve the command that invokes the `hindsight-embed` Python CLI.
*
* - If `embedPackagePath` is set, runs the package from a local checkout via
* `uv run --directory <path> hindsight-embed`. Used for in-repo development.
* - Otherwise runs it via `uvx hindsight-embed@<version>` so no global install
* is required.
*
* Returns the argv as `[command, ...baseArgs]` suitable for `spawn()` /
* `execFile()` (never shell-interpolated).
*/
export interface EmbedCommandOptions {
/** Version spec passed to uvx (e.g. "latest", "0.5.0"). Default: "latest". */
embedVersion?: string;
/** Local checkout path. When set, overrides `embedVersion` and uses `uv run`. */
embedPackagePath?: string;
}
export function getEmbedCommand(opts: EmbedCommandOptions = {}): string[] {
if (opts.embedPackagePath) {
return ['uv', 'run', '--directory', opts.embedPackagePath, 'hindsight-embed'];
}
const version = opts.embedVersion && opts.embedVersion.length > 0 ? opts.embedVersion : 'latest';
return ['uvx', `hindsight-embed@${version}`];
}
+7
View File
@@ -0,0 +1,7 @@
export { HindsightServer } from './server.js';
export { getEmbedCommand } from './command.js';
export { silentLogger, consoleLogger } from './logger.js';
export type { Logger } from './logger.js';
export type { EmbedCommandOptions } from './command.js';
export type { HindsightServerOptions } from './types.js';
+29
View File
@@ -0,0 +1,29 @@
/**
* Pluggable logger interface.
*
* This package does not own any logging infrastructure — consumers inject
* whatever they want (console, pino, openclaw's logger, a no-op). The default
* is silent so embedding this package never adds noise to an unrelated app.
*/
export interface Logger {
debug(msg: string): void;
info(msg: string): void;
warn(msg: string): void;
error(msg: string): void;
}
/** Logger that drops every call. Used when no logger is passed. */
export const silentLogger: Logger = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
/** Logger that writes to the standard console. Handy for CLIs and tests. */
export const consoleLogger: Logger = {
debug: (msg) => console.debug(msg),
info: (msg) => console.log(msg),
warn: (msg) => console.warn(msg),
error: (msg) => console.error(msg),
};
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { HindsightServer } from './server.js';
describe('HindsightServer construction', () => {
it('defaults base URL to http://127.0.0.1:8888', () => {
const server = new HindsightServer();
expect(server.getBaseUrl()).toBe('http://127.0.0.1:8888');
expect(server.getProfile()).toBe('default');
});
it('honours custom profile, port, and host', () => {
const server = new HindsightServer({ profile: 'app', port: 9077, host: '0.0.0.0' });
expect(server.getProfile()).toBe('app');
expect(server.getBaseUrl()).toBe('http://0.0.0.0:9077');
});
it('accepts open env pass-through without complaining about unknown keys', () => {
const server = new HindsightServer({
env: {
HINDSIGHT_API_LLM_PROVIDER: 'openai',
HINDSIGHT_API_LLM_MODEL: 'gpt-4o-mini',
// A field that does not exist today — should still be accepted
HINDSIGHT_FUTURE_FLAG: 'enabled',
},
});
expect(server).toBeInstanceOf(HindsightServer);
});
it('exposes checkHealth that returns false when no daemon is running', async () => {
// Random high port that nothing is listening on.
const server = new HindsightServer({ port: 1, readyTimeoutMs: 100 });
const healthy = await server.checkHealth();
expect(healthy).toBe(false);
});
});
+322
View File
@@ -0,0 +1,322 @@
import { spawn } from 'child_process';
import { getEmbedCommand } from './command.js';
import { silentLogger } from './logger.js';
import type { Logger } from './logger.js';
import type { HindsightServerOptions } from './types.js';
const DEFAULT_PORT = 8888;
const DEFAULT_HOST = '127.0.0.1';
const DEFAULT_PROFILE = 'default';
const DEFAULT_READY_TIMEOUT_MS = 30_000;
const DEFAULT_READY_POLL_INTERVAL_MS = 1_000;
/**
* Manages the lifecycle of a local Hindsight daemon from a Node.js process.
*
* On {@link start}, this class:
* 1. Resolves the `hindsight-embed` command (via `uvx` or a local `uv run`).
* 2. Runs `profile create <name> --merge --port <port> [--env K=V ...]`
* with every entry in {@link HindsightServerOptions.env} forwarded as
* an `--env` flag.
* 3. Runs `daemon --profile <name> start` and waits for the start command
* to exit.
* 4. Polls `http://host:port/health` until it returns `200` or the
* `readyTimeoutMs` budget is exhausted.
*
* On {@link stop}, it runs `daemon --profile <name> stop` and returns once
* the command exits (or after a short grace period).
*
* This is the Node.js equivalent of the Python `hindsight-all` package's
* `HindsightServer`: a thin programmatic lifecycle wrapper around the
* Hindsight daemon. It does NOT ship an HTTP client — once `start()`
* resolves, use `@vectorize-io/hindsight-client` against `getBaseUrl()` for
* retain / recall / reflect.
*
* The class is deliberately transparent about the daemon: new CLI flags or
* environment variables never require a code change here — callers can pass
* them via `env`, `extraProfileCreateArgs`, or `extraDaemonStartArgs`.
*/
export class HindsightServer {
private readonly profile: string;
private readonly port: number;
private readonly host: string;
private readonly baseUrl: string;
private readonly embedVersion: string | undefined;
private readonly embedPackagePath: string | undefined;
private readonly userEnv: Record<string, string | undefined>;
private readonly extraProfileCreateArgs: string[];
private readonly extraDaemonStartArgs: string[];
private readonly platformCpuWorkaround: boolean;
private readonly readyTimeoutMs: number;
private readonly readyPollIntervalMs: number;
private readonly logger: Logger;
constructor(opts: HindsightServerOptions = {}) {
this.profile = opts.profile ?? DEFAULT_PROFILE;
this.port = opts.port ?? DEFAULT_PORT;
this.host = opts.host ?? DEFAULT_HOST;
this.baseUrl = `http://${this.host}:${this.port}`;
this.embedVersion = opts.embedVersion;
this.embedPackagePath = opts.embedPackagePath;
this.userEnv = opts.env ?? {};
this.extraProfileCreateArgs = opts.extraProfileCreateArgs ?? [];
this.extraDaemonStartArgs = opts.extraDaemonStartArgs ?? [];
this.platformCpuWorkaround = opts.platformCpuWorkaround ?? (process.platform === 'darwin');
this.readyTimeoutMs = opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
this.readyPollIntervalMs = opts.readyPollIntervalMs ?? DEFAULT_READY_POLL_INTERVAL_MS;
this.logger = opts.logger ?? silentLogger;
}
/** The base URL the daemon listens on (`http://host:port`). */
getBaseUrl(): string {
return this.baseUrl;
}
/** The profile name this server operates on. */
getProfile(): string {
return this.profile;
}
/**
* Ensure the daemon is configured and running. Idempotent — the underlying
* `profile create --merge` and `daemon start` commands tolerate re-runs.
*/
async start(): Promise<void> {
this.logger.info(`[hindsight] starting daemon for profile "${this.profile}"`);
const env = this.buildEnv();
await this.configureProfile(env);
await this.startDaemon(env);
await this.waitForReady();
this.logger.info(`[hindsight] daemon ready at ${this.baseUrl}`);
}
/** Stop the daemon. Never throws — logs and resolves even on failure. */
async stop(): Promise<void> {
this.logger.info(`[hindsight] stopping daemon for profile "${this.profile}"`);
const [cmd, ...baseArgs] = getEmbedCommand({
embedVersion: this.embedVersion,
embedPackagePath: this.embedPackagePath,
});
const args = [...baseArgs, 'daemon', '--profile', this.profile, 'stop'];
const child = spawn(cmd, args, { stdio: 'pipe' });
this.pipeOutput(child, 'daemon.stop');
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
this.logger.warn(`[hindsight] daemon stop timed out after 5s`);
resolve();
}, 5_000);
child.on('exit', () => {
clearTimeout(timeout);
this.logger.info(`[hindsight] daemon stopped`);
resolve();
});
child.on('error', (err) => {
clearTimeout(timeout);
this.logger.warn(`[hindsight] error stopping daemon: ${err.message}`);
resolve();
});
});
}
/** Probe `/health` once with a short timeout. */
async checkHealth(): Promise<boolean> {
try {
const res = await fetch(`${this.baseUrl}/health`, {
signal: AbortSignal.timeout(2_000),
});
return res.ok;
} catch {
return false;
}
}
// -------------------------------------------------------------------------
// Internal
// -------------------------------------------------------------------------
/**
* Merge the process env, the caller-supplied `env`, and (on macOS) the
* embeddings CPU workaround. Caller-supplied values always win over the
* workaround; undefined values are dropped.
*/
private buildEnv(): NodeJS.ProcessEnv {
const merged: NodeJS.ProcessEnv = { ...process.env };
if (this.platformCpuWorkaround && process.platform === 'darwin') {
merged['HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU'] = '1';
merged['HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU'] = '1';
}
for (const [key, value] of Object.entries(this.userEnv)) {
if (value !== undefined) {
merged[key] = value;
}
}
return merged;
}
/**
* Run `profile create <name> --merge --port <port> [--env K=V ...]`.
* Every entry in the merged env that was passed via {@link userEnv} (or
* auto-applied by the CPU workaround) is forwarded as `--env`.
*/
private async configureProfile(env: NodeJS.ProcessEnv): Promise<void> {
this.logger.info(`[hindsight] configuring profile "${this.profile}"`);
const [cmd, ...baseArgs] = getEmbedCommand({
embedVersion: this.embedVersion,
embedPackagePath: this.embedPackagePath,
});
const createArgs = [
...baseArgs,
'profile',
'create',
this.profile,
'--merge',
'--port',
String(this.port),
];
// Forward every env var that the caller intended for the daemon as --env.
// We only forward keys the caller explicitly set (userEnv) plus the CPU
// workaround values — not the entire process.env, to avoid leaking random
// host state into profile config.
const envForProfile = this.collectProfileEnv(env);
for (const [key, value] of Object.entries(envForProfile)) {
createArgs.push('--env', `${key}=${value}`);
}
createArgs.push(...this.extraProfileCreateArgs);
await this.runCommand(cmd, createArgs, env, 'profile.create');
}
/** Collect only the env vars that should be written into the profile file. */
private collectProfileEnv(env: NodeJS.ProcessEnv): Record<string, string> {
const out: Record<string, string> = {};
// 1. User-supplied env — always forwarded.
for (const [key, value] of Object.entries(this.userEnv)) {
if (value !== undefined) {
out[key] = value;
}
}
// 2. CPU workaround — only if auto-applied and not already overridden.
if (this.platformCpuWorkaround && process.platform === 'darwin') {
const cpuKeys = [
'HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU',
'HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU',
];
for (const key of cpuKeys) {
if (!(key in out) && env[key] !== undefined) {
out[key] = env[key] as string;
}
}
}
return out;
}
private async startDaemon(env: NodeJS.ProcessEnv): Promise<void> {
const [cmd, ...baseArgs] = getEmbedCommand({
embedVersion: this.embedVersion,
embedPackagePath: this.embedPackagePath,
});
const args = [
...baseArgs,
'daemon',
'--profile',
this.profile,
'start',
...this.extraDaemonStartArgs,
];
await this.runCommand(cmd, args, env, 'daemon.start');
}
/**
* Spawn `cmd` with `args`, pipe its output through the logger, and resolve
* once it exits with code 0. Rejects on non-zero exit or spawn error.
*/
private async runCommand(
cmd: string,
args: string[],
env: NodeJS.ProcessEnv,
label: string,
): Promise<void> {
const child = spawn(cmd, args, { stdio: 'pipe', env });
let output = '';
child.stdout?.on('data', (data: Buffer) => {
const text = data.toString();
output += text;
for (const line of text.trimEnd().split('\n')) {
if (line) this.logger.info(`[hindsight:${label}] ${line}`);
}
});
child.stderr?.on('data', (data: Buffer) => {
const text = data.toString();
output += text;
for (const line of text.trimEnd().split('\n')) {
if (line) this.logger.warn(`[hindsight:${label}] ${line}`);
}
});
await new Promise<void>((resolve, reject) => {
child.on('exit', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`${label} failed with code ${code}: ${output.trim()}`));
}
});
child.on('error', (err) => {
reject(new Error(`${label} failed to spawn: ${err.message}`, { cause: err }));
});
});
}
/** Stream a spawned child's stdout/stderr through the logger without blocking. */
private pipeOutput(child: ReturnType<typeof spawn>, label: string): void {
child.stdout?.on('data', (data: Buffer) => {
for (const line of data.toString().trimEnd().split('\n')) {
if (line) this.logger.info(`[hindsight:${label}] ${line}`);
}
});
child.stderr?.on('data', (data: Buffer) => {
for (const line of data.toString().trimEnd().split('\n')) {
if (line) this.logger.warn(`[hindsight:${label}] ${line}`);
}
});
}
/** Poll `/health` until it succeeds or `readyTimeoutMs` elapses. */
private async waitForReady(): Promise<void> {
const deadline = Date.now() + this.readyTimeoutMs;
let attempt = 0;
while (Date.now() < deadline) {
attempt++;
try {
const res = await fetch(`${this.baseUrl}/health`, {
signal: AbortSignal.timeout(this.readyPollIntervalMs),
});
if (res.ok) {
this.logger.debug(`[hindsight] health check passed (attempt ${attempt})`);
return;
}
} catch {
// expected while the daemon is still booting
}
await new Promise((resolve) => setTimeout(resolve, this.readyPollIntervalMs));
}
throw new Error(
`Hindsight daemon did not become ready within ${this.readyTimeoutMs}ms at ${this.baseUrl}`,
);
}
}
+54
View File
@@ -0,0 +1,54 @@
import type { Logger } from './logger.js';
/**
* Options for {@link HindsightServer}.
*
* The server is intentionally thin and pass-through: anything configurable
* on the daemon side (env vars or CLI flags) can be set here without needing
* a new dedicated option. Use {@link env} for `HINDSIGHT_*` / `OPENAI_API_KEY` /
* custom provider settings, and the two `extra*` arrays to append raw CLI
* args to `profile create` or `daemon start`.
*
* For talking to the daemon after `start()`, use `@vectorize-io/hindsight-client`
* against `server.getBaseUrl()`. This package does not ship its own HTTP
* client.
*/
export interface HindsightServerOptions {
/** Profile name used for `--profile <name>` on every sub-command. Default: `"default"`. */
profile?: string;
/** TCP port the daemon listens on. Default: `8888`. */
port?: number;
/** Hostname the daemon binds to (for health checks). Default: `127.0.0.1`. */
host?: string;
/** Version of the underlying `hindsight-embed` PyPI package to run via `uvx`. Default: `"latest"`. */
embedVersion?: string;
/** Local path to a `hindsight-embed` checkout — takes precedence over `embedVersion`. */
embedPackagePath?: string;
/**
* Environment variables passed to the daemon process AND written into the
* profile via repeated `--env KEY=VALUE` flags. This is the preferred way
* to surface any `HINDSIGHT_API_*` / `HINDSIGHT_EMBED_*` setting — adding a
* new daemon env var never requires a wrapper update.
*
* Values of `undefined` are dropped (so you can spread conditionally).
*/
env?: Record<string, string | undefined>;
/** Extra args appended verbatim to `hindsight-embed profile create <name> --merge ...`. */
extraProfileCreateArgs?: string[];
/** Extra args appended verbatim to `hindsight-embed daemon --profile <name> start ...`. */
extraDaemonStartArgs?: string[];
/**
* On macOS, automatically set
* `HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU=1` and
* `HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=1` to avoid Metal/MPS crashes in
* daemon mode. Default: `true` on `darwin`, ignored elsewhere. Any value set
* explicitly in {@link env} wins over the auto-applied value.
*/
platformCpuWorkaround?: boolean;
/** Max time (ms) to wait for `/health` to return 200. Default: `30_000`. */
readyTimeoutMs?: number;
/** Polling interval (ms) while waiting for `/health`. Default: `1_000`. */
readyPollIntervalMs?: number;
/** Optional pluggable logger. Default: silent. */
logger?: Logger;
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "node",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
outDir: 'dist',
clean: true,
sourcemap: true,
bundle: true,
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
environment: 'node',
},
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-all-slim"
version = "0.4.17"
version = "0.5.1"
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
+223 -43
View File
@@ -13,7 +13,10 @@ from hindsight_client import Hindsight
class BanksAPI:
"""Namespace for bank-related operations."""
"""Namespace for bank-related operations.
Provides methods to create, delete, and manage memory banks.
"""
def __init__(self, client: Hindsight):
self._client = client
@@ -24,8 +27,18 @@ class BanksAPI:
name: str | None = None,
mission: str | None = None,
disposition: dict[str, Any] | None = None,
):
"""Create a new bank."""
) -> Any:
"""Create a new bank.
Args:
bank_id: Unique identifier for the bank.
name: Optional display name for the bank.
mission: Optional mission statement for the bank.
disposition: Optional disposition configuration dict.
Returns:
Bank creation response from the API.
"""
return self._client.create_bank(
bank_id=bank_id,
name=name,
@@ -33,27 +46,57 @@ class BanksAPI:
disposition=disposition,
)
def delete(self, bank_id: str):
"""Delete a bank."""
def delete(self, bank_id: str) -> Any:
"""Delete a bank.
Args:
bank_id: The ID of the bank to delete.
Returns:
Deletion response from the API.
"""
return self._client.delete_bank(bank_id=bank_id)
def set_mission(self, bank_id: str, mission: str):
"""Set or update the mission for a bank."""
def set_mission(self, bank_id: str, mission: str) -> Any:
"""Set or update the mission for a bank.
Args:
bank_id: The ID of the bank.
mission: The mission statement to set.
Returns:
API response confirming the update.
"""
return self._client.set_mission(bank_id=bank_id, mission=mission)
def set_disposition(self, bank_id: str, disposition: dict[str, Any]):
"""Set or update the disposition for a bank."""
def set_disposition(self, bank_id: str, disposition: dict[str, Any]) -> Any:
"""Set or update the disposition for a bank.
Args:
bank_id: The ID of the bank.
disposition: The disposition configuration dict.
Returns:
API response confirming the update.
"""
return self._client.set_disposition(bank_id=bank_id, disposition=disposition)
def list(self):
"""List all banks."""
def list(self) -> Any:
"""List all banks.
Returns:
List of banks from the API.
"""
from hindsight_client.hindsight_client import _run_async
return _run_async(self._client._banks_api.list_banks())
class MentalModelsAPI:
"""Namespace for mental model operations."""
"""Namespace for mental model operations.
Mental models are reusable knowledge structures that guide agent behavior.
"""
def __init__(self, client: Hindsight):
self._client = client
@@ -64,8 +107,18 @@ class MentalModelsAPI:
name: str,
content: str,
tags: list[str] | None = None,
):
"""Create a new mental model."""
) -> Any:
"""Create a new mental model.
Args:
bank_id: The ID of the bank to add the model to.
name: Name for the mental model.
content: The content/instructions for the mental model.
tags: Optional list of tags for categorization.
Returns:
Creation response from the API.
"""
return self._client.create_mental_model(
bank_id=bank_id,
name=name,
@@ -73,16 +126,40 @@ class MentalModelsAPI:
tags=tags,
)
def list(self, bank_id: str, tags: list[str] | None = None):
"""List all mental models for a bank."""
def list(self, bank_id: str, tags: list[str] | None = None) -> Any:
"""List all mental models for a bank.
Args:
bank_id: The ID of the bank.
tags: Optional filter by tags.
Returns:
List of mental models.
"""
return self._client.list_mental_models(bank_id=bank_id, tags=tags)
def get(self, bank_id: str, mental_model_id: str):
"""Get a specific mental model."""
def get(self, bank_id: str, mental_model_id: str) -> Any:
"""Get a specific mental model.
Args:
bank_id: The ID of the bank.
mental_model_id: The ID of the mental model.
Returns:
The mental model details.
"""
return self._client.get_mental_model(bank_id=bank_id, mental_model_id=mental_model_id)
def refresh(self, bank_id: str, mental_model_id: str):
"""Refresh a mental model."""
def refresh(self, bank_id: str, mental_model_id: str) -> Any:
"""Refresh a mental model.
Args:
bank_id: The ID of the bank.
mental_model_id: The ID of the mental model to refresh.
Returns:
Refresh response from the API.
"""
return self._client.refresh_mental_model(bank_id=bank_id, mental_model_id=mental_model_id)
def update(
@@ -92,8 +169,19 @@ class MentalModelsAPI:
name: str | None = None,
content: str | None = None,
tags: list[str] | None = None,
):
"""Update a mental model."""
) -> Any:
"""Update a mental model.
Args:
bank_id: The ID of the bank.
mental_model_id: The ID of the mental model to update.
name: Optional new name.
content: Optional new content.
tags: Optional new tags list.
Returns:
Update response from the API.
"""
return self._client.update_mental_model(
bank_id=bank_id,
mental_model_id=mental_model_id,
@@ -102,13 +190,24 @@ class MentalModelsAPI:
tags=tags,
)
def delete(self, bank_id: str, mental_model_id: str):
"""Delete a mental model."""
def delete(self, bank_id: str, mental_model_id: str) -> Any:
"""Delete a mental model.
Args:
bank_id: The ID of the bank.
mental_model_id: The ID of the mental model to delete.
Returns:
Deletion response from the API.
"""
return self._client.delete_mental_model(bank_id=bank_id, mental_model_id=mental_model_id)
class DirectivesAPI:
"""Namespace for directive operations."""
"""Namespace for directive operations.
Directives are explicit instructions that guide agent behavior.
"""
def __init__(self, client: Hindsight):
self._client = client
@@ -119,8 +218,18 @@ class DirectivesAPI:
name: str,
content: str,
tags: list[str] | None = None,
):
"""Create a new directive."""
) -> Any:
"""Create a new directive.
Args:
bank_id: The ID of the bank to add the directive to.
name: Name for the directive.
content: The directive content/instructions.
tags: Optional list of tags for categorization.
Returns:
Creation response from the API.
"""
return self._client.create_directive(
bank_id=bank_id,
name=name,
@@ -128,12 +237,28 @@ class DirectivesAPI:
tags=tags,
)
def list(self, bank_id: str, tags: list[str] | None = None):
"""List all directives for a bank."""
def list(self, bank_id: str, tags: list[str] | None = None) -> Any:
"""List all directives for a bank.
Args:
bank_id: The ID of the bank.
tags: Optional filter by tags.
Returns:
List of directives.
"""
return self._client.list_directives(bank_id=bank_id, tags=tags)
def get(self, bank_id: str, directive_id: str):
"""Get a specific directive."""
def get(self, bank_id: str, directive_id: str) -> Any:
"""Get a specific directive.
Args:
bank_id: The ID of the bank.
directive_id: The ID of the directive.
Returns:
The directive details.
"""
return self._client.get_directive(bank_id=bank_id, directive_id=directive_id)
def update(
@@ -143,8 +268,19 @@ class DirectivesAPI:
name: str | None = None,
content: str | None = None,
tags: list[str] | None = None,
):
"""Update a directive."""
) -> Any:
"""Update a directive.
Args:
bank_id: The ID of the bank.
directive_id: The ID of the directive to update.
name: Optional new name.
content: Optional new content.
tags: Optional new tags list.
Returns:
Update response from the API.
"""
return self._client.update_directive(
bank_id=bank_id,
directive_id=directive_id,
@@ -153,13 +289,24 @@ class DirectivesAPI:
tags=tags,
)
def delete(self, bank_id: str, directive_id: str):
"""Delete a directive."""
def delete(self, bank_id: str, directive_id: str) -> Any:
"""Delete a directive.
Args:
bank_id: The ID of the bank.
directive_id: The ID of the directive to delete.
Returns:
Deletion response from the API.
"""
return self._client.delete_directive(bank_id=bank_id, directive_id=directive_id)
class MemoriesAPI:
"""Namespace for memory operations."""
"""Namespace for memory operations.
Provides methods to query and retrieve stored memories.
"""
def __init__(self, client: Hindsight):
self._client = client
@@ -171,8 +318,19 @@ class MemoriesAPI:
search_query: str | None = None,
limit: int = 100,
offset: int = 0,
):
"""List memories in a bank."""
) -> Any:
"""List memories in a bank.
Args:
bank_id: The ID of the bank to query.
type: Optional filter by memory type.
search_query: Optional search query for filtering.
limit: Maximum number of results to return (default: 100).
offset: Number of results to skip for pagination (default: 0).
Returns:
List of memories matching the criteria.
"""
return self._client.list_memories(
bank_id=bank_id,
type=type,
@@ -205,9 +363,15 @@ class HindsightClient(Hindsight):
directives = client.directives.list(bank_id="test")
memories = client.memories.list(bank_id="test")
```
Attributes:
banks: Namespace for bank management operations.
mental_models: Namespace for mental model operations.
directives: Namespace for directive operations.
memories: Namespace for memory listing operations.
"""
def __init__(self, *args, **kwargs):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._banks_namespace: BanksAPI | None = None
self._mental_models_namespace: MentalModelsAPI | None = None
@@ -216,28 +380,44 @@ class HindsightClient(Hindsight):
@property
def banks(self) -> BanksAPI:
"""Access bank management operations."""
"""Access bank management operations.
Returns:
BanksAPI instance for bank operations.
"""
if self._banks_namespace is None:
self._banks_namespace = BanksAPI(self)
return self._banks_namespace
@property
def mental_models(self) -> MentalModelsAPI:
"""Access mental model operations."""
"""Access mental model operations.
Returns:
MentalModelsAPI instance for mental model operations.
"""
if self._mental_models_namespace is None:
self._mental_models_namespace = MentalModelsAPI(self)
return self._mental_models_namespace
@property
def directives(self) -> DirectivesAPI:
"""Access directive operations."""
"""Access directive operations.
Returns:
DirectivesAPI instance for directive operations.
"""
if self._directives_namespace is None:
self._directives_namespace = DirectivesAPI(self)
return self._directives_namespace
@property
def memories(self) -> MemoriesAPI:
"""Access memory listing operations."""
"""Access memory listing operations.
Returns:
MemoriesAPI instance for memory operations.
"""
if self._memories_namespace is None:
self._memories_namespace = MemoriesAPI(self)
return self._memories_namespace
+62 -5
View File
@@ -34,7 +34,6 @@ Using context manager:
"""
import logging
import os
import threading
from typing import Optional
@@ -74,6 +73,9 @@ class HindsightEmbedded:
database_url: Optional database URL override (default: profile-specific pg0)
idle_timeout: Seconds before daemon auto-exits when idle (default: 300)
log_level: Daemon log level (default: "info")
ui: Whether to start the control plane web UI alongside the daemon (default: False)
ui_port: Port for the UI. Defaults to daemon_port + 10000.
ui_hostname: Hostname to bind the UI to. Defaults to "0.0.0.0".
"""
def __init__(
@@ -86,6 +88,9 @@ class HindsightEmbedded:
database_url: Optional[str] = None,
idle_timeout: int = 300,
log_level: str = "info",
ui: bool = False,
ui_port: Optional[int] = None,
ui_hostname: str = "0.0.0.0",
):
"""
Initialize the embedded client (daemon starts on first use).
@@ -99,6 +104,9 @@ class HindsightEmbedded:
database_url: Optional database URL override
idle_timeout: Seconds before daemon auto-exits when idle
log_level: Daemon log level
ui: Whether to start the control plane web UI alongside the daemon
ui_port: Port for the UI (defaults to daemon_port + 10000)
ui_hostname: Hostname to bind the UI to (defaults to "0.0.0.0")
"""
self.profile = profile
@@ -117,6 +125,10 @@ class HindsightEmbedded:
if database_url:
self.config["HINDSIGHT_EMBED_API_DATABASE_URL"] = database_url
self._ui = ui
self._ui_port = ui_port
self._ui_hostname = ui_hostname
self._client: Optional[Hindsight] = None
self._lock = threading.Lock()
self._started = False
@@ -140,13 +152,17 @@ class HindsightEmbedded:
return
if self._closed:
raise RuntimeError("Cannot use HindsightEmbedded after it has been closed")
raise RuntimeError(
"Cannot use HindsightEmbedded after it has been closed"
)
# Use embed manager interface for daemon management
logger.info(f"Ensuring daemon is running for profile '{self.profile}'...")
success = self._manager.ensure_running(self.config, self.profile)
if not success:
raise RuntimeError(f"Failed to start daemon for profile '{self.profile}'")
raise RuntimeError(
f"Failed to start daemon for profile '{self.profile}'"
)
# Get daemon URL and create client
daemon_url = self._manager.get_url(self.profile)
@@ -154,6 +170,15 @@ class HindsightEmbedded:
self._started = True
logger.info(f"Connected to daemon at {daemon_url}")
# Start UI if requested
if self._ui:
logger.info(f"Starting UI for profile '{self.profile}'...")
ui_started = self._manager.start_ui(
self.profile, self._ui_port, self._ui_hostname
)
if not ui_started:
logger.warning(f"Failed to start UI for profile '{self.profile}'")
def _cleanup(self, stop_daemon_on_close: bool = False):
"""
Cleanup client resources (idempotent).
@@ -165,20 +190,47 @@ class HindsightEmbedded:
if self._closed:
return
with self._lock:
acquired = self._lock.acquire(timeout=5.0)
if not acquired:
# Lock is held by another thread (e.g. _ensure_started).
# Mark closed to prevent new operations but skip shared-state
# teardown — the daemon's idle timeout handles the rest.
logger.warning(
"Cleanup lock acquisition timed out for profile '%s'; "
"marking closed, daemon will idle-stop on its own",
self.profile,
)
self._closed = True
return
try:
if self._closed:
return
if self._client is not None:
self._client.close()
try:
self._client.close()
except Exception:
logger.debug(
"Error closing client for profile '%s'",
self.profile,
exc_info=True,
)
self._client = None
# Stop UI if it was started
if self._ui and self._started:
logger.info(f"Stopping UI for profile '{self.profile}'...")
self._manager.stop_ui(self.profile, self._ui_port)
# Optionally stop daemon (daemon has idle timeout, so not required)
if stop_daemon_on_close and self._started:
logger.info(f"Stopping daemon for profile '{self.profile}'...")
self._manager.stop(self.profile)
self._closed = True
finally:
self._lock.release()
def close(self, stop_daemon: bool = False):
"""
@@ -375,3 +427,8 @@ class HindsightEmbedded:
def is_running(self) -> bool:
"""Check if the client is initialized."""
return self._started and not self._closed and self._client is not None
@property
def ui_url(self) -> str:
"""Get the UI URL for this profile."""
return self._manager.get_ui_url(self.profile)
View File
+4 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.4.17"
version = "0.5.1"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
@@ -20,6 +20,9 @@ hindsight-client = { workspace = true }
hindsight-embed = { workspace = true }
[project.optional-dependencies]
local-llm = [
"hindsight-api-slim[local-llm]>=0.4.17",
]
test = [
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
@@ -0,0 +1,56 @@
"""
Unit test for _cleanup lock timeout behavior.
Verifies that _cleanup completes even when the lock is held by another thread,
instead of hanging indefinitely (fixes #952).
"""
import threading
import time
from unittest.mock import MagicMock, patch
import pytest
def test_cleanup_completes_when_lock_held():
"""
_cleanup should complete (best-effort) even when self._lock is held
by another thread, e.g. during a long _ensure_started call.
"""
with patch.dict("sys.modules", {
"hindsight_client": MagicMock(),
"hindsight_embed": MagicMock(),
"hindsight.api_namespaces": MagicMock(),
}):
from hindsight.embedded import HindsightEmbedded
client = HindsightEmbedded.__new__(HindsightEmbedded)
client.profile = "test"
client._lock = threading.Lock()
client._closed = False
client._client = None
client._started = False
client._ui = False
# Simulate another thread holding the lock
client._lock.acquire()
cleanup_done = threading.Event()
def run_cleanup():
client._cleanup()
cleanup_done.set()
t = threading.Thread(target=run_cleanup)
t.start()
# Cleanup should complete within the timeout (5s) + margin
assert cleanup_done.wait(timeout=8.0), (
"_cleanup hung instead of timing out on lock acquisition"
)
# Release the lock from the simulating thread
client._lock.release()
t.join(timeout=1.0)
assert client._closed, "Client should be marked as closed after cleanup"
+82 -17
View File
@@ -15,6 +15,8 @@ import os
import uuid
import pytest
import urllib.request
import json
from hindsight import HindsightEmbedded
@@ -23,12 +25,20 @@ from hindsight import HindsightEmbedded
def llm_config():
"""Get LLM configuration from environment (session-scoped)."""
# Try both naming conventions
provider = os.getenv("HINDSIGHT_API_LLM_PROVIDER") or os.getenv("HINDSIGHT_LLM_PROVIDER", "groq")
api_key = os.getenv("HINDSIGHT_API_LLM_API_KEY") or os.getenv("HINDSIGHT_LLM_API_KEY", "")
model = os.getenv("HINDSIGHT_API_LLM_MODEL") or os.getenv("HINDSIGHT_LLM_MODEL", "openai/gpt-oss-120b")
provider = os.getenv("HINDSIGHT_API_LLM_PROVIDER") or os.getenv(
"HINDSIGHT_LLM_PROVIDER", "groq"
)
api_key = os.getenv("HINDSIGHT_API_LLM_API_KEY") or os.getenv(
"HINDSIGHT_LLM_API_KEY", ""
)
model = os.getenv("HINDSIGHT_API_LLM_MODEL") or os.getenv(
"HINDSIGHT_LLM_MODEL", "openai/gpt-oss-120b"
)
if not api_key:
pytest.skip("LLM API key not configured. Set HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_LLM_API_KEY.")
pytest.skip(
"LLM API key not configured. Set HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_LLM_API_KEY."
)
return {
"llm_provider": provider,
@@ -78,7 +88,9 @@ def test_embedded_context_manager(llm_config):
# Recall memory
recall_results = client.recall(bank_id=bank_id, query="context")
assert isinstance(recall_results.results, list), "Recall should return results list"
assert isinstance(recall_results.results, list), (
"Recall should return results list"
)
# Server should be stopped after context exit
# Note: We can't check client.is_running here as client is out of scope
@@ -105,7 +117,9 @@ def test_embedded_complete_workflow(llm_config):
# Step 1: Create a memory bank
print(f"\n1. Creating memory bank: {bank_id}")
bank_response = client.create_bank(
bank_id=bank_id, name="Test Assistant", mission="Help with programming tasks"
bank_id=bank_id,
name="Test Assistant",
mission="Help with programming tasks",
)
assert bank_response.bank_id == bank_id
@@ -126,7 +140,9 @@ def test_embedded_complete_workflow(llm_config):
items=[
{"content": "User works with pandas and numpy."},
{"content": "User likes matplotlib for visualization."},
{"content": "User is interested in machine learning with scikit-learn."},
{
"content": "User is interested in machine learning with scikit-learn."
},
],
)
assert batch_response.success
@@ -134,7 +150,9 @@ def test_embedded_complete_workflow(llm_config):
# Step 4: Recall memories
print("\n4. Recalling memories...")
recall_response = client.recall(bank_id=bank_id, query="What tools does the user prefer?", max_tokens=2000)
recall_response = client.recall(
bank_id=bank_id, query="What tools does the user prefer?", max_tokens=2000
)
assert isinstance(recall_response.results, list)
assert len(recall_response.results) > 0
print(f" Found {len(recall_response.results)} relevant memories")
@@ -152,7 +170,9 @@ def test_embedded_complete_workflow(llm_config):
# Verify answer mentions relevant tools
answer_lower = reflect_response.text.lower()
assert any(term in answer_lower for term in ["python", "pandas", "numpy", "data"])
assert any(
term in answer_lower for term in ["python", "pandas", "numpy", "data"]
)
# Step 6: List memories
print("\n6. Listing memories...")
@@ -215,7 +235,9 @@ def test_embedded_method_proxying(llm_config):
assert bank.bank_id == bank_id
# Test mission setting
mission_response = client.set_mission(bank_id=bank_id, mission="Test mission for proxying")
mission_response = client.set_mission(
bank_id=bank_id, mission="Test mission for proxying"
)
assert mission_response.bank_id == bank_id
# Test retain
@@ -264,7 +286,9 @@ def test_embedded_multiple_banks(llm_config):
# Create second bank and store data
client.create_bank(bank_id=bank2_id, name="Bank 2")
client.retain(bank_id=bank2_id, content="Bob uses JavaScript for web development")
client.retain(
bank_id=bank2_id, content="Bob uses JavaScript for web development"
)
# Recall from both banks
results1 = client.recall(bank_id=bank1_id, query="programming language")
@@ -275,9 +299,9 @@ def test_embedded_multiple_banks(llm_config):
# Verify banks are isolated (each should only see their own content)
# This is a basic check - content isolation is tested more thoroughly in other tests
assert results1.results[0].text != results2.results[0].text or len(results1.results) != len(
results2.results
)
assert results1.results[0].text != results2.results[0].text or len(
results1.results
) != len(results2.results)
finally:
client.close()
@@ -296,10 +320,14 @@ def test_embedded_profile_isolation(llm_config):
try:
# Store data in profile1
client1.retain(bank_id=bank_id, content="User likes TypeScript for frontend development")
client1.retain(
bank_id=bank_id, content="User likes TypeScript for frontend development"
)
# Store different data in profile2
client2.retain(bank_id=bank_id, content="User prefers Rust for systems programming")
client2.retain(
bank_id=bank_id, content="User prefers Rust for systems programming"
)
# Each profile should only see its own data
results1 = client1.recall(bank_id=bank_id, query="programming preference")
@@ -334,5 +362,42 @@ def test_embedded_error_after_close(llm_config):
assert not client.is_running
# Trying to use it after close should raise an error
with pytest.raises(RuntimeError, match="Cannot use HindsightEmbedded after it has been closed"):
with pytest.raises(
RuntimeError, match="Cannot use HindsightEmbedded after it has been closed"
):
client.retain(bank_id=bank_id, content="This should fail")
def test_embedded_ui_flag(llm_config):
"""
Test that ui=True starts the control plane UI alongside the daemon,
and that the UI's health endpoint reports a connected dataplane.
"""
profile = f"test_ui_{uuid.uuid4().hex[:8]}"
bank_id = f"bank_{uuid.uuid4().hex[:8]}"
client = HindsightEmbedded(profile=profile, log_level="info", ui=True, **llm_config)
try:
# First use triggers daemon + UI startup
result = client.retain(bank_id=bank_id, content="UI integration test content")
assert result.success, "Retain should succeed"
assert client.is_running, "Daemon should be running"
# Verify UI is reachable and reports connected dataplane
ui_url = client.ui_url
assert ui_url, "ui_url should be set"
health_url = f"{ui_url}/api/health"
with urllib.request.urlopen(health_url, timeout=10) as resp:
health = json.loads(resp.read().decode())
assert health["status"] == "ok", (
f"UI health status should be 'ok', got: {health['status']}"
)
assert health["dataplane"]["status"] == "connected", (
f"Dataplane should be connected, got: {health['dataplane']}"
)
finally:
client.close()
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.4.17"
__version__ = "0.5.1"
@@ -249,7 +249,7 @@ async def _run_migration(
schemas = list(dict.fromkeys(schemas))
for schema in schemas:
run_migrations(resolved_url, schema=schema)
run_migrations(resolved_url, schema=schema, migration_database_url=config.migration_database_url)
if embedding_dimension is not None:
for schema in schemas:
@@ -0,0 +1,45 @@
"""Recreate entities trigram index on LOWER(canonical_name) for case-insensitive matching
The previous GIN trigram index on canonical_name was case-sensitive, causing
"Alice" and "alice" to have different trigram sets. This recreates it on
LOWER(canonical_name) so the % operator matches case-insensitively.
Revision ID: d6e7f8a9b0c1
Revises: c5d6e7f8a9b0
Create Date: 2026-03-31
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "d6e7f8a9b0c1"
down_revision: str | Sequence[str] | None = "c5d6e7f8a9b0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
# Drop the old case-sensitive trigram index
op.execute("DROP INDEX IF EXISTS entities_canonical_name_trgm_idx")
# Create case-insensitive trigram index on LOWER(canonical_name)
op.execute(
f"CREATE INDEX IF NOT EXISTS entities_canonical_name_lower_trgm_idx "
f"ON {schema}entities USING GIN (LOWER(canonical_name) gin_trgm_ops)"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS entities_canonical_name_lower_trgm_idx")
schema = _get_schema_prefix()
# Restore original case-sensitive index
op.execute(
f"CREATE INDEX IF NOT EXISTS entities_canonical_name_trgm_idx "
f"ON {schema}entities USING GIN (canonical_name gin_trgm_ops)"
)
@@ -0,0 +1,52 @@
"""Add consolidation_failed_at column to memory_units for tracking persistent LLM failures.
When all LLM retries are exhausted on a single-memory batch, the memory is marked
with consolidation_failed_at instead of consolidated_at, so it is not silently lost
and can be retried later via the API.
Revision ID: a3b4c5d6e7f8
Revises: g7h8i9j0k1l2
Create Date: 2026-03-17
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "a3b4c5d6e7f8"
down_revision: str | Sequence[str] | None = "g7h8i9j0k1l2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(
f"""
ALTER TABLE {schema}memory_units
ADD COLUMN IF NOT EXISTS consolidation_failed_at TIMESTAMPTZ DEFAULT NULL
"""
)
# Index to efficiently query memories that failed consolidation for a given bank
op.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_consolidation_failed
ON {schema}memory_units (bank_id, consolidation_failed_at)
WHERE consolidation_failed_at IS NOT NULL AND fact_type IN ('experience', 'world')
"""
)
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_consolidation_failed")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS consolidation_failed_at")
@@ -0,0 +1,142 @@
"""Fix per-bank vector indexes to match configured extension
Revision ID: a4b5c6d7e8f9
Revises: d6e7f8a9b0c1
Create Date: 2026-04-01
Migration d5e6f7a8b9c0 hardcoded HNSW when creating per-bank partial vector
indexes, ignoring HINDSIGHT_API_VECTOR_EXTENSION. Banks that existed when that
migration ran got HNSW indexes even when pgvectorscale (DiskANN) or vchord
was configured.
This migration detects the mismatch and recreates the affected indexes with
the correct type. Skipped entirely when the configured extension is pgvector
(the default), since those indexes are already correct.
"""
import os
from collections.abc import Sequence
from alembic import context, op
from sqlalchemy import text
revision: str = "a4b5c6d7e8f9"
down_revision: str | Sequence[str] | None = "d6e7f8a9b0c1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_FACT_TYPES: dict[str, str] = {
"world": "worl",
"experience": "expr",
"observation": "obsv",
}
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _target_index_type() -> str | None:
"""Return the target index type, or None if pgvector (no fix needed)."""
ext = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
if ext == "pgvectorscale":
return "diskann"
elif ext == "vchord":
return "vchordrq"
return None
def _vector_index_using_clause() -> str:
"""Return the USING clause based on the configured vector extension."""
ext = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
if ext == "pgvectorscale":
return "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)"
elif ext == "vchord":
return "USING vchordrq (embedding vector_l2_ops)"
else:
return "USING hnsw (embedding vector_cosine_ops)"
def upgrade() -> None:
target = _target_index_type()
if target is None:
# pgvector — indexes are already HNSW, nothing to fix
return
bind = op.get_bind()
schema_name = context.config.get_main_option("target_schema")
schema = _get_schema_prefix()
table_ref = f'"{schema_name}".memory_units' if schema_name else "memory_units"
banks_ref = f'"{schema_name}".banks' if schema_name else "banks"
using_clause = _vector_index_using_clause()
pg_schema = schema_name or "public"
rows = bind.execute(text(f"SELECT bank_id, internal_id FROM {banks_ref}")).fetchall() # noqa: S608
for row in rows:
bank_id = row[0]
internal_id = str(row[1]).replace("-", "")[:16]
escaped_bank_id = bank_id.replace("'", "''")
for ft, ft_short in _FACT_TYPES.items():
idx_name = f"idx_mu_emb_{ft_short}_{internal_id}"
# Check if this index exists and what type it is
idx_info = bind.execute(
text("SELECT indexdef FROM pg_indexes WHERE schemaname = :schema AND indexname = :idx"),
{"schema": pg_schema, "idx": idx_name},
).fetchone()
if idx_info is None:
# Index doesn't exist — create it with the correct type
bind.execute(
text(
f"CREATE INDEX IF NOT EXISTS {idx_name} "
f"ON {table_ref} {using_clause} "
f"WHERE fact_type = '{ft}' AND bank_id = '{escaped_bank_id}'"
)
)
continue
indexdef = idx_info[0].lower()
if target in indexdef:
# Already the correct type
continue
# Wrong type — drop and recreate
bind.execute(text(f"DROP INDEX IF EXISTS {schema}{idx_name}"))
bind.execute(
text(
f"CREATE INDEX IF NOT EXISTS {idx_name} "
f"ON {table_ref} {using_clause} "
f"WHERE fact_type = '{ft}' AND bank_id = '{escaped_bank_id}'"
)
)
def downgrade() -> None:
# Downgrade recreates indexes as HNSW (the original hardcoded behavior)
target = _target_index_type()
if target is None:
return
bind = op.get_bind()
schema_name = context.config.get_main_option("target_schema")
schema = _get_schema_prefix()
table_ref = f'"{schema_name}".memory_units' if schema_name else "memory_units"
banks_ref = f'"{schema_name}".banks' if schema_name else "banks"
rows = bind.execute(text(f"SELECT bank_id, internal_id FROM {banks_ref}")).fetchall() # noqa: S608
for row in rows:
bank_id = row[0]
internal_id = str(row[1]).replace("-", "")[:16]
escaped_bank_id = bank_id.replace("'", "''")
for ft, ft_short in _FACT_TYPES.items():
idx_name = f"idx_mu_emb_{ft_short}_{internal_id}"
bind.execute(text(f"DROP INDEX IF EXISTS {schema}{idx_name}"))
bind.execute(
text(
f"CREATE INDEX IF NOT EXISTS {idx_name} "
f"ON {table_ref} USING hnsw (embedding vector_cosine_ops) "
f"WHERE fact_type = '{ft}' AND bank_id = '{escaped_bank_id}'"
)
)
@@ -0,0 +1,32 @@
"""add content_hash to chunks table for delta retain
Revision ID: b3c4d5e6f7a8
Revises: a3b4c5d6e7f8
Create Date: 2026-03-25
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "b3c4d5e6f7a8"
down_revision: str | Sequence[str] | None = "a3b4c5d6e7f8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
# Add content_hash column to chunks table for delta comparison
op.execute(f"ALTER TABLE {schema}chunks ADD COLUMN IF NOT EXISTS content_hash TEXT")
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}chunks DROP COLUMN IF EXISTS content_hash")
@@ -11,6 +11,7 @@ block; see migrations.py for how this is handled safely.
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
revision: str = "c1a2b3d4e5f6"
@@ -25,9 +26,21 @@ def _get_schema_prefix() -> str:
def upgrade() -> None:
# pg_trgm ships with every standard PostgreSQL installation as a contrib module.
# pg_trgm ships with most PostgreSQL installations as a contrib module.
# It enables fast similarity lookups via GIN indexes, used for entity name matching.
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
# On managed services (e.g. Azure Flexible Server), the extension may not be
# available or may require manual enablement. We gracefully skip the index
# creation if the extension cannot be loaded — the entity resolver will
# auto-detect and fall back to the "full" lookup strategy at runtime. See #626.
conn = op.get_bind()
try:
conn.execute(sa.text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
except Exception:
# Extension not available (managed Postgres, insufficient privileges, etc.)
# Roll back the failed statement and skip index creation.
conn.execute(sa.text("ROLLBACK"))
conn.execute(sa.text("BEGIN"))
return
schema = _get_schema_prefix()
# GIN index on canonical_name enables sub-millisecond trigram similarity queries
@@ -0,0 +1,61 @@
"""Add audit_log table for feature usage tracking.
Merge migration that combines the two existing heads (a3b4c5d6e7f8 + c8e5f2a3b4d1).
Stores raw request/response as JSONB for expandability without future migrations.
The metadata JSONB column allows adding arbitrary fields in the future.
Revision ID: c2d3e4f5g6h7
Revises: a3b4c5d6e7f8, c8e5f2a3b4d1
Create Date: 2026-03-26
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "c2d3e4f5g6h7"
down_revision: str | Sequence[str] | None = ("a3b4c5d6e7f8", "c8e5f2a3b4d1")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
action TEXT NOT NULL,
transport TEXT NOT NULL,
bank_id TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
request JSONB,
response JSONB,
metadata JSONB DEFAULT '{{}}'::jsonb
)
"""
)
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_audit_log_action_started ON {schema}audit_log (action, started_at DESC)"
)
op.execute(f"CREATE INDEX IF NOT EXISTS idx_audit_log_bank_started ON {schema}audit_log (bank_id, started_at DESC)")
op.execute(f"CREATE INDEX IF NOT EXISTS idx_audit_log_started ON {schema}audit_log (started_at DESC)")
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_audit_log_started")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_audit_log_bank_started")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_audit_log_action_started")
op.execute(f"DROP TABLE IF EXISTS {schema}audit_log")
@@ -0,0 +1,48 @@
"""Add bank_id column to memory_links for direct filtering
The stats endpoint JOINs memory_links to memory_units just to filter by
bank_id. With millions of links this takes 18+ seconds. Adding bank_id
directly to memory_links lets Postgres push the filter down before the JOIN.
Revision ID: c5d6e7f8a9b0
Revises: b3c4d5e6f7a8
Create Date: 2026-03-26
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "c5d6e7f8a9b0"
down_revision: str | Sequence[str] | None = "b3c4d5e6f7a8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
# 1. Add nullable column
op.execute(f"ALTER TABLE {schema}memory_links ADD COLUMN IF NOT EXISTS bank_id TEXT")
# 2. Backfill from memory_units
op.execute(f"""
UPDATE {schema}memory_links ml
SET bank_id = mu.bank_id
FROM {schema}memory_units mu
WHERE ml.from_unit_id = mu.id
AND ml.bank_id IS NULL
""")
# 3. Set NOT NULL
op.execute(f"ALTER TABLE {schema}memory_links ALTER COLUMN bank_id SET NOT NULL")
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}memory_links DROP COLUMN IF EXISTS bank_id")
@@ -1,4 +1,4 @@
"""Add internal_id to banks and per-(bank, fact_type) partial HNSW indexes
"""Add internal_id to banks and per-(bank, fact_type) partial vector indexes
Revision ID: d5e6f7a8b9c0
Revises: a3b4c5d6e7f8
@@ -6,25 +6,20 @@ Create Date: 2026-03-11
This migration:
1. Adds internal_id UUID column to banks (stable identifier for index naming)
2. Drops the global HNSW index (competes with per-bank partial indexes)
3. Creates per-(bank_id, fact_type) partial HNSW indexes for all existing banks
(new banks get indexes created at bank-creation time via bank_utils.create_bank_hnsw_indexes)
2. Drops the global vector index (competes with per-bank partial indexes)
3. Creates per-(bank_id, fact_type) partial vector indexes for all existing banks
using the configured vector extension (HNSW for pgvector, DiskANN for
pgvectorscale, vchordrq for vchord).
(new banks get indexes created at bank-creation time via bank_utils.create_bank_vector_indexes)
Why per-(bank, fact_type) indexes:
- fact_type-only partial indexes are never chosen by the planner when bank_id is in the WHERE
clause, because the idx_memory_units_bank_id B-tree index always wins at planning time.
- Per-(bank, fact_type) partial indexes have both predicates matching → planner selects them.
- The global HNSW index competes for larger partitions (world, observation) and must be dropped.
For large deployments, create indexes CONCURRENTLY before running this migration:
SELECT internal_id, bank_id FROM banks;
-- for each bank and each fact_type in (world, experience, observation):
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_mu_emb_{ft}_{uid16}
ON memory_units USING hnsw (embedding vector_cosine_ops)
WHERE fact_type = '{ft}' AND bank_id = '{bank_id}';
DROP INDEX CONCURRENTLY IF EXISTS idx_memory_units_embedding;
- The global vector index competes for larger partitions (world, observation) and must be dropped.
"""
import os
from collections.abc import Sequence
from alembic import context, op
@@ -35,7 +30,7 @@ down_revision: str | Sequence[str] | None = "c3d4e5f6g7h8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_HNSW_FACT_TYPES: dict[str, str] = {
_FACT_TYPES: dict[str, str] = {
"world": "worl",
"experience": "expr",
"observation": "obsv",
@@ -47,6 +42,17 @@ def _get_schema_prefix() -> str:
return f'"{schema}".' if schema else ""
def _vector_index_using_clause() -> str:
"""Return the USING clause based on the configured vector extension."""
ext = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
if ext == "pgvectorscale":
return "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)"
elif ext == "vchord":
return "USING vchordrq (embedding vector_l2_ops)"
else:
return "USING hnsw (embedding vector_cosine_ops)"
def upgrade() -> None:
schema = _get_schema_prefix()
@@ -56,33 +62,35 @@ def upgrade() -> None:
)
op.execute(f"ALTER TABLE {schema}banks ADD CONSTRAINT banks_internal_id_unique UNIQUE (internal_id)")
# 2. Drop any fact_type-only partial HNSW indexes that may exist from prior migrations
# 2. Drop any fact_type-only partial indexes that may exist from prior migrations
# (bank_id B-tree always wins over them when bank_id is in the WHERE clause)
op.execute(f"DROP INDEX IF EXISTS {schema}idx_mu_emb_world")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_mu_emb_observation")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_mu_emb_experience")
# 4. Drop global HNSW index (competes with per-bank partial indexes)
# 4. Drop global vector index (competes with per-bank partial indexes)
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_embedding")
# 5. Create per-(bank, fact_type) partial HNSW indexes for all existing banks
# 5. Create per-(bank, fact_type) partial vector indexes for all existing banks
# using the configured extension (HNSW / DiskANN / vchordrq)
bind = op.get_bind()
schema_name = context.config.get_main_option("target_schema")
table_ref = f'"{schema_name}".memory_units' if schema_name else "memory_units"
banks_ref = f'"{schema_name}".banks' if schema_name else "banks"
using_clause = _vector_index_using_clause()
rows = bind.execute(text(f"SELECT bank_id, internal_id FROM {banks_ref}")).fetchall() # noqa: S608
for row in rows:
bank_id = row[0]
internal_id = str(row[1]).replace("-", "")[:16]
escaped_bank_id = bank_id.replace("'", "''")
for ft, ft_short in _HNSW_FACT_TYPES.items():
for ft, ft_short in _FACT_TYPES.items():
idx_name = f"idx_mu_emb_{ft_short}_{internal_id}"
# Index name is schema-unqualified (indexes live in the schema of their table)
bind.execute(
text(
f"CREATE INDEX IF NOT EXISTS {idx_name} "
f"ON {table_ref} USING hnsw (embedding vector_cosine_ops) "
f"ON {table_ref} {using_clause} "
f"WHERE fact_type = '{ft}' AND bank_id = '{escaped_bank_id}'"
)
)
@@ -5,7 +5,7 @@ Revises: e0a1b2c3d4e5
Create Date: 2025-01-12
Add composite index on memory_links (from_unit_id, link_type, weight DESC)
to optimize MPFP graph traversal queries that need top-k edges per type.
to optimize graph traversal queries that need top-k edges per type.
"""
from collections.abc import Sequence
@@ -26,7 +26,7 @@ def _get_schema_prefix() -> str:
def upgrade() -> None:
"""Add composite index for efficient MPFP edge loading."""
"""Add composite index for efficient graph retrieval edge loading."""
schema = _get_schema_prefix()
# Create composite index for efficient top-k per (from_node, link_type) queries
# This enables LATERAL joins to use index-only scans with early termination
@@ -0,0 +1,57 @@
"""chunk_fk_cascade_delete
Revision ID: f6g7h8i9j0k1
Revises: e5f6g7h8i9j0
Create Date: 2026-03-16 00:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "f6g7h8i9j0k1"
down_revision: str | Sequence[str] | None = "e5f6g7h8i9j0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Change memory_units.chunk_id FK from SET NULL to CASCADE.
When a document is deleted the CASCADE reaches chunks first; with SET NULL
the memory_units rows survived with chunk_id = NULL, leaving ghost records.
Switching to CASCADE ensures they are removed together with their chunk.
"""
from alembic import context
schema = context.config.get_main_option("target_schema")
schema_prefix = f'"{schema}".' if schema else ""
# Use raw SQL with IF EXISTS so this is safe on schemas where the FK was
# already dropped or never existed under this name.
op.execute(f"ALTER TABLE {schema_prefix}memory_units DROP CONSTRAINT IF EXISTS memory_units_chunk_fkey")
# Use a DO block so the ADD is also idempotent: if the FK already exists (e.g.
# the schema was provisioned after the base migration already added it) the
# duplicate_object exception is swallowed rather than failing the migration.
op.execute(
f"""
DO $$ BEGIN
ALTER TABLE {schema_prefix}memory_units
ADD CONSTRAINT memory_units_chunk_fkey
FOREIGN KEY (chunk_id)
REFERENCES {schema_prefix}chunks (chunk_id)
ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
"""
)
def downgrade() -> None:
"""Revert to SET NULL behaviour."""
op.drop_constraint("memory_units_chunk_fkey", "memory_units", type_="foreignkey")
op.create_foreign_key(
"memory_units_chunk_fkey", "memory_units", "chunks", ["chunk_id"], ["chunk_id"], ondelete="SET NULL"
)
@@ -0,0 +1,83 @@
"""remove_opinion_fact_type
Revision ID: g2h3i4j5k6l7
Revises: f1a2b3c4d5e6
Create Date: 2026-04-02
Remove the deprecated 'opinion' fact type: drop opinion-specific indexes,
update CHECK constraints, delete any remaining opinion rows, and drop the
confidence_score column (was only used for opinions, always NULL otherwise).
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "g2h3i4j5k6l7"
down_revision: str | Sequence[str] | None = "f1a2b3c4d5e6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
# 1. Delete any remaining opinion rows
op.execute(f"DELETE FROM {schema}memory_units WHERE fact_type = 'opinion'")
# 2. Drop opinion-specific indexes
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_opinion_confidence")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_opinion_date")
# 3. Drop confidence_score constraints and column (only used for opinions, always NULL otherwise)
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS confidence_score_fact_type_check")
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_confidence_score_check")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS confidence_score")
# 4. Replace fact_type CHECK constraint
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
op.execute(
f"ALTER TABLE {schema}memory_units ADD CONSTRAINT memory_units_fact_type_check "
f"CHECK (fact_type IN ('world', 'experience', 'observation'))"
)
def downgrade() -> None:
schema = _get_schema_prefix()
# Restore confidence_score column
op.execute(f"ALTER TABLE {schema}memory_units ADD COLUMN IF NOT EXISTS confidence_score float")
op.execute(
f"ALTER TABLE {schema}memory_units ADD CONSTRAINT memory_units_confidence_score_check "
f"CHECK (confidence_score IS NULL OR (confidence_score >= 0.0 AND confidence_score <= 1.0))"
)
op.execute(
f"ALTER TABLE {schema}memory_units ADD CONSTRAINT confidence_score_fact_type_check "
f"CHECK ((fact_type = 'opinion' AND confidence_score IS NOT NULL) OR "
f"(fact_type = 'observation') OR "
f"(fact_type NOT IN ('opinion', 'observation') AND confidence_score IS NULL))"
)
# Restore original fact_type CHECK constraint (with opinion)
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
op.execute(
f"ALTER TABLE {schema}memory_units ADD CONSTRAINT memory_units_fact_type_check "
f"CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation'))"
)
# Recreate opinion indexes
op.execute(
f"CREATE INDEX idx_memory_units_opinion_confidence ON {schema}memory_units "
f"(bank_id, confidence_score DESC) WHERE fact_type = 'opinion'"
)
op.execute(
f"CREATE INDEX idx_memory_units_opinion_date ON {schema}memory_units "
f"(bank_id, event_date DESC) WHERE fact_type = 'opinion'"
)
@@ -0,0 +1,71 @@
"""backsweep_orphan_memory_units
Two-pass cleanup of memory_units rows that were never removed by earlier bugs:
Pass 1 — any fact_type, bank gone:
memory_units whose bank_id no longer exists in banks. These accumulate when
a bank is deleted without a proper cascade (no FK from memory_units to banks
exists in the schema).
Pass 2 — observations only, all sources gone:
observation rows whose bank still exists but every source_memory_id points
to a deleted memory unit. These were left behind before PR #580 fixed the
chunk FK cascade and before delete_document() called
_delete_stale_observations_for_memories.
Revision ID: g7h8i9j0k1l2
Revises: f6g7h8i9j0k1
Create Date: 2026-03-16
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "g7h8i9j0k1l2"
down_revision: str | Sequence[str] | None = "f6g7h8i9j0k1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
mu = f"{schema}memory_units"
banks = f"{schema}banks"
# Pass 1: delete all memory_units (any fact_type) whose bank no longer exists.
# There is no FK from memory_units to banks, so these never cascade away.
op.execute(
f"""
DELETE FROM {mu}
WHERE NOT EXISTS (
SELECT 1 FROM {banks} b WHERE b.bank_id = {mu}.bank_id
)
"""
)
# Pass 2: delete orphaned observations whose bank still exists but every
# source_memory_id refers to a now-deleted memory unit (or the array is
# empty). Observations with at least one surviving source are left alone.
op.execute(
f"""
DELETE FROM {mu} orphan
WHERE orphan.fact_type = 'observation'
AND NOT EXISTS (
SELECT 1
FROM {mu} src
WHERE src.id = ANY(orphan.source_memory_ids)
AND src.bank_id = orphan.bank_id
)
"""
)
def downgrade() -> None:
# Deleted rows cannot be restored.
pass
@@ -0,0 +1,42 @@
"""Merge 3 migration heads and add unit_entities composite index
Revision ID: h3i4j5k6l7m8
Revises: a4b5c6d7e8f9, c2d3e4f5g6h7, g2h3i4j5k6l7
Create Date: 2026-04-07
Merges three unmerged migration heads into one, and adds a composite index
(entity_id, unit_id) on unit_entities for index-only scans in the LATERAL
entity expansion query.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "h3i4j5k6l7m8"
down_revision: str | Sequence[str] | None = ("a4b5c6d7e8f9", "c2d3e4f5g6h7", "g2h3i4j5k6l7")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
# Composite index enables index-only scans for entity_id -> unit_id lookups
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_unit_entities_entity_unit ON {schema}unit_entities (entity_id, unit_id)"
)
# Drop the now-redundant single-column index
op.execute(f"DROP INDEX IF EXISTS {schema}idx_unit_entities_entity")
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_unit_entities_entity_unit")
# Restore the single-column index
op.execute(f"CREATE INDEX IF NOT EXISTS idx_unit_entities_entity ON {schema}unit_entities (entity_id)")
File diff suppressed because it is too large Load Diff
+163 -47
View File
@@ -12,44 +12,9 @@ from hindsight_api.config import _get_raw_config
from hindsight_api.engine.memory_engine import _current_schema
from hindsight_api.extensions import MCPExtension, load_extension
from hindsight_api.extensions.tenant import AuthenticationError
from hindsight_api.mcp_tools import MCPToolsConfig, register_mcp_tools
from hindsight_api.mcp_tools import _ALL_TOOLS, MCPToolsConfig, register_mcp_tools
from hindsight_api.models import RequestContext
# All tools available in the system (explicit list — no wildcards)
_ALL_TOOLS: frozenset[str] = frozenset(
{
"retain",
"recall",
"reflect",
"list_banks",
"create_bank",
"list_mental_models",
"get_mental_model",
"create_mental_model",
"update_mental_model",
"delete_mental_model",
"refresh_mental_model",
"list_directives",
"create_directive",
"delete_directive",
"list_memories",
"get_memory",
"delete_memory",
"list_documents",
"get_document",
"delete_document",
"list_operations",
"get_operation",
"cancel_operation",
"list_tags",
"get_bank",
"get_bank_stats",
"update_bank",
"delete_bank",
"clear_memories",
}
)
# Configure logging from HINDSIGHT_API_LOG_LEVEL environment variable
_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "info").lower()
_log_level_map = {
@@ -83,6 +48,9 @@ _current_api_key: ContextVar[str | None] = ContextVar("current_api_key", default
_current_tenant_id: ContextVar[str | None] = ContextVar("current_tenant_id", default=None)
_current_api_key_id: ContextVar[str | None] = ContextVar("current_api_key_id", default=None)
# Context variable for MCP pre-authentication flag (set when MCP_AUTH_TOKEN validates)
_current_mcp_authenticated: ContextVar[bool] = ContextVar("current_mcp_authenticated", default=False)
def get_current_bank_id() -> str | None:
"""Get the current bank_id from context."""
@@ -104,6 +72,11 @@ def get_current_api_key_id() -> str | None:
return _current_api_key_id.get()
def get_current_mcp_authenticated() -> bool:
"""Get whether the request was pre-authenticated by MCP transport auth."""
return _current_mcp_authenticated.get()
def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
"""
Create and configure the Hindsight MCP server.
@@ -124,6 +97,7 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
_SINGLE_BANK_TOOLS: frozenset[str] = frozenset(
{
"retain",
"sync_retain",
"recall",
"reflect",
"list_mental_models",
@@ -164,6 +138,7 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
api_key_resolver=get_current_api_key, # Propagate API key for tenant auth
tenant_id_resolver=get_current_tenant_id, # Propagate tenant_id for usage metering
api_key_id_resolver=get_current_api_key_id, # Propagate api_key_id for usage metering
mcp_authenticated_resolver=get_current_mcp_authenticated, # Propagate MCP pre-auth flag
include_bank_id_param=multi_bank,
tools=base_tools,
)
@@ -182,24 +157,65 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
return mcp
def _get_mcp_tools(mcp: FastMCP) -> dict:
"""Get tool name→object mapping, compatible with FastMCP 2.x and 3.x."""
# FastMCP 2.x: _tool_manager._tools
if hasattr(mcp, "_tool_manager"):
return mcp._tool_manager._tools # type: ignore[union-attr]
# FastMCP 3.x: _local_provider._components with "tool:" prefix
if hasattr(mcp, "_local_provider"):
return {
k.split(":")[1].split("@")[0]: v
for k, v in mcp._local_provider._components.items() # type: ignore[union-attr]
if k.startswith("tool:")
}
msg = "Cannot locate tools on FastMCP instance"
raise AttributeError(msg)
def _make_tools_tolerant(mcp: FastMCP) -> None:
"""Wrap all tool run methods to strip unknown arguments before validation.
"""Wrap all tool run methods to strip unknown arguments and coerce string-encoded JSON.
LLMs frequently add extra fields like "explanation" or "reasoning" to tool calls.
FastMCP's Pydantic TypeAdapter rejects these with "Unexpected keyword argument".
This wraps each tool's run() to filter arguments to only known parameters.
LLMs also frequently serialize list/dict arguments as JSON strings instead of native
types (e.g., tags='["a","b"]' instead of tags=["a","b"]). This auto-coerces them.
This wraps each tool's run() to apply both fixes before validation.
"""
try:
for name, tool in mcp._tool_manager._tools.items():
tools = _get_mcp_tools(mcp)
for name, tool in tools.items():
if hasattr(tool, "parameters") and tool.parameters:
allowed = set(tool.parameters.get("properties", {}).keys())
properties = tool.parameters.get("properties", {})
allowed = set(properties.keys())
# Build sets of parameter names that expect array or object types.
# Handles both direct types {"type": "array"} and anyOf/oneOf unions
# like {"anyOf": [{"type": "array", ...}, {"type": "null"}]}.
array_params: set[str] = set()
object_params: set[str] = set()
for param_name, param_schema in properties.items():
_collect_coercible_types(param_schema, param_name, array_params, object_params)
original_run = tool.run
async def _tolerant_run(arguments, _allowed=allowed, _orig=original_run):
async def _tolerant_run(
arguments,
_allowed=allowed,
_orig=original_run,
_array_params=array_params,
_object_params=object_params,
):
extra_keys = set(arguments.keys()) - _allowed
if extra_keys:
logger.debug(f"Stripping unknown arguments from tool call: {extra_keys}")
arguments = {k: v for k, v in arguments.items() if k in _allowed}
# Coerce string-encoded JSON for list/dict parameters
arguments = _coerce_string_json(arguments, _array_params, _object_params)
return await _orig(arguments)
# FunctionTool is a Pydantic model with extra='forbid', so use
@@ -209,6 +225,59 @@ def _make_tools_tolerant(mcp: FastMCP) -> None:
logger.warning(f"Could not make tools tolerant of extra arguments: {e}")
def _collect_coercible_types(schema: dict, param_name: str, array_params: set[str], object_params: set[str]) -> None:
"""Check a JSON Schema property and add param_name to array_params/object_params if applicable."""
# Direct type
schema_type = schema.get("type")
if schema_type == "array":
array_params.add(param_name)
return
if schema_type == "object":
object_params.add(param_name)
return
# anyOf / oneOf unions (e.g., list[str] | None → {"anyOf": [{"type": "array"}, {"type": "null"}]})
for variant in schema.get("anyOf", []) + schema.get("oneOf", []):
variant_type = variant.get("type")
if variant_type == "array":
array_params.add(param_name)
return
if variant_type == "object":
object_params.add(param_name)
return
def _coerce_string_json(arguments: dict, array_params: set[str], object_params: set[str]) -> dict:
"""Auto-coerce string-encoded JSON arrays/objects to native types.
LLM agents frequently serialize list and dict tool arguments as JSON strings.
This is backward-compatible: native arrays/objects pass through unchanged.
"""
for param_name in array_params:
val = arguments.get(param_name)
if isinstance(val, str):
try:
parsed = json.loads(val)
if isinstance(parsed, list):
arguments = {**arguments, param_name: parsed}
logger.debug(f"Coerced string to list for parameter '{param_name}'")
except (json.JSONDecodeError, TypeError):
pass
for param_name in object_params:
val = arguments.get(param_name)
if isinstance(val, str):
try:
parsed = json.loads(val)
if isinstance(parsed, dict):
arguments = {**arguments, param_name: parsed}
logger.debug(f"Coerced string to dict for parameter '{param_name}'")
except (json.JSONDecodeError, TypeError):
pass
return arguments
class MCPMiddleware:
"""ASGI middleware that intercepts MCP requests and routes to appropriate MCP server.
@@ -272,10 +341,12 @@ class MCPMiddleware:
self.single_bank_server = single_bank_server
else:
# Create servers internally (for direct construction / tests)
global_config = _get_raw_config()
stateless = global_config.mcp_stateless
self.multi_bank_server = create_mcp_server(memory, multi_bank=True)
self.multi_bank_app = self.multi_bank_server.http_app(path="/", stateless_http=True)
self.multi_bank_app = self.multi_bank_server.http_app(path="/", stateless_http=stateless)
self.single_bank_server = create_mcp_server(memory, multi_bank=False)
self.single_bank_app = self.single_bank_server.http_app(path="/", stateless_http=True)
self.single_bank_app = self.single_bank_server.http_app(path="/", stateless_http=stateless)
def _get_header(self, scope: dict, name: str) -> str | None:
"""Extract a header value from ASGI scope."""
@@ -298,6 +369,17 @@ class MCPMiddleware:
await self.app(scope, receive, send)
return
# Handle GET-before-POST gracefully (Claude Code v2.1.84+ sends GET probe before POST initialize).
# Without a valid Mcp-Session-Id, GET has no meaningful response — return 200 OK so
# the client proceeds to POST initialize instead of marking the server as failed.
method = scope.get("method", "")
if method == "GET":
session_id = self._get_header(scope, "Mcp-Session-Id")
if not session_id:
logger.debug("MCP GET without session ID (client probe) — returning 200 OK")
await self._send_ok(send)
return
# Strip prefix from path
path = path[len(self.prefix) :] or "/"
@@ -312,6 +394,7 @@ class MCPMiddleware:
tenant_context = None
auth_tenant_id: str | None = None
auth_api_key_id: str | None = None
mcp_pre_authenticated = False
if MCP_AUTH_TOKEN:
# Legacy authentication mode - validate against static token
if not auth_token:
@@ -320,8 +403,9 @@ class MCPMiddleware:
if auth_token != MCP_AUTH_TOKEN:
await self._send_error(send, 401, "Invalid authentication token")
return
# Legacy mode doesn't use tenant schemas
# Legacy mode: mark as pre-authenticated so tenant extension won't re-validate
tenant_context = None
mcp_pre_authenticated = True
else:
# Use TenantExtension.authenticate_mcp() for auth
try:
@@ -368,19 +452,30 @@ class MCPMiddleware:
# - Header/env bank_id → multi-bank app (bank_id param, all tools)
target_app = self.single_bank_app if bank_id_from_path else self.multi_bank_app
# Set bank_id, api_key, tenant_id, and api_key_id context
# Set bank_id, api_key, tenant_id, api_key_id, and mcp_authenticated context
bank_id_token = _current_bank_id.set(bank_id)
# Store the auth token for tenant extension to validate
api_key_token = _current_api_key.set(auth_token) if auth_token else None
# Store tenant_id and api_key_id from authentication for usage metering
tenant_id_token = _current_tenant_id.set(auth_tenant_id) if auth_tenant_id else None
api_key_id_token = _current_api_key_id.set(auth_api_key_id) if auth_api_key_id else None
# Store MCP pre-authentication flag to skip tenant re-validation
mcp_auth_token = _current_mcp_authenticated.set(mcp_pre_authenticated)
try:
new_scope = scope.copy()
new_scope["path"] = new_path
# Clear root_path since we're passing directly to the app
new_scope["root_path"] = ""
# Ensure Accept header includes required MIME types for MCP SDK.
# Some clients (e.g., Claude Code) don't send Accept, causing
# the SDK to reject with 406 Not Acceptable.
accept_header = self._get_header(new_scope, "accept")
if not accept_header or "text/event-stream" not in accept_header:
headers = [(k, v) for k, v in new_scope.get("headers", []) if k.lower() != b"accept"]
headers.append((b"accept", b"application/json, text/event-stream"))
new_scope["headers"] = headers
# Wrap send to rewrite the SSE endpoint URL to include bank_id if using path-based routing.
# Only rewrite SSE (text/event-stream) responses to avoid corrupting tool results
# that might contain the literal string "data: /messages".
@@ -410,9 +505,26 @@ class MCPMiddleware:
_current_tenant_id.reset(tenant_id_token)
if api_key_id_token is not None:
_current_api_key_id.reset(api_key_id_token)
_current_mcp_authenticated.reset(mcp_auth_token)
if schema_token is not None:
_current_schema.reset(schema_token)
async def _send_ok(self, send):
"""Send a 200 OK response with empty body (used for GET probes without session)."""
await send(
{
"type": "http.response.start",
"status": 200,
"headers": [(b"content-type", b"application/json")],
}
)
await send(
{
"type": "http.response.body",
"body": b"{}",
}
)
async def _send_error(self, send, status: int, message: str, extra_headers: dict[str, str] | None = None):
"""Send an error response."""
body = json.dumps({"error": message}).encode()
@@ -443,10 +555,14 @@ def create_mcp_servers(memory: MemoryEngine):
Returns:
Tuple of (multi_bank_server, single_bank_server, multi_bank_app, single_bank_app)
"""
global_config = _get_raw_config()
stateless = global_config.mcp_stateless
multi_bank_server = create_mcp_server(memory, multi_bank=True)
multi_bank_app = multi_bank_server.http_app(path="/", stateless_http=True)
multi_bank_app = multi_bank_server.http_app(path="/", stateless_http=stateless)
single_bank_server = create_mcp_server(memory, multi_bank=False)
single_bank_app = single_bank_server.http_app(path="/", stateless_http=True)
single_bank_app = single_bank_server.http_app(path="/", stateless_http=stateless)
logger.info(f"MCP servers created (stateless_http={stateless})")
return multi_bank_server, single_bank_server, multi_bank_app, single_bank_app
+320 -9
View File
@@ -118,6 +118,7 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]:
# Environment variable names
ENV_DATABASE_URL = "HINDSIGHT_API_DATABASE_URL"
ENV_MIGRATION_DATABASE_URL = "HINDSIGHT_API_MIGRATION_DATABASE_URL"
ENV_DATABASE_SCHEMA = "HINDSIGHT_API_DATABASE_SCHEMA"
ENV_LLM_PROVIDER = "HINDSIGHT_API_LLM_PROVIDER"
ENV_LLM_API_KEY = "HINDSIGHT_API_LLM_API_KEY"
@@ -130,10 +131,12 @@ ENV_LLM_MAX_BACKOFF = "HINDSIGHT_API_LLM_MAX_BACKOFF"
ENV_LLM_TIMEOUT = "HINDSIGHT_API_LLM_TIMEOUT"
ENV_LLM_GROQ_SERVICE_TIER = "HINDSIGHT_API_LLM_GROQ_SERVICE_TIER"
ENV_LLM_OPENAI_SERVICE_TIER = "HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER"
ENV_LLM_EXTRA_BODY = "HINDSIGHT_API_LLM_EXTRA_BODY"
# Defaults for service tiers
DEFAULT_LLM_GROQ_SERVICE_TIER = "auto" # "on_demand", "flex", or "auto"
DEFAULT_LLM_OPENAI_SERVICE_TIER = None # None (default) or "flex" (50% cheaper)
DEFAULT_LLM_EXTRA_BODY = None # None = no extra body params; JSON dict merged into OpenAI extra_body
# Per-operation LLM configuration (optional, falls back to global LLM config)
ENV_RETAIN_LLM_PROVIDER = "HINDSIGHT_API_RETAIN_LLM_PROVIDER"
@@ -175,6 +178,14 @@ ENV_EMBEDDINGS_OPENAI_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY"
ENV_EMBEDDINGS_OPENAI_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL"
ENV_EMBEDDINGS_OPENAI_BASE_URL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL"
# Gemini/Vertex AI embeddings configuration
ENV_EMBEDDINGS_GEMINI_API_KEY = "HINDSIGHT_API_EMBEDDINGS_GEMINI_API_KEY"
ENV_EMBEDDINGS_GEMINI_MODEL = "HINDSIGHT_API_EMBEDDINGS_GEMINI_MODEL"
ENV_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY = "HINDSIGHT_API_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY"
ENV_EMBEDDINGS_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_EMBEDDINGS_VERTEXAI_PROJECT_ID"
ENV_EMBEDDINGS_VERTEXAI_REGION = "HINDSIGHT_API_EMBEDDINGS_VERTEXAI_REGION"
ENV_EMBEDDINGS_VERTEXAI_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_EMBEDDINGS_VERTEXAI_SERVICE_ACCOUNT_KEY"
# Cohere configuration (separate for embeddings and reranker)
ENV_EMBEDDINGS_COHERE_API_KEY = "HINDSIGHT_API_EMBEDDINGS_COHERE_API_KEY"
ENV_EMBEDDINGS_COHERE_MODEL = "HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL"
@@ -183,6 +194,13 @@ ENV_RERANKER_COHERE_API_KEY = "HINDSIGHT_API_RERANKER_COHERE_API_KEY"
ENV_RERANKER_COHERE_MODEL = "HINDSIGHT_API_RERANKER_COHERE_MODEL"
ENV_RERANKER_COHERE_BASE_URL = "HINDSIGHT_API_RERANKER_COHERE_BASE_URL"
# OpenRouter configuration (embeddings and reranker)
ENV_OPENROUTER_API_KEY = "HINDSIGHT_API_OPENROUTER_API_KEY"
ENV_EMBEDDINGS_OPENROUTER_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENROUTER_API_KEY"
ENV_EMBEDDINGS_OPENROUTER_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENROUTER_MODEL"
ENV_RERANKER_OPENROUTER_API_KEY = "HINDSIGHT_API_RERANKER_OPENROUTER_API_KEY"
ENV_RERANKER_OPENROUTER_MODEL = "HINDSIGHT_API_RERANKER_OPENROUTER_MODEL"
# Deprecated: Legacy shared Cohere API key (for backward compatibility)
ENV_COHERE_API_KEY = "HINDSIGHT_API_COHERE_API_KEY"
@@ -199,6 +217,8 @@ ENV_RERANKER_LITELLM_MAX_TOKENS_PER_DOC = "HINDSIGHT_API_RERANKER_LITELLM_MAX_TO
ENV_EMBEDDINGS_LITELLM_SDK_API_KEY = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_KEY"
ENV_EMBEDDINGS_LITELLM_SDK_MODEL = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MODEL"
ENV_EMBEDDINGS_LITELLM_SDK_API_BASE = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_BASE"
ENV_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS"
ENV_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT"
ENV_RERANKER_LITELLM_SDK_API_KEY = "HINDSIGHT_API_RERANKER_LITELLM_SDK_API_KEY"
ENV_RERANKER_LITELLM_SDK_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_SDK_MODEL"
ENV_RERANKER_LITELLM_SDK_API_BASE = "HINDSIGHT_API_RERANKER_LITELLM_SDK_API_BASE"
@@ -212,6 +232,9 @@ ENV_RERANKER_LOCAL_MODEL = "HINDSIGHT_API_RERANKER_LOCAL_MODEL"
ENV_RERANKER_LOCAL_FORCE_CPU = "HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"
ENV_RERANKER_LOCAL_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_LOCAL_MAX_CONCURRENT"
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE = "HINDSIGHT_API_RERANKER_LOCAL_TRUST_REMOTE_CODE"
ENV_RERANKER_LOCAL_FP16 = "HINDSIGHT_API_RERANKER_LOCAL_FP16"
ENV_RERANKER_LOCAL_BUCKET_BATCHING = "HINDSIGHT_API_RERANKER_LOCAL_BUCKET_BATCHING"
ENV_RERANKER_LOCAL_BATCH_SIZE = "HINDSIGHT_API_RERANKER_LOCAL_BATCH_SIZE"
ENV_RERANKER_TEI_URL = "HINDSIGHT_API_RERANKER_TEI_URL"
ENV_RERANKER_TEI_BATCH_SIZE = "HINDSIGHT_API_RERANKER_TEI_BATCH_SIZE"
ENV_RERANKER_TEI_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_TEI_MAX_CONCURRENT"
@@ -222,6 +245,17 @@ ENV_RERANKER_FLASHRANK_CACHE_DIR = "HINDSIGHT_API_RERANKER_FLASHRANK_CACHE_DIR"
# ZeroEntropy configuration (reranker only)
ENV_RERANKER_ZEROENTROPY_API_KEY = "HINDSIGHT_API_RERANKER_ZEROENTROPY_API_KEY"
ENV_RERANKER_ZEROENTROPY_MODEL = "HINDSIGHT_API_RERANKER_ZEROENTROPY_MODEL"
ENV_RERANKER_ZEROENTROPY_BASE_URL = "HINDSIGHT_API_RERANKER_ZEROENTROPY_BASE_URL"
# SiliconFlow configuration (reranker only; Cohere-compatible /rerank endpoint)
ENV_RERANKER_SILICONFLOW_API_KEY = "HINDSIGHT_API_RERANKER_SILICONFLOW_API_KEY"
ENV_RERANKER_SILICONFLOW_MODEL = "HINDSIGHT_API_RERANKER_SILICONFLOW_MODEL"
ENV_RERANKER_SILICONFLOW_BASE_URL = "HINDSIGHT_API_RERANKER_SILICONFLOW_BASE_URL"
# Google Discovery Engine reranker configuration
ENV_RERANKER_GOOGLE_MODEL = "HINDSIGHT_API_RERANKER_GOOGLE_MODEL"
ENV_RERANKER_GOOGLE_PROJECT_ID = "HINDSIGHT_API_RERANKER_GOOGLE_PROJECT_ID"
ENV_RERANKER_GOOGLE_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_RERANKER_GOOGLE_SERVICE_ACCOUNT_KEY"
ENV_VECTOR_EXTENSION = "HINDSIGHT_API_VECTOR_EXTENSION"
ENV_TEXT_SEARCH_EXTENSION = "HINDSIGHT_API_TEXT_SEARCH_EXTENSION"
@@ -234,13 +268,16 @@ ENV_LOG_FORMAT = "HINDSIGHT_API_LOG_FORMAT"
ENV_WORKERS = "HINDSIGHT_API_WORKERS"
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
ENV_MCP_ENABLED_TOOLS = "HINDSIGHT_API_MCP_ENABLED_TOOLS"
ENV_MCP_STATELESS = "HINDSIGHT_API_MCP_STATELESS"
ENV_ENABLE_BANK_CONFIG_API = "HINDSIGHT_API_ENABLE_BANK_CONFIG_API"
ENV_DEFAULT_BANK_TEMPLATE = "HINDSIGHT_API_DEFAULT_BANK_TEMPLATE"
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
ENV_MPFP_TOP_K_NEIGHBORS = "HINDSIGHT_API_MPFP_TOP_K_NEIGHBORS"
ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
ENV_RECALL_CONNECTION_BUDGET = "HINDSIGHT_API_RECALL_CONNECTION_BUDGET"
ENV_RECALL_MAX_QUERY_TOKENS = "HINDSIGHT_API_RECALL_MAX_QUERY_TOKENS"
ENV_MENTAL_MODEL_REFRESH_CONCURRENCY = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY"
ENV_LINK_EXPANSION_PER_ENTITY_LIMIT = "HINDSIGHT_API_LINK_EXPANSION_PER_ENTITY_LIMIT"
ENV_LINK_EXPANSION_TIMEOUT = "HINDSIGHT_API_LINK_EXPANSION_TIMEOUT"
# OpenTelemetry tracing configuration
ENV_OTEL_TRACES_ENABLED = "HINDSIGHT_API_OTEL_TRACES_ENABLED"
@@ -248,6 +285,7 @@ ENV_OTEL_EXPORTER_OTLP_ENDPOINT = "HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT"
ENV_OTEL_EXPORTER_OTLP_HEADERS = "HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS"
ENV_OTEL_SERVICE_NAME = "HINDSIGHT_API_OTEL_SERVICE_NAME"
ENV_OTEL_DEPLOYMENT_ENVIRONMENT = "HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT"
ENV_METRICS_INCLUDE_BANK_ID = "HINDSIGHT_API_METRICS_INCLUDE_BANK_ID"
# Vertex AI configuration
ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"
@@ -264,10 +302,12 @@ ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
ENV_RETAIN_MISSION = "HINDSIGHT_API_RETAIN_MISSION"
ENV_RETAIN_CUSTOM_INSTRUCTIONS = "HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"
ENV_RETAIN_DEFAULT_STRATEGY = "HINDSIGHT_API_RETAIN_DEFAULT_STRATEGY"
ENV_RETAIN_BATCH_TOKENS = "HINDSIGHT_API_RETAIN_BATCH_TOKENS"
ENV_RETAIN_ENTITY_LOOKUP = "HINDSIGHT_API_RETAIN_ENTITY_LOOKUP"
ENV_RETAIN_BATCH_ENABLED = "HINDSIGHT_API_RETAIN_BATCH_ENABLED"
ENV_RETAIN_BATCH_POLL_INTERVAL_SECONDS = "HINDSIGHT_API_RETAIN_BATCH_POLL_INTERVAL_SECONDS"
ENV_RETAIN_CHUNK_BATCH_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_BATCH_SIZE"
# File storage configuration
ENV_FILE_STORAGE_TYPE = "HINDSIGHT_API_FILE_STORAGE_TYPE"
@@ -300,6 +340,7 @@ ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = (
"HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION"
)
ENV_OBSERVATIONS_MISSION = "HINDSIGHT_API_OBSERVATIONS_MISSION"
ENV_MAX_OBSERVATIONS_PER_SCOPE = "HINDSIGHT_API_MAX_OBSERVATIONS_PER_SCOPE"
ENV_ENABLE_OBSERVATION_HISTORY = "HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY"
ENV_ENABLE_MENTAL_MODEL_HISTORY = "HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY"
@@ -309,6 +350,14 @@ ENV_WEBHOOK_SECRET = "HINDSIGHT_API_WEBHOOK_SECRET"
ENV_WEBHOOK_EVENT_TYPES = "HINDSIGHT_API_WEBHOOK_EVENT_TYPES"
ENV_WEBHOOK_DELIVERY_POLL_INTERVAL_SECONDS = "HINDSIGHT_API_WEBHOOK_DELIVERY_POLL_INTERVAL_SECONDS"
# Built-in llama.cpp configuration (for provider=llamacpp)
ENV_LLAMACPP_MODEL_PATH = "HINDSIGHT_API_LLAMACPP_MODEL_PATH"
ENV_LLAMACPP_GPU_LAYERS = "HINDSIGHT_API_LLAMACPP_GPU_LAYERS"
ENV_LLAMACPP_CONTEXT_SIZE = "HINDSIGHT_API_LLAMACPP_CONTEXT_SIZE"
ENV_LLAMACPP_CHAT_FORMAT = "HINDSIGHT_API_LLAMACPP_CHAT_FORMAT"
ENV_LLAMACPP_NO_GRAMMAR = "HINDSIGHT_API_LLAMACPP_NO_GRAMMAR"
ENV_LLAMACPP_EXTRA_ARGS = "HINDSIGHT_API_LLAMACPP_EXTRA_ARGS"
# Optimization flags
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
@@ -330,11 +379,22 @@ ENV_WORKER_MAX_RETRIES = "HINDSIGHT_API_WORKER_MAX_RETRIES"
ENV_WORKER_HTTP_PORT = "HINDSIGHT_API_WORKER_HTTP_PORT"
ENV_WORKER_MAX_SLOTS = "HINDSIGHT_API_WORKER_MAX_SLOTS"
ENV_WORKER_CONSOLIDATION_MAX_SLOTS = "HINDSIGHT_API_WORKER_CONSOLIDATION_MAX_SLOTS"
ENV_RETAIN_MAX_CONCURRENT = "HINDSIGHT_API_RETAIN_MAX_CONCURRENT"
# Reflect agent settings
ENV_REFLECT_MAX_ITERATIONS = "HINDSIGHT_API_REFLECT_MAX_ITERATIONS"
ENV_REFLECT_MAX_CONTEXT_TOKENS = "HINDSIGHT_API_REFLECT_MAX_CONTEXT_TOKENS"
ENV_REFLECT_WALL_TIMEOUT = "HINDSIGHT_API_REFLECT_WALL_TIMEOUT"
ENV_REFLECT_MISSION = "HINDSIGHT_API_REFLECT_MISSION"
ENV_REFLECT_SOURCE_FACTS_MAX_TOKENS = "HINDSIGHT_API_REFLECT_SOURCE_FACTS_MAX_TOKENS"
ENV_RECALL_INCLUDE_CHUNKS = "HINDSIGHT_API_RECALL_INCLUDE_CHUNKS"
ENV_RECALL_MAX_TOKENS = "HINDSIGHT_API_RECALL_MAX_TOKENS"
ENV_RECALL_CHUNKS_MAX_TOKENS = "HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS"
# Audit log settings
ENV_AUDIT_LOG_ENABLED = "HINDSIGHT_API_AUDIT_LOG_ENABLED"
ENV_AUDIT_LOG_ACTIONS = "HINDSIGHT_API_AUDIT_LOG_ACTIONS"
ENV_AUDIT_LOG_RETENTION_DAYS = "HINDSIGHT_API_AUDIT_LOG_RETENTION_DAYS"
# Disposition settings
ENV_DISPOSITION_SKEPTICISM = "HINDSIGHT_API_DISPOSITION_SKEPTICISM"
@@ -349,18 +409,31 @@ DEFAULT_LLM_PROVIDER = "openai"
# Provider-specific default models
PROVIDER_DEFAULT_MODELS = {
"openai": "gpt-4o-mini",
"anthropic": "claude-haiku-4-5-20251001",
"anthropic": "claude-haiku-4-5",
"gemini": "gemini-2.5-flash",
"groq": "openai/gpt-oss-120b",
"minimax": "MiniMax-M2.5",
"minimax": "MiniMax-M2.7",
"ollama": "gemma3:12b",
"llamacpp": "gemma-4-e2b-it",
"lmstudio": "local-model",
"vertexai": "google/gemini-2.5-flash-lite",
"openai-codex": "gpt-5.2-codex",
"claude-code": "claude-sonnet-4-5-20250929",
"mock": "mock-model",
"none": "none",
"litellm": "gpt-4o-mini",
"bedrock": "us.amazon.nova-2-lite-v1:0",
"volcano": "doubao-pro-32k",
"openrouter": "qwen/qwen3.5-9b",
}
DEFAULT_LLM_MODEL = "gpt-4o-mini" # Fallback if provider not in table
# Built-in llama.cpp defaults
DEFAULT_LLAMACPP_GPU_LAYERS = -1 # -1 = offload all layers to GPU (Metal/CUDA)
DEFAULT_LLAMACPP_CONTEXT_SIZE = 8192
DEFAULT_LLAMACPP_CHAT_FORMAT = None # None = auto-detect from GGUF metadata
DEFAULT_LLAMACPP_NO_GRAMMAR = False # True = disable JSON grammar enforcement (faster but less reliable)
DEFAULT_LLAMACPP_EXTRA_ARGS = None # Space-separated extra CLI args for llama.cpp server
DEFAULT_LLM_MAX_CONCURRENT = 32
DEFAULT_LLM_MAX_RETRIES = 10 # Max retry attempts for LLM API calls
DEFAULT_LLM_INITIAL_BACKOFF = 1.0 # Initial backoff in seconds for retry exponential backoff
@@ -380,6 +453,8 @@ DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS)
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE = False # Security: disabled by default, required for some models
DEFAULT_EMBEDDINGS_OPENAI_MODEL = "text-embedding-3-small"
DEFAULT_EMBEDDINGS_GEMINI_MODEL = "gemini-embedding-001"
DEFAULT_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY = 768
DEFAULT_EMBEDDING_DIMENSION = 384
DEFAULT_RERANKER_PROVIDER = "local"
@@ -389,6 +464,9 @@ DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT = 4 # Limit concurrent CPU-bound rerankin
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE = (
False # Security: disabled by default, required for some models like jina-reranker-v2
)
DEFAULT_RERANKER_LOCAL_FP16 = False # FP16 inference: opt-in, faster on MPS/CUDA (not CPU)
DEFAULT_RERANKER_LOCAL_BUCKET_BATCHING = False # Length-sorted bucket batching: opt-in, 36-54% speedup
DEFAULT_RERANKER_LOCAL_BATCH_SIZE = 32 # Batch size for local reranker predict() calls
DEFAULT_RERANKER_TEI_BATCH_SIZE = 128
DEFAULT_RERANKER_TEI_MAX_CONCURRENT = 8
DEFAULT_RERANKER_MAX_CANDIDATES = 300
@@ -398,8 +476,17 @@ DEFAULT_RERANKER_FLASHRANK_CACHE_DIR = None # Use default cache directory
DEFAULT_EMBEDDINGS_COHERE_MODEL = "embed-english-v3.0"
DEFAULT_RERANKER_COHERE_MODEL = "rerank-english-v3.0"
# OpenRouter defaults
DEFAULT_EMBEDDINGS_OPENROUTER_MODEL = "perplexity/pplx-embed-v1-0.6b"
DEFAULT_RERANKER_OPENROUTER_MODEL = "cohere/rerank-v3.5"
DEFAULT_RERANKER_ZEROENTROPY_MODEL = "zerank-2"
DEFAULT_RERANKER_SILICONFLOW_MODEL = "BAAI/bge-reranker-v2-m3"
DEFAULT_RERANKER_SILICONFLOW_BASE_URL = "https://api.siliconflow.cn/v1"
DEFAULT_RERANKER_GOOGLE_MODEL = "semantic-ranker-default-004"
# Vector extension (pgvector, vchord, or pgvectorscale)
DEFAULT_VECTOR_EXTENSION = "pgvector" # Options: "pgvector", "vchord", "pgvectorscale"
@@ -414,6 +501,7 @@ DEFAULT_RERANKER_LITELLM_MAX_TOKENS_PER_DOC: int | None = None
# LiteLLM SDK defaults
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL = "cohere/embed-english-v3.0"
DEFAULT_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT = "float"
DEFAULT_RERANKER_LITELLM_SDK_MODEL = "cohere/rerank-english-v3.0"
DEFAULT_HOST = "0.0.0.0"
@@ -424,22 +512,30 @@ DEFAULT_LOG_FORMAT = "text" # Options: "text", "json"
DEFAULT_WORKERS = 1
DEFAULT_MCP_ENABLED = True
DEFAULT_MCP_ENABLED_TOOLS: list[str] | None = None # None = all tools enabled
DEFAULT_MCP_STATELESS = False # False = stateful (supports SSE/GET); True = stateless (POST-only)
DEFAULT_ENABLE_BANK_CONFIG_API = True
DEFAULT_GRAPH_RETRIEVER = "link_expansion" # Options: "link_expansion", "mpfp", "bfs"
DEFAULT_MPFP_TOP_K_NEIGHBORS = 20 # Fan-out limit per node in MPFP graph traversal
DEFAULT_DEFAULT_BANK_TEMPLATE: dict | None = None # BankTemplateManifest dict applied to newly-created banks
DEFAULT_GRAPH_RETRIEVER = "link_expansion"
DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worker
DEFAULT_RECALL_CONNECTION_BUDGET = 4 # Max concurrent DB connections per recall operation
DEFAULT_RECALL_MAX_QUERY_TOKENS = 500 # Maximum tokens allowed in recall query
DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY = 8 # Max concurrent mental model refreshes
DEFAULT_LINK_EXPANSION_PER_ENTITY_LIMIT = 200 # Max target units per entity in graph expansion
DEFAULT_LINK_EXPANSION_TIMEOUT = 10.0 # Timeout (seconds) for entity expansion query
# Retain settings
DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction LLM call
DEFAULT_RETAIN_CHUNK_SIZE = 3000 # Max chars per chunk for fact extraction
DEFAULT_RETAIN_EXTRACT_CAUSAL_LINKS = True # Extract causal links between facts
DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise", "verbose", or "custom"
RETAIN_EXTRACTION_MODES = ("concise", "verbose", "custom") # Allowed extraction modes
RETAIN_EXTRACTION_MODES = ("concise", "verbose", "custom", "verbatim", "chunks") # Allowed extraction modes
DEFAULT_RETAIN_MISSION = None # Declarative spec of what to retain (injected into any extraction mode)
DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS = None # Custom extraction guidelines (only used when mode="custom")
DEFAULT_RETAIN_DEFAULT_STRATEGY = None # Default strategy name (None = no strategy override)
DEFAULT_RETAIN_STRATEGIES: dict | None = None # Named retain strategies (dict of name → config overrides)
DEFAULT_RETAIN_CHUNK_BATCH_SIZE = (
100 # Max chunks per streaming batch. Each chunk produces ~17 facts, so 100 chunks = ~1700 facts/batch.
)
DEFAULT_RETAIN_BATCH_TOKENS = 10_000 # ~40KB of text # Max chars per sub-batch for async retain auto-splitting
DEFAULT_RETAIN_ENTITY_LOOKUP = "trigram" # "full" or "trigram"
DEFAULT_RETAIN_BATCH_ENABLED = False # Use LLM Batch API for fact extraction (only when async=True)
@@ -468,6 +564,7 @@ DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = (
256 # Max tokens of source facts per observation in consolidation prompt (-1 = unlimited)
)
DEFAULT_OBSERVATIONS_MISSION = None # Declarative spec of what observations are for this bank
DEFAULT_MAX_OBSERVATIONS_PER_SCOPE = -1 # Max observations per tag scope (-1 = unlimited)
# Database migrations
DEFAULT_RUN_MIGRATIONS_ON_STARTUP = True
@@ -486,10 +583,16 @@ DEFAULT_WORKER_MAX_RETRIES = 3 # Max retries before marking task failed
DEFAULT_WORKER_HTTP_PORT = 8889 # HTTP port for worker metrics/health
DEFAULT_WORKER_MAX_SLOTS = 10 # Total concurrent tasks per worker
DEFAULT_WORKER_CONSOLIDATION_MAX_SLOTS = 2 # Max concurrent consolidation tasks per worker
DEFAULT_RETAIN_MAX_CONCURRENT = 4 # Max concurrent retain DB phases (HNSW reads + writes). Limits I/O contention.
# Reflect agent settings
DEFAULT_REFLECT_MAX_ITERATIONS = 10 # Max tool call iterations before forcing response
DEFAULT_REFLECT_MAX_CONTEXT_TOKENS = 100_000 # Max accumulated context tokens before forcing final prompt
DEFAULT_REFLECT_WALL_TIMEOUT = 300 # Wall-clock timeout in seconds for the entire reflect operation (5 minutes)
DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS = -1 # Token budget for source facts in search_observations (-1 = disabled)
DEFAULT_RECALL_INCLUDE_CHUNKS = True # Whether internal recall (e.g. mental model refresh) returns raw chunks
DEFAULT_RECALL_MAX_TOKENS = 2048 # Token budget for facts returned by internal recall
DEFAULT_RECALL_CHUNKS_MAX_TOKENS = 1000 # Token budget for raw chunks returned by internal recall
# Disposition defaults (None = not set, fall back to bank DB value or 3)
DEFAULT_DISPOSITION_SKEPTICISM = None
@@ -500,6 +603,12 @@ DEFAULT_DISPOSITION_EMPATHY = None
DEFAULT_OTEL_TRACES_ENABLED = False # Disabled by default for backward compatibility
DEFAULT_OTEL_SERVICE_NAME = "hindsight-api"
DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT = "development"
DEFAULT_METRICS_INCLUDE_BANK_ID = False # Disabled by default to avoid high-cardinality OTel metric growth
# Audit log defaults
DEFAULT_AUDIT_LOG_ENABLED = False # Disabled by default
DEFAULT_AUDIT_LOG_ACTIONS = "" # Empty = audit all eligible actions
DEFAULT_AUDIT_LOG_RETENTION_DAYS = -1 # -1 = keep forever
# Default MCP tool descriptions (can be customized via env vars)
DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory.
@@ -583,12 +692,33 @@ def _get_default_model_for_provider(provider: str) -> str:
return PROVIDER_DEFAULT_MODELS.get(provider.lower(), DEFAULT_LLM_MODEL)
def _parse_default_bank_template(raw: str | None) -> dict | None:
"""
Parse HINDSIGHT_API_DEFAULT_BANK_TEMPLATE as JSON.
The env var holds a BankTemplateManifest (JSON object) applied verbatim to
every newly-created bank. Full Pydantic validation is deferred to bank
creation time (to avoid pulling API models into config.py), but we fail
fast here if the value is not valid JSON or not a JSON object.
"""
if raw is None or raw.strip() == "":
return DEFAULT_DEFAULT_BANK_TEMPLATE
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid {ENV_DEFAULT_BANK_TEMPLATE}: expected a JSON object, got invalid JSON: {e}") from e
if not isinstance(parsed, dict):
raise ValueError(f"Invalid {ENV_DEFAULT_BANK_TEMPLATE}: expected a JSON object, got {type(parsed).__name__}")
return parsed
@dataclass
class HindsightConfig:
"""Configuration container for Hindsight API."""
# Database
database_url: str
migration_database_url: str | None
database_schema: str
vector_extension: str # "pgvector" or "vchord"
text_search_extension: str # "native" or "vchord"
@@ -605,6 +735,9 @@ class HindsightConfig:
llm_timeout: float
llm_groq_service_tier: str # Groq: "on_demand", "flex", or "auto"
llm_openai_service_tier: str | None # OpenAI: None (default) or "flex" (50% cheaper)
llm_extra_body: (
dict | None
) # Extra body params merged into OpenAI-compatible API calls (e.g. {"chat_template_kwargs": {"enable_thinking": true}})
# Vertex AI configuration
llm_vertexai_project_id: str | None
@@ -614,6 +747,14 @@ class HindsightConfig:
# Gemini safety settings (None = use Gemini defaults; list of dicts with category/threshold)
llm_gemini_safety_settings: list | None
# Built-in llama.cpp configuration (for provider=llamacpp)
llamacpp_model_path: str | None # Path to GGUF file (None = auto-download default)
llamacpp_gpu_layers: int # -1 = all layers on GPU, 0 = CPU only
llamacpp_context_size: int # Context window size
llamacpp_chat_format: str | None # Chat template format (None = auto-detect from GGUF)
llamacpp_no_grammar: bool # Disable JSON grammar enforcement (faster, less reliable)
llamacpp_extra_args: str | None # Space-separated extra CLI args for llama.cpp server
# Per-operation LLM configuration (None = use default LLM config)
retain_llm_provider: str | None
retain_llm_api_key: str | None
@@ -655,12 +796,23 @@ class HindsightConfig:
embeddings_cohere_api_key: str | None
embeddings_cohere_model: str
embeddings_cohere_base_url: str | None
embeddings_openrouter_api_key: str | None
embeddings_openrouter_model: str
embeddings_litellm_api_base: str
embeddings_litellm_api_key: str | None
embeddings_litellm_model: str
embeddings_litellm_sdk_api_key: str | None
embeddings_litellm_sdk_model: str
embeddings_litellm_sdk_api_base: str | None
embeddings_litellm_sdk_output_dimensions: int | None
embeddings_litellm_sdk_encoding_format: str | None
# Gemini/Vertex AI embeddings
embeddings_gemini_api_key: str | None
embeddings_gemini_model: str
embeddings_gemini_output_dimensionality: int | None
embeddings_vertexai_project_id: str | None
embeddings_vertexai_region: str | None
embeddings_vertexai_service_account_key: str | None
# Reranker
reranker_provider: str
@@ -668,6 +820,9 @@ class HindsightConfig:
reranker_local_force_cpu: bool
reranker_local_max_concurrent: int
reranker_local_trust_remote_code: bool
reranker_local_fp16: bool
reranker_local_bucket_batching: bool
reranker_local_batch_size: int
reranker_tei_url: str | None
reranker_tei_batch_size: int
reranker_tei_max_concurrent: int
@@ -675,6 +830,8 @@ class HindsightConfig:
reranker_cohere_api_key: str | None
reranker_cohere_model: str
reranker_cohere_base_url: str | None
reranker_openrouter_api_key: str | None
reranker_openrouter_model: str
reranker_litellm_api_base: str
reranker_litellm_api_key: str | None
reranker_litellm_model: str
@@ -684,6 +841,13 @@ class HindsightConfig:
reranker_litellm_sdk_api_base: str | None
reranker_zeroentropy_api_key: str | None
reranker_zeroentropy_model: str
reranker_zeroentropy_base_url: str | None
reranker_siliconflow_api_key: str | None
reranker_siliconflow_model: str
reranker_siliconflow_base_url: str
reranker_google_model: str
reranker_google_project_id: str | None
reranker_google_service_account_key: str | None
# Server
host: str
@@ -693,15 +857,20 @@ class HindsightConfig:
log_format: str
mcp_enabled: bool
mcp_enabled_tools: list[str] | None # None = all tools; explicit list = allowlist
mcp_stateless: bool # True = stateless HTTP (POST-only); False = stateful (supports GET/SSE)
enable_bank_config_api: bool
# Default bank template (static, server-level only). When set, the manifest is applied
# to every newly-created bank, overriding the env/config defaults for any fields it sets.
default_bank_template: dict | None
# Recall
graph_retriever: str
mpfp_top_k_neighbors: int
recall_max_concurrent: int
recall_connection_budget: int
recall_max_query_tokens: int
mental_model_refresh_concurrency: int
link_expansion_per_entity_limit: int
link_expansion_timeout: float
# Retain settings
retain_max_completion_tokens: int
@@ -710,10 +879,13 @@ class HindsightConfig:
retain_extraction_mode: str
retain_mission: str | None
retain_custom_instructions: str | None
retain_default_strategy: str | None
retain_strategies: dict | None
retain_batch_tokens: int
retain_batch_enabled: bool
retain_batch_poll_interval_seconds: int
retain_entity_lookup: str # "full" or "trigram"
retain_chunk_batch_size: int # Max chunks per streaming batch (0 = disabled)
# File storage (static - server-level only)
file_storage_type: str # "native" (PostgreSQL) or "s3" (S3-compatible)
@@ -746,6 +918,7 @@ class HindsightConfig:
consolidation_source_facts_max_tokens: int
consolidation_source_facts_max_tokens_per_observation: int
observations_mission: str | None
max_observations_per_scope: int
# Entity labels (controlled vocabulary of key:value classification labels extracted at retain time)
# List of label group dicts: [{key, description, type, optional, values: [{value, description}]}]
@@ -756,6 +929,12 @@ class HindsightConfig:
# Reflect agent settings
reflect_mission: str | None
reflect_source_facts_max_tokens: int
# Recall settings (used by internal recall, e.g. during mental model refresh)
recall_include_chunks: bool
recall_max_tokens: int
recall_chunks_max_tokens: int
# Disposition settings (hierarchical - can be overridden per bank; None = fall back to DB)
disposition_skepticism: int | None
@@ -783,10 +962,12 @@ class HindsightConfig:
worker_http_port: int
worker_max_slots: int
worker_consolidation_max_slots: int
retain_max_concurrent: int
# Reflect agent settings
reflect_max_iterations: int
reflect_max_context_tokens: int
reflect_wall_timeout: int
# OpenTelemetry tracing configuration
otel_traces_enabled: bool
@@ -794,6 +975,12 @@ class HindsightConfig:
otel_exporter_otlp_headers: str | None
otel_service_name: str
otel_deployment_environment: str
metrics_include_bank_id: bool
# Audit log configuration (static - server-level only)
audit_log_enabled: bool # Master switch for audit logging
audit_log_actions: list[str] # Allowlist of action types (empty = all)
audit_log_retention_days: int # -1 = keep forever, >0 = delete after N days
# Webhook configuration (static - server-level only, not per-bank)
webhook_url: str | None # Global webhook URL (None = disabled)
@@ -818,8 +1005,14 @@ class HindsightConfig:
"embeddings_tei_base_url",
"reranker_tei_base_url",
"reranker_cohere_base_url",
"reranker_zeroentropy_base_url",
"reranker_siliconflow_base_url",
# Service Account Keys
"llm_vertexai_service_account_key",
"embeddings_vertexai_service_account_key",
"reranker_google_service_account_key",
# Embeddings API keys
"embeddings_gemini_api_key",
# File storage credentials
"file_storage_s3_access_key_id",
"file_storage_s3_secret_access_key",
@@ -840,6 +1033,9 @@ class HindsightConfig:
"retain_extraction_mode",
"retain_mission",
"retain_custom_instructions",
"retain_default_strategy",
"retain_strategies",
"retain_chunk_batch_size",
# Entity labels (controlled vocabulary for entity classification)
"entity_labels",
"entities_allow_free_form",
@@ -849,8 +1045,14 @@ class HindsightConfig:
"consolidation_source_facts_max_tokens",
"consolidation_source_facts_max_tokens_per_observation",
"observations_mission",
"max_observations_per_scope",
# Reflect settings
"reflect_mission",
"reflect_source_facts_max_tokens",
# Recall settings (used by internal recall, e.g. mental model refresh)
"recall_include_chunks",
"recall_max_tokens",
"recall_chunks_max_tokens",
# Disposition settings
"disposition_skepticism",
"disposition_literalism",
@@ -933,9 +1135,19 @@ class HindsightConfig:
f"Invalid text_search_extension: {self.text_search_extension}. Must be one of: {', '.join(valid_text_search)}"
)
# When LLM provider is "none", force chunks-only mode and disable LLM-dependent features
if self.llm_provider == "none":
self.retain_extraction_mode = "chunks"
self.enable_observations = False
logger.info(
"LLM provider set to 'none': forcing retain_extraction_mode='chunks', "
"disabling observations/consolidation. Reflect will return HTTP 400."
)
# RETAIN_MAX_COMPLETION_TOKENS must be greater than RETAIN_CHUNK_SIZE
# to ensure the LLM has enough output capacity to extract facts from chunks
if self.retain_max_completion_tokens <= self.retain_chunk_size:
# (not applicable when provider is "none" since no LLM calls are made)
if self.llm_provider != "none" and self.retain_max_completion_tokens <= self.retain_chunk_size:
raise ValueError(
f"Invalid configuration: HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS "
f"({self.retain_max_completion_tokens}) must be greater than "
@@ -957,6 +1169,7 @@ class HindsightConfig:
config = cls(
# Database
database_url=os.getenv(ENV_DATABASE_URL, DEFAULT_DATABASE_URL),
migration_database_url=os.getenv(ENV_MIGRATION_DATABASE_URL) or None,
database_schema=os.getenv(ENV_DATABASE_SCHEMA, DEFAULT_DATABASE_SCHEMA),
vector_extension=os.getenv(ENV_VECTOR_EXTENSION, DEFAULT_VECTOR_EXTENSION).lower(),
text_search_extension=os.getenv(ENV_TEXT_SEARCH_EXTENSION, DEFAULT_TEXT_SEARCH_EXTENSION).lower(),
@@ -972,6 +1185,7 @@ class HindsightConfig:
llm_timeout=float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT))),
llm_groq_service_tier=os.getenv(ENV_LLM_GROQ_SERVICE_TIER, DEFAULT_LLM_GROQ_SERVICE_TIER),
llm_openai_service_tier=os.getenv(ENV_LLM_OPENAI_SERVICE_TIER, DEFAULT_LLM_OPENAI_SERVICE_TIER),
llm_extra_body=json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null")),
# Vertex AI
llm_vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or DEFAULT_LLM_VERTEXAI_PROJECT_ID,
llm_vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION, DEFAULT_LLM_VERTEXAI_REGION),
@@ -979,6 +1193,14 @@ class HindsightConfig:
or DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
# Gemini safety settings (JSON-encoded list of {category, threshold} dicts)
llm_gemini_safety_settings=json.loads(os.getenv(ENV_LLM_GEMINI_SAFETY_SETTINGS, "null")),
# Built-in llama.cpp configuration
llamacpp_model_path=os.getenv(ENV_LLAMACPP_MODEL_PATH) or None,
llamacpp_gpu_layers=int(os.getenv(ENV_LLAMACPP_GPU_LAYERS, str(DEFAULT_LLAMACPP_GPU_LAYERS))),
llamacpp_context_size=int(os.getenv(ENV_LLAMACPP_CONTEXT_SIZE, str(DEFAULT_LLAMACPP_CONTEXT_SIZE))),
llamacpp_chat_format=os.getenv(ENV_LLAMACPP_CHAT_FORMAT) or DEFAULT_LLAMACPP_CHAT_FORMAT,
llamacpp_no_grammar=os.getenv(ENV_LLAMACPP_NO_GRAMMAR, str(DEFAULT_LLAMACPP_NO_GRAMMAR)).lower()
in ("true", "1"),
llamacpp_extra_args=os.getenv(ENV_LLAMACPP_EXTRA_ARGS) or DEFAULT_LLAMACPP_EXTRA_ARGS,
# Per-operation LLM config (None = use default)
retain_llm_provider=os.getenv(ENV_RETAIN_LLM_PROVIDER) or None,
retain_llm_api_key=os.getenv(ENV_RETAIN_LLM_API_KEY) or None,
@@ -1067,6 +1289,11 @@ class HindsightConfig:
embeddings_cohere_api_key=os.getenv(ENV_EMBEDDINGS_COHERE_API_KEY) or os.getenv(ENV_COHERE_API_KEY),
embeddings_cohere_model=os.getenv(ENV_EMBEDDINGS_COHERE_MODEL, DEFAULT_EMBEDDINGS_COHERE_MODEL),
embeddings_cohere_base_url=os.getenv(ENV_EMBEDDINGS_COHERE_BASE_URL) or None,
# OpenRouter embeddings (with fallback to shared OpenRouter key, then LLM key)
embeddings_openrouter_api_key=os.getenv(ENV_EMBEDDINGS_OPENROUTER_API_KEY)
or os.getenv(ENV_OPENROUTER_API_KEY)
or os.getenv(ENV_LLM_API_KEY),
embeddings_openrouter_model=os.getenv(ENV_EMBEDDINGS_OPENROUTER_MODEL, DEFAULT_EMBEDDINGS_OPENROUTER_MODEL),
# LiteLLM embeddings (with backward-compatible fallback to shared config)
embeddings_litellm_api_base=os.getenv(ENV_EMBEDDINGS_LITELLM_API_BASE)
or os.getenv(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE),
@@ -1078,6 +1305,26 @@ class HindsightConfig:
ENV_EMBEDDINGS_LITELLM_SDK_MODEL, DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL
),
embeddings_litellm_sdk_api_base=os.getenv(ENV_EMBEDDINGS_LITELLM_SDK_API_BASE) or None,
embeddings_litellm_sdk_output_dimensions=int(v)
if (v := os.getenv(ENV_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS))
else None,
embeddings_litellm_sdk_encoding_format=os.getenv(
ENV_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT, DEFAULT_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT
),
# Gemini/Vertex AI embeddings (with fallback to LLM keys)
embeddings_gemini_api_key=os.getenv(ENV_EMBEDDINGS_GEMINI_API_KEY) or os.getenv(ENV_LLM_API_KEY),
embeddings_gemini_model=os.getenv(ENV_EMBEDDINGS_GEMINI_MODEL, DEFAULT_EMBEDDINGS_GEMINI_MODEL),
embeddings_gemini_output_dimensionality=int(
os.getenv(
ENV_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY,
str(DEFAULT_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY),
)
),
embeddings_vertexai_project_id=os.getenv(ENV_EMBEDDINGS_VERTEXAI_PROJECT_ID)
or os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID),
embeddings_vertexai_region=os.getenv(ENV_EMBEDDINGS_VERTEXAI_REGION) or os.getenv(ENV_LLM_VERTEXAI_REGION),
embeddings_vertexai_service_account_key=os.getenv(ENV_EMBEDDINGS_VERTEXAI_SERVICE_ACCOUNT_KEY)
or os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY),
# Reranker
reranker_provider=os.getenv(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER),
reranker_local_model=os.getenv(ENV_RERANKER_LOCAL_MODEL, DEFAULT_RERANKER_LOCAL_MODEL),
@@ -1092,6 +1339,15 @@ class HindsightConfig:
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE, str(DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE)
).lower()
in ("true", "1"),
reranker_local_fp16=os.getenv(ENV_RERANKER_LOCAL_FP16, str(DEFAULT_RERANKER_LOCAL_FP16)).lower()
in ("true", "1"),
reranker_local_bucket_batching=os.getenv(
ENV_RERANKER_LOCAL_BUCKET_BATCHING, str(DEFAULT_RERANKER_LOCAL_BUCKET_BATCHING)
).lower()
in ("true", "1"),
reranker_local_batch_size=int(
os.getenv(ENV_RERANKER_LOCAL_BATCH_SIZE, str(DEFAULT_RERANKER_LOCAL_BATCH_SIZE))
),
reranker_tei_url=os.getenv(ENV_RERANKER_TEI_URL),
reranker_tei_batch_size=int(os.getenv(ENV_RERANKER_TEI_BATCH_SIZE, str(DEFAULT_RERANKER_TEI_BATCH_SIZE))),
reranker_tei_max_concurrent=int(
@@ -1102,6 +1358,11 @@ class HindsightConfig:
reranker_cohere_api_key=os.getenv(ENV_RERANKER_COHERE_API_KEY) or os.getenv(ENV_COHERE_API_KEY),
reranker_cohere_model=os.getenv(ENV_RERANKER_COHERE_MODEL, DEFAULT_RERANKER_COHERE_MODEL),
reranker_cohere_base_url=os.getenv(ENV_RERANKER_COHERE_BASE_URL) or None,
# OpenRouter reranker (with fallback to shared OpenRouter key, then LLM key)
reranker_openrouter_api_key=os.getenv(ENV_RERANKER_OPENROUTER_API_KEY)
or os.getenv(ENV_OPENROUTER_API_KEY)
or os.getenv(ENV_LLM_API_KEY),
reranker_openrouter_model=os.getenv(ENV_RERANKER_OPENROUTER_MODEL, DEFAULT_RERANKER_OPENROUTER_MODEL),
# LiteLLM reranker (with backward-compatible fallback to shared config)
reranker_litellm_api_base=os.getenv(ENV_RERANKER_LITELLM_API_BASE)
or os.getenv(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE),
@@ -1117,6 +1378,19 @@ class HindsightConfig:
# ZeroEntropy reranker
reranker_zeroentropy_api_key=os.getenv(ENV_RERANKER_ZEROENTROPY_API_KEY),
reranker_zeroentropy_model=os.getenv(ENV_RERANKER_ZEROENTROPY_MODEL, DEFAULT_RERANKER_ZEROENTROPY_MODEL),
reranker_zeroentropy_base_url=os.getenv(ENV_RERANKER_ZEROENTROPY_BASE_URL) or None,
# SiliconFlow reranker (Cohere-compatible /rerank endpoint)
reranker_siliconflow_api_key=os.getenv(ENV_RERANKER_SILICONFLOW_API_KEY),
reranker_siliconflow_model=os.getenv(ENV_RERANKER_SILICONFLOW_MODEL, DEFAULT_RERANKER_SILICONFLOW_MODEL),
reranker_siliconflow_base_url=os.getenv(
ENV_RERANKER_SILICONFLOW_BASE_URL, DEFAULT_RERANKER_SILICONFLOW_BASE_URL
),
# Google Discovery Engine reranker (with fallback to LLM Vertex AI keys)
reranker_google_model=os.getenv(ENV_RERANKER_GOOGLE_MODEL, DEFAULT_RERANKER_GOOGLE_MODEL),
reranker_google_project_id=os.getenv(ENV_RERANKER_GOOGLE_PROJECT_ID)
or os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID),
reranker_google_service_account_key=os.getenv(ENV_RERANKER_GOOGLE_SERVICE_ACCOUNT_KEY)
or os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY),
# Server
host=os.getenv(ENV_HOST, DEFAULT_HOST),
port=int(os.getenv(ENV_PORT, DEFAULT_PORT)),
@@ -1127,11 +1401,12 @@ class HindsightConfig:
mcp_enabled_tools=[t.strip() for t in os.getenv(ENV_MCP_ENABLED_TOOLS).split(",") if t.strip()]
if os.getenv(ENV_MCP_ENABLED_TOOLS)
else DEFAULT_MCP_ENABLED_TOOLS,
mcp_stateless=os.getenv(ENV_MCP_STATELESS, str(DEFAULT_MCP_STATELESS)).lower() == "true",
enable_bank_config_api=os.getenv(ENV_ENABLE_BANK_CONFIG_API, str(DEFAULT_ENABLE_BANK_CONFIG_API)).lower()
== "true",
default_bank_template=_parse_default_bank_template(os.getenv(ENV_DEFAULT_BANK_TEMPLATE)),
# Recall
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
mpfp_top_k_neighbors=int(os.getenv(ENV_MPFP_TOP_K_NEIGHBORS, str(DEFAULT_MPFP_TOP_K_NEIGHBORS))),
recall_max_concurrent=int(os.getenv(ENV_RECALL_MAX_CONCURRENT, str(DEFAULT_RECALL_MAX_CONCURRENT))),
recall_connection_budget=int(
os.getenv(ENV_RECALL_CONNECTION_BUDGET, str(DEFAULT_RECALL_CONNECTION_BUDGET))
@@ -1140,6 +1415,10 @@ class HindsightConfig:
mental_model_refresh_concurrency=int(
os.getenv(ENV_MENTAL_MODEL_REFRESH_CONCURRENCY, str(DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY))
),
link_expansion_per_entity_limit=int(
os.getenv(ENV_LINK_EXPANSION_PER_ENTITY_LIMIT, str(DEFAULT_LINK_EXPANSION_PER_ENTITY_LIMIT))
),
link_expansion_timeout=float(os.getenv(ENV_LINK_EXPANSION_TIMEOUT, str(DEFAULT_LINK_EXPANSION_TIMEOUT))),
# Optimization flags
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
@@ -1157,6 +1436,8 @@ class HindsightConfig:
),
retain_mission=os.getenv(ENV_RETAIN_MISSION) or DEFAULT_RETAIN_MISSION,
retain_custom_instructions=os.getenv(ENV_RETAIN_CUSTOM_INSTRUCTIONS) or DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS,
retain_default_strategy=os.getenv(ENV_RETAIN_DEFAULT_STRATEGY) or DEFAULT_RETAIN_DEFAULT_STRATEGY,
retain_strategies=DEFAULT_RETAIN_STRATEGIES,
retain_batch_tokens=int(os.getenv(ENV_RETAIN_BATCH_TOKENS, str(DEFAULT_RETAIN_BATCH_TOKENS))),
retain_entity_lookup=os.getenv(ENV_RETAIN_ENTITY_LOOKUP, DEFAULT_RETAIN_ENTITY_LOOKUP),
retain_batch_enabled=os.getenv(ENV_RETAIN_BATCH_ENABLED, str(DEFAULT_RETAIN_BATCH_ENABLED)).lower()
@@ -1164,6 +1445,7 @@ class HindsightConfig:
retain_batch_poll_interval_seconds=int(
os.getenv(ENV_RETAIN_BATCH_POLL_INTERVAL_SECONDS, str(DEFAULT_RETAIN_BATCH_POLL_INTERVAL_SECONDS))
),
retain_chunk_batch_size=int(os.getenv(ENV_RETAIN_CHUNK_BATCH_SIZE, str(DEFAULT_RETAIN_CHUNK_BATCH_SIZE))),
# File storage
file_storage_type=os.getenv(ENV_FILE_STORAGE_TYPE, DEFAULT_FILE_STORAGE_TYPE),
file_storage_s3_bucket=os.getenv(ENV_FILE_STORAGE_S3_BUCKET) or None,
@@ -1223,6 +1505,9 @@ class HindsightConfig:
)
),
observations_mission=os.getenv(ENV_OBSERVATIONS_MISSION) or DEFAULT_OBSERVATIONS_MISSION,
max_observations_per_scope=int(
os.getenv(ENV_MAX_OBSERVATIONS_PER_SCOPE, str(DEFAULT_MAX_OBSERVATIONS_PER_SCOPE))
),
entity_labels=None,
entities_allow_free_form=True,
# Database migrations
@@ -1242,12 +1527,23 @@ class HindsightConfig:
worker_consolidation_max_slots=int(
os.getenv(ENV_WORKER_CONSOLIDATION_MAX_SLOTS, str(DEFAULT_WORKER_CONSOLIDATION_MAX_SLOTS))
),
retain_max_concurrent=int(os.getenv(ENV_RETAIN_MAX_CONCURRENT, str(DEFAULT_RETAIN_MAX_CONCURRENT))),
# Reflect agent settings
reflect_max_iterations=int(os.getenv(ENV_REFLECT_MAX_ITERATIONS, str(DEFAULT_REFLECT_MAX_ITERATIONS))),
reflect_max_context_tokens=int(
os.getenv(ENV_REFLECT_MAX_CONTEXT_TOKENS, str(DEFAULT_REFLECT_MAX_CONTEXT_TOKENS))
),
reflect_wall_timeout=int(os.getenv(ENV_REFLECT_WALL_TIMEOUT, str(DEFAULT_REFLECT_WALL_TIMEOUT))),
reflect_mission=os.getenv(ENV_REFLECT_MISSION) or None,
reflect_source_facts_max_tokens=int(
os.getenv(ENV_REFLECT_SOURCE_FACTS_MAX_TOKENS, str(DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS))
),
recall_include_chunks=os.getenv(ENV_RECALL_INCLUDE_CHUNKS, str(DEFAULT_RECALL_INCLUDE_CHUNKS)).lower()
in ("true", "1", "yes"),
recall_max_tokens=int(os.getenv(ENV_RECALL_MAX_TOKENS, str(DEFAULT_RECALL_MAX_TOKENS))),
recall_chunks_max_tokens=int(
os.getenv(ENV_RECALL_CHUNKS_MAX_TOKENS, str(DEFAULT_RECALL_CHUNKS_MAX_TOKENS))
),
# Disposition settings (None = fall back to DB value)
disposition_skepticism=int(os.getenv(ENV_DISPOSITION_SKEPTICISM))
if os.getenv(ENV_DISPOSITION_SKEPTICISM)
@@ -1265,6 +1561,16 @@ class HindsightConfig:
otel_exporter_otlp_headers=os.getenv(ENV_OTEL_EXPORTER_OTLP_HEADERS) or None,
otel_service_name=os.getenv(ENV_OTEL_SERVICE_NAME, DEFAULT_OTEL_SERVICE_NAME),
otel_deployment_environment=os.getenv(ENV_OTEL_DEPLOYMENT_ENVIRONMENT, DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT),
metrics_include_bank_id=os.getenv(ENV_METRICS_INCLUDE_BANK_ID, str(DEFAULT_METRICS_INCLUDE_BANK_ID)).lower()
in ("true", "1", "yes"),
# Audit log configuration (static, server-level only)
audit_log_enabled=os.getenv(ENV_AUDIT_LOG_ENABLED, str(DEFAULT_AUDIT_LOG_ENABLED)).lower() == "true",
audit_log_actions=[
a.strip() for a in os.getenv(ENV_AUDIT_LOG_ACTIONS, DEFAULT_AUDIT_LOG_ACTIONS).split(",") if a.strip()
],
audit_log_retention_days=int(
os.getenv(ENV_AUDIT_LOG_RETENTION_DAYS, str(DEFAULT_AUDIT_LOG_RETENTION_DAYS))
),
# Webhook configuration (static, server-level only)
webhook_url=os.getenv(ENV_WEBHOOK_URL) or DEFAULT_WEBHOOK_URL,
webhook_secret=os.getenv(ENV_WEBHOOK_SECRET) or DEFAULT_WEBHOOK_SECRET,
@@ -1334,9 +1640,14 @@ class HindsightConfig:
root_logger.addHandler(handler)
# Silence noisy third-party loggers
logging.getLogger("google_genai.models").setLevel(logging.WARNING)
def log_config(self) -> None:
"""Log the current configuration (without sensitive values)."""
logger.info(f"Database: {self.database_url} (schema: {self.database_schema})")
if self.migration_database_url:
logger.info(f"Migration database: {self.migration_database_url}")
logger.info(f"LLM: provider={self.llm_provider}, model={self.llm_model}")
if self.retain_llm_provider or self.retain_llm_model:
retain_provider = self.retain_llm_provider or self.llm_provider
@@ -10,7 +10,7 @@ multiple API servers.
import json
import logging
from dataclasses import asdict
from dataclasses import asdict, replace
from typing import Any
import asyncpg
@@ -239,6 +239,23 @@ class ConfigResolver:
logger.warning(f"Failed to check permissions for bank {bank_id}: {e}")
# Continue without permission check (fail open for backward compatibility)
# Validate entity_labels structure
if "entity_labels" in normalized_updates and normalized_updates["entity_labels"] is not None:
from .engine.retain.entity_labels import parse_entity_labels
try:
parse_entity_labels(normalized_updates["entity_labels"])
except Exception as e:
raise ValueError(f"Invalid entity_labels format: {e}")
# Validate retain_strategies: reject empty string keys
if "retain_strategies" in normalized_updates and normalized_updates["retain_strategies"]:
empty_keys = [k for k in normalized_updates["retain_strategies"] if not str(k).strip()]
if empty_keys:
raise ValueError(
"Strategy names must not be empty strings. Remove entries with empty names before saving."
)
# Merge with existing config (JSONB || operator)
async with self.pool.acquire() as conn:
await conn.execute(
@@ -273,3 +290,35 @@ class ConfigResolver:
)
logger.info(f"Reset bank config for {bank_id} to defaults")
def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConfig:
"""
Apply a named retain strategy's overrides on top of a resolved config.
A strategy is a named set of hierarchical field overrides stored in
config.retain_strategies. Any field in _HIERARCHICAL_FIELDS can be
overridden, including retain_extraction_mode, retain_chunk_size,
entity_labels, entities_allow_free_form, etc.
Unknown strategy names log a warning and return config unchanged.
Unknown or non-hierarchical fields in the strategy are silently ignored.
"""
strategies = config.retain_strategies or {}
if strategy_name not in strategies:
logger.warning(f"Unknown retain strategy '{strategy_name}', using resolved config as-is")
return config
overrides = strategies[strategy_name]
if not isinstance(overrides, dict):
logger.warning(f"Retain strategy '{strategy_name}' is not a dict, skipping")
return config
configurable = HindsightConfig.get_configurable_fields()
filtered = {k: v for k, v in overrides.items() if k in configurable}
if not filtered:
return config
logger.debug(f"Applying retain strategy '{strategy_name}': {list(filtered.keys())}")
return replace(config, **filtered)
@@ -0,0 +1,209 @@
"""Audit logging for feature usage tracking.
Provides fire-and-forget audit logging of all mutating and core operations
(retain, recall, reflect, bank CRUD, etc.) across HTTP, MCP, and system transports.
"""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from collections.abc import Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
import asyncpg
from ..engine.db_utils import acquire_with_retry
logger = logging.getLogger(__name__)
@dataclass
class AuditEntry:
"""A single audit log entry."""
action: str
transport: str # "http", "mcp", "system"
bank_id: str | None = None
started_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
ended_at: datetime | None = None
request: dict[str, Any] | None = None
response: dict[str, Any] | None = None
metadata: dict[str, Any] = field(default_factory=dict)
def _json_default(obj: Any) -> str:
"""JSON serializer for objects not serializable by default."""
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, uuid.UUID):
return str(obj)
if isinstance(obj, bytes):
return "<bytes>"
if isinstance(obj, set):
return list(obj)
return str(obj)
def _safe_json(data: Any) -> str | None:
"""Serialize data to JSON string, returning None on failure."""
if data is None:
return None
try:
return json.dumps(data, default=_json_default)
except Exception:
logger.debug("Failed to serialize audit data", exc_info=True)
return None
_SWEEP_INTERVAL_SECONDS = 3600 # Run retention sweep every hour
class AuditLogger:
"""Fire-and-forget audit log writer with optional retention sweep."""
def __init__(
self,
pool_getter: Callable[[], asyncpg.Pool | None],
schema_getter: Callable[[], str],
enabled: bool,
allowed_actions: list[str],
retention_days: int = -1,
) -> None:
self._pool_getter = pool_getter
self._schema_getter = schema_getter
self._enabled = enabled
self._allowed_actions: frozenset[str] | None = frozenset(allowed_actions) if allowed_actions else None
self._retention_days = retention_days
self._sweep_task: asyncio.Task | None = None
def is_enabled(self, action: str) -> bool:
"""Check if audit logging is enabled for this action."""
if not self._enabled:
return False
if self._allowed_actions is not None:
return action in self._allowed_actions
return True
def log_fire_and_forget(self, entry: AuditEntry) -> None:
"""Schedule an audit write as a background task."""
if not self.is_enabled(entry.action):
return
try:
asyncio.create_task(self._safe_log(entry))
except RuntimeError:
# No running event loop (e.g. during shutdown)
logger.debug("Cannot schedule audit log write: no running event loop")
async def _safe_log(self, entry: AuditEntry) -> None:
"""Write audit entry to DB. Errors are logged, never raised."""
pool = self._pool_getter()
if pool is None:
logger.debug("Audit log skipped: pool not available")
return
try:
schema = self._schema_getter()
table = f"{schema}.audit_log"
async with acquire_with_retry(pool, max_retries=1) as conn:
await conn.execute(
f"""
INSERT INTO {table}
(id, action, transport, bank_id, started_at, ended_at, request, response, metadata)
VALUES
($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9::jsonb)
""",
uuid.uuid4(),
entry.action,
entry.transport,
entry.bank_id,
entry.started_at,
entry.ended_at,
_safe_json(entry.request),
_safe_json(entry.response),
_safe_json(entry.metadata) or "{}",
)
except Exception as e:
logger.warning(f"Audit log write failed for action={entry.action}: {e}")
def start_retention_sweep(self) -> None:
"""Start the periodic retention sweep if retention is configured."""
if self._retention_days <= 0 or not self._enabled:
return
try:
self._sweep_task = asyncio.create_task(self._sweep_loop())
except RuntimeError:
logger.debug("Cannot start retention sweep: no running event loop")
async def stop_retention_sweep(self) -> None:
"""Stop the periodic retention sweep."""
if self._sweep_task and not self._sweep_task.done():
self._sweep_task.cancel()
try:
await self._sweep_task
except asyncio.CancelledError:
pass
self._sweep_task = None
async def _sweep_loop(self) -> None:
"""Periodically delete audit log entries older than retention_days."""
while True:
await self._run_sweep()
await asyncio.sleep(_SWEEP_INTERVAL_SECONDS)
async def _run_sweep(self) -> None:
"""Delete expired audit log entries. Concurrent-safe via row-level deletes."""
pool = self._pool_getter()
if pool is None:
return
try:
schema = self._schema_getter()
table = f"{schema}.audit_log"
async with acquire_with_retry(pool, max_retries=1) as conn:
result = await conn.execute(
f"DELETE FROM {table} WHERE started_at < NOW() - INTERVAL '{self._retention_days} days'"
)
if result and result != "DELETE 0":
logger.info(f"Audit log retention sweep: {result}")
except Exception as e:
logger.warning(f"Audit log retention sweep failed: {e}")
@asynccontextmanager
async def audit_context(
audit_logger: AuditLogger | None,
action: str,
transport: str,
bank_id: str | None = None,
request: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
):
"""Async context manager that times the operation and writes audit on exit.
Usage:
async with audit_context(logger, "retain", "http", bank_id, request_dict) as entry:
result = await do_work()
entry.response = result_dict
"""
if audit_logger is None or not audit_logger.is_enabled(action):
entry = AuditEntry(action=action, transport=transport, bank_id=bank_id)
yield entry
return
entry = AuditEntry(
action=action,
transport=transport,
bank_id=bank_id,
started_at=datetime.now(timezone.utc),
request=request,
metadata=metadata or {},
)
try:
yield entry
finally:
entry.ended_at = datetime.now(timezone.utc)
audit_logger.log_fire_and_forget(entry)
@@ -80,6 +80,7 @@ class _BatchLLMResult:
deletes: list[_DeleteAction] = field(default_factory=list)
obs_count: int = 0
prompt_chars: int = 0
failed: bool = False
@dataclass
@@ -118,6 +119,39 @@ def _aggregate_source_fields(source_mems: list[dict[str, Any]], tags: list[str]
)
async def _count_observations_for_scope(
conn: "Connection",
bank_id: str,
tags: list[str],
) -> int:
"""Count existing observations matching the given tag scope.
Returns the count of observations whose tags contain all specified tags.
Observations with no tags are not counted (the limit does not apply to them).
"""
return await conn.fetchval(
f"SELECT COUNT(*) FROM {fq_table('memory_units')} "
f"WHERE bank_id = $1 AND fact_type = 'observation' AND tags @> $2::varchar[]",
bank_id,
tags,
)
def _build_response_model(max_creates: int | None = None) -> type[_ConsolidationBatchResponse]:
"""Build a response model, optionally constraining max creates via JSON schema."""
if max_creates is None or max_creates < 0:
return _ConsolidationBatchResponse
from pydantic import Field as PydanticField
clamped = max(max_creates, 0)
class _ConstrainedConsolidationBatchResponse(_ConsolidationBatchResponse):
creates: list[_CreateAction] = PydanticField(default=[], max_length=clamped)
return _ConstrainedConsolidationBatchResponse
class ConsolidationPerfLog:
"""Performance logging for consolidation operations."""
@@ -219,6 +253,7 @@ async def run_consolidation_job(
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND consolidated_at IS NULL
AND consolidation_failed_at IS NULL
AND fact_type IN ('experience', 'world')
""",
bank_id,
@@ -240,6 +275,7 @@ async def run_consolidation_job(
"observations_deleted": 0,
"actions_executed": 0,
"skipped": 0,
"memories_failed": 0,
}
# Track all unique tags from consolidated memories for mental model refresh filtering
@@ -257,6 +293,7 @@ async def run_consolidation_job(
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND consolidated_at IS NULL
AND consolidation_failed_at IS NULL
AND fact_type IN ('experience', 'world')
ORDER BY created_at ASC
LIMIT $2
@@ -298,94 +335,141 @@ async def run_consolidation_job(
if memory_tags:
consolidated_tags.update(memory_tags)
async with pool.acquire() as conn:
# Determine observation_scopes for this batch. All memories in a batch share
# the same tags (enforced by tag_groups), so we only check the first memory.
# asyncpg returns JSONB columns as raw JSON strings, so parse if needed.
_obs_raw = llm_batch[0].get("observation_scopes") if llm_batch else None
_obs_parsed = json.loads(_obs_raw) if isinstance(_obs_raw, str) else _obs_raw
# Process llm_batch with adaptive splitting: on LLM failure, halve the sub-batch
# and retry, down to batch_size=1. Only if a single-memory batch still fails is
# the memory marked with consolidation_failed_at and excluded from future runs
# until explicitly retried via the API.
all_results: list[dict[str, Any]] = []
all_deleted = 0
succeeded_ids: list[Any] = []
failed_ids: list[Any] = []
# Resolve the scope spec into a concrete list[list[str]] (or None for combined).
if _obs_parsed == "per_tag":
_memory_tags = llm_batch[0].get("tags") or []
obs_tags_list = [[tag] for tag in _memory_tags] if _memory_tags else None
elif _obs_parsed == "all_combinations":
_memory_tags = llm_batch[0].get("tags") or []
obs_tags_list = (
[
list(combo)
for r in range(1, len(_memory_tags) + 1)
for combo in combinations(_memory_tags, r)
]
if _memory_tags
else None
)
elif _obs_parsed == "combined" or _obs_parsed is None:
obs_tags_list = None # single combined pass (default behaviour)
else:
# explicit list[list[str]]
obs_tags_list = _obs_parsed
pending: list[list[dict[str, Any]]] = [llm_batch]
while pending:
sub_batch = pending.pop(0)
batch_deleted: int = 0
if obs_tags_list:
# Multi-pass: run one observation consolidation pass per tag set
results = []
for obs_tags in obs_tags_list:
pass_results, pass_deleted = await _process_memory_batch(
async with pool.acquire() as conn:
# Determine observation_scopes for this sub-batch. All memories share
# the same tags (enforced by tag_groups), so we only check the first memory.
# asyncpg returns JSONB columns as raw JSON strings, so parse if needed.
_obs_raw = sub_batch[0].get("observation_scopes") if sub_batch else None
_obs_parsed = json.loads(_obs_raw) if isinstance(_obs_raw, str) else _obs_raw
# Resolve the scope spec into a concrete list[list[str]] (or None for combined).
if _obs_parsed == "per_tag":
_memory_tags = sub_batch[0].get("tags") or []
obs_tags_list = [[tag] for tag in _memory_tags] if _memory_tags else None
elif _obs_parsed == "all_combinations":
_memory_tags = sub_batch[0].get("tags") or []
obs_tags_list = (
[
list(combo)
for r in range(1, len(_memory_tags) + 1)
for combo in combinations(_memory_tags, r)
]
if _memory_tags
else None
)
elif _obs_parsed == "combined" or _obs_parsed is None:
obs_tags_list = None # single combined pass (default behaviour)
else:
# explicit list[list[str]]
obs_tags_list = _obs_parsed
sub_deleted: int = 0
sub_llm_failed = False
if obs_tags_list:
# Multi-pass: run one observation consolidation pass per tag set
sub_results: list[dict[str, Any]] = []
for obs_tags in obs_tags_list:
pass_results, pass_deleted, pass_failed = await _process_memory_batch(
conn=conn,
memory_engine=memory_engine,
llm_config=llm_config,
bank_id=bank_id,
memories=sub_batch,
request_context=request_context,
perf=perf,
config=config,
obs_tags_override=obs_tags,
)
sub_deleted += pass_deleted
sub_llm_failed = sub_llm_failed or pass_failed
# Merge results: prefer non-skipped actions
if not sub_results:
sub_results = pass_results
else:
for i, (existing, new) in enumerate(zip(sub_results, pass_results)):
if existing.get("action") == "skipped" and new.get("action") != "skipped":
sub_results[i] = new
elif existing.get("action") != "skipped" and new.get("action") != "skipped":
# Both did something — combine into "multiple"
existing_created = existing.get(
"created", 1 if existing.get("action") == "created" else 0
)
existing_updated = existing.get(
"updated", 1 if existing.get("action") == "updated" else 0
)
new_created = new.get("created", 1 if new.get("action") == "created" else 0)
new_updated = new.get("updated", 1 if new.get("action") == "updated" else 0)
total = existing_created + existing_updated + new_created + new_updated
sub_results[i] = {
"action": "multiple",
"created": existing_created + new_created,
"updated": existing_updated + new_updated,
"merged": 0,
"total_actions": total,
}
else:
# Normal single pass using the memory's own tags
sub_results, sub_deleted, sub_llm_failed = await _process_memory_batch(
conn=conn,
memory_engine=memory_engine,
llm_config=llm_config,
bank_id=bank_id,
memories=llm_batch,
memories=sub_batch,
request_context=request_context,
perf=perf,
config=config,
obs_tags_override=obs_tags,
)
batch_deleted += pass_deleted
# Merge results: prefer non-skipped actions
if not results:
results = pass_results
else:
for i, (existing, new) in enumerate(zip(results, pass_results)):
if existing.get("action") == "skipped" and new.get("action") != "skipped":
results[i] = new
elif existing.get("action") != "skipped" and new.get("action") != "skipped":
# Both did something — combine into "multiple"
existing_created = existing.get(
"created", 1 if existing.get("action") == "created" else 0
)
existing_updated = existing.get(
"updated", 1 if existing.get("action") == "updated" else 0
)
new_created = new.get("created", 1 if new.get("action") == "created" else 0)
new_updated = new.get("updated", 1 if new.get("action") == "updated" else 0)
total = existing_created + existing_updated + new_created + new_updated
results[i] = {
"action": "multiple",
"created": existing_created + new_created,
"updated": existing_updated + new_updated,
"merged": 0,
"total_actions": total,
}
else:
# Normal single pass using the memory's own tags
results, batch_deleted = await _process_memory_batch(
conn=conn,
memory_engine=memory_engine,
llm_config=llm_config,
bank_id=bank_id,
memories=llm_batch,
request_context=request_context,
perf=perf,
config=config,
)
stats["observations_deleted"] += batch_deleted
await conn.executemany(
f"UPDATE {fq_table('memory_units')} SET consolidated_at = NOW() WHERE id = $1",
[(m["id"],) for m in llm_batch],
)
all_deleted += sub_deleted
if sub_llm_failed and len(sub_batch) > 1:
# Split and retry with smaller batches
mid = len(sub_batch) // 2
logger.warning(
f"[CONSOLIDATION] bank={bank_id} LLM failed for sub-batch of {len(sub_batch)},"
f" splitting into {mid}/{len(sub_batch) - mid}"
)
pending[0:0] = [sub_batch[:mid], sub_batch[mid:]]
elif sub_llm_failed:
# batch_size=1 and still failing — mark as permanently failed for now
failed_ids.append(sub_batch[0]["id"])
all_results.append({"action": "failed"})
logger.warning(
f"[CONSOLIDATION] bank={bank_id} LLM failed for single memory"
f" {sub_batch[0]['id']}, marking consolidation_failed_at"
)
else:
succeeded_ids.extend(m["id"] for m in sub_batch)
all_results.extend(sub_results)
# Commit consolidated_at / consolidation_failed_at in a single DB round-trip
async with pool.acquire() as conn:
if succeeded_ids:
await conn.executemany(
f"UPDATE {fq_table('memory_units')} SET consolidated_at = NOW() WHERE id = $1",
[(mem_id,) for mem_id in succeeded_ids],
)
if failed_ids:
await conn.executemany(
f"UPDATE {fq_table('memory_units')} SET consolidation_failed_at = NOW() WHERE id = $1",
[(mem_id,) for mem_id in failed_ids],
)
stats["observations_deleted"] += all_deleted
results = all_results
# Checkpoint: abort if the operation (and thus the bank) was deleted mid-run.
if operation_id and not await memory_engine._check_op_alive(operation_id):
@@ -413,6 +497,8 @@ async def run_consolidation_job(
stats["actions_executed"] += result.get("total_actions", 0)
elif action == "skipped":
stats["skipped"] += 1
elif action == "failed":
stats["memories_failed"] += 1
# Per-LLM-batch log
llm_batch_time = time.time() - llm_batch_start
@@ -425,6 +511,7 @@ async def run_consolidation_job(
batch_created = stats["observations_created"] - snap_stats["observations_created"]
batch_updated = stats["observations_updated"] - snap_stats["observations_updated"]
batch_skipped = stats["skipped"] - snap_stats["skipped"]
batch_failed = stats["memories_failed"] - snap_stats["memories_failed"]
llm_calls_made = perf.llm_calls - snap_llm_calls
logger.info(
f"[CONSOLIDATION] bank={bank_id} llm_batch #{llm_batch_num}"
@@ -432,7 +519,8 @@ async def run_consolidation_job(
f" | {stats['memories_processed']}/{total_count} processed"
f" | {', '.join(timing_parts)}"
f" | created={batch_created} updated={batch_updated} skipped={batch_skipped}"
f" | input_tokens=~{input_tokens}"
+ (f" failed={batch_failed}" if batch_failed else "")
+ f" | input_tokens=~{input_tokens}"
f" | avg={llm_batch_time / len(llm_batch):.3f}s/memory"
)
@@ -584,7 +672,7 @@ async def _process_memory_batch(
perf: ConsolidationPerfLog | None = None,
config: Any = None,
obs_tags_override: list[str] | None = None,
) -> tuple[list[dict[str, Any]], int]:
) -> tuple[list[dict[str, Any]], int, bool]:
"""
Process a batch of memories in a single LLM call.
@@ -643,24 +731,6 @@ async def _process_memory_batch(
if recall_result.source_facts:
union_source_facts.update(recall_result.source_facts)
# 3. Single LLM call
t0 = time.time()
llm_result = await _consolidate_batch_with_llm(
llm_config=llm_config,
memories=memories,
union_observations=union_observations,
union_source_facts=union_source_facts,
config=config,
)
if perf:
perf.record_timing("llm", time.time() - t0)
perf.record_llm_call(llm_result.obs_count, llm_result.prompt_chars)
# 4. Sequential execution of creates / updates / deletes
# Track which memory indices participated so we can build per-memory results for stats
per_memory_created: set[str] = set()
per_memory_updated: set[str] = set()
# Determine effective tag scope for observations.
# When obs_tags_override is set, use it; otherwise use the memory's own tags.
if obs_tags_override is not None:
@@ -669,28 +739,52 @@ async def _process_memory_batch(
# All memories in the batch share the same tag set (enforced by batching)
fact_tags = memories[0].get("tags") or [] if memories else []
# 2b. Compute remaining observation slots for this scope (if limit configured)
max_obs = config.max_observations_per_scope if config is not None else -1
remaining_observation_slots: int | None = None
if max_obs > 0 and fact_tags:
current_count = await _count_observations_for_scope(conn, bank_id, fact_tags)
remaining_observation_slots = max(max_obs - current_count, 0)
if remaining_observation_slots == 0:
logger.info(
f"[CONSOLIDATION] bank={bank_id} scope={fact_tags} at observation limit "
f"({current_count}/{max_obs}), only updates/deletes allowed"
)
# 3. Single LLM call
t0 = time.time()
llm_result = await _consolidate_batch_with_llm(
llm_config=llm_config,
memories=memories,
union_observations=union_observations,
union_source_facts=union_source_facts,
config=config,
remaining_observation_slots=remaining_observation_slots,
max_observations_per_scope=max_obs,
)
if perf:
perf.record_timing("llm", time.time() - t0)
perf.record_llm_call(llm_result.obs_count, llm_result.prompt_chars)
# 4. Sequential execution of deletes / updates / creates
# Deletes run first to free observation slots before creates consume them.
# Track which memory indices participated so we can build per-memory results for stats
per_memory_created: set[str] = set()
per_memory_updated: set[str] = set()
mem_by_id = {str(m["id"]): m for m in memories}
for create in llm_result.creates:
source_mems = [mem_by_id[fid] for fid in create.source_fact_ids if fid in mem_by_id]
if not source_mems:
# Execute deletes first to free observation slots before creates consume them
deleted_count = 0
for delete in llm_result.deletes:
# Security: the observation must be present in the unioned recall
if not any(str(obs.id) == delete.observation_id for obs in union_observations):
logger.debug(
f"Batch consolidation: rejected delete — observation {delete.observation_id} not in unioned recall"
)
continue
agg = _aggregate_source_fields(source_mems, tags=fact_tags)
await _execute_create_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
source_memory_ids=[m["id"] for m in source_mems],
text=create.text,
source_fact_tags=agg.tags,
event_date=agg.event_date,
occurred_start=agg.occurred_start,
occurred_end=agg.occurred_end,
mentioned_at=agg.mentioned_at,
perf=perf,
)
for m in source_mems:
per_memory_created.add(str(m["id"]))
await _execute_delete_action(conn=conn, bank_id=bank_id, observation_id=delete.observation_id)
deleted_count += 1
for update in llm_result.updates:
source_mems = [mem_by_id[fid] for fid in update.source_fact_ids if fid in mem_by_id]
@@ -721,16 +815,26 @@ async def _process_memory_batch(
for m in source_mems:
per_memory_updated.add(str(m["id"]))
deleted_count = 0
for delete in llm_result.deletes:
# Security: the observation must be present in the unioned recall
if not any(str(obs.id) == delete.observation_id for obs in union_observations):
logger.debug(
f"Batch consolidation: rejected delete — observation {delete.observation_id} not in unioned recall"
)
for create in llm_result.creates:
source_mems = [mem_by_id[fid] for fid in create.source_fact_ids if fid in mem_by_id]
if not source_mems:
continue
await _execute_delete_action(conn=conn, bank_id=bank_id, observation_id=delete.observation_id)
deleted_count += 1
agg = _aggregate_source_fields(source_mems, tags=fact_tags)
await _execute_create_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
source_memory_ids=[m["id"] for m in source_mems],
text=create.text,
source_fact_tags=agg.tags,
event_date=agg.event_date,
occurred_start=agg.occurred_start,
occurred_end=agg.occurred_end,
mentioned_at=agg.mentioned_at,
perf=perf,
)
for m in source_mems:
per_memory_created.add(str(m["id"]))
# Build per-memory result dicts for the stats tracker in the outer loop
results: list[dict[str, Any]] = []
@@ -747,7 +851,7 @@ async def _process_memory_batch(
else:
results.append({"action": "skipped", "reason": "no_durable_knowledge"})
return results, deleted_count
return results, deleted_count, llm_result.failed
def _min_date(dates: "Any") -> "datetime | None":
@@ -1028,6 +1132,8 @@ async def _consolidate_batch_with_llm(
union_observations: "list[MemoryFact]",
union_source_facts: "dict[str, MemoryFact]",
config: Any = None,
remaining_observation_slots: int | None = None,
max_observations_per_scope: int = -1,
) -> _BatchLLMResult:
"""Single LLM call for a batch of facts against a pooled set of observations."""
if union_observations:
@@ -1051,24 +1157,51 @@ async def _consolidate_batch_with_llm(
facts_lines = "\n".join(_fact_line(m) for m in memories)
# Build capacity note for the prompt when observation limit is configured
observation_capacity_note: str | None = None
if remaining_observation_slots is not None and max_observations_per_scope > 0:
if remaining_observation_slots == 0:
observation_capacity_note = (
f"OBSERVATION LIMIT REACHED ({max_observations_per_scope}/{max_observations_per_scope}). "
"Only UPDATE or DELETE existing observations. Do NOT create new ones — "
"merge new knowledge into existing observations via UPDATE."
)
elif remaining_observation_slots <= len(memories):
observation_capacity_note = (
f"This scope has {remaining_observation_slots} observation slot(s) remaining "
f"(out of {max_observations_per_scope}). Prefer UPDATE over CREATE when possible."
)
observations_mission = config.observations_mission if config is not None else None
prompt_template = build_batch_consolidation_prompt(observations_mission)
prompt_template = build_batch_consolidation_prompt(observations_mission, observation_capacity_note)
prompt = prompt_template.format(
facts_text=facts_lines,
observations_text=observations_text,
)
# Use a constrained response model when observation limit is active
response_model = _build_response_model(max_creates=remaining_observation_slots)
max_attempts = 3
last_exc: Exception | None = None
for attempt in range(1, max_attempts + 1):
try:
response: _ConsolidationBatchResponse = await llm_config.call(
messages=[{"role": "user", "content": prompt}],
response_format=_ConsolidationBatchResponse,
response_format=response_model,
scope="consolidation",
)
# Defensive truncation: some LLM providers may not enforce JSON schema max_length
creates = response.creates
if remaining_observation_slots is not None and remaining_observation_slots >= 0:
if len(creates) > remaining_observation_slots:
logger.info(
f"[CONSOLIDATION] Truncating {len(creates)} creates to {remaining_observation_slots} "
f"(max_observations_per_scope={max_observations_per_scope})"
)
creates = creates[:remaining_observation_slots]
return _BatchLLMResult(
creates=response.creates,
creates=creates,
updates=response.updates,
deletes=response.deletes,
obs_count=len(union_observations),
@@ -1081,7 +1214,7 @@ async def _consolidate_batch_with_llm(
logger.error(
f"[CONSOLIDATION] LLM batch call failed after {max_attempts} attempts, skipping batch. Last error: {last_exc}"
)
return _BatchLLMResult(obs_count=len(union_observations), prompt_chars=len(prompt))
return _BatchLLMResult(obs_count=len(union_observations), prompt_chars=len(prompt), failed=True)
async def _create_observation_directly(
@@ -5,10 +5,24 @@ _DEFAULT_MISSION = "Track every detail: names, numbers, dates, places, and relat
# Processing rules — always present regardless of mission
_PROCESSING_RULES = """Processing rules (always apply):
- REDUNDANT: same info worded differently → UPDATE the existing observation.
- CONTRADICTION/UPDATE: capture both states with temporal markers ("used to X, now Y").
- RESOLVE REFERENCES: when a new fact provides a concrete value resolving a vague placeholder in an existing observation (e.g. "home country", "hometown", "birthplace", "native language", "her ex", "that city"), UPDATE the observation to embed the resolved value explicitly. Example: new fact says "grandma in Sweden" + existing observation says "moved from her home country" → update to "home country is Sweden".
- NEVER merge observations about different people or unrelated topics."""
1. ONE OBSERVATION PER DISTINCT FACET: each observation tracks exactly one specific facet — a count ("has 3 items"), a named entity ("has a dog named Rex"), a relationship ("works at Google"), etc. Never merge different facets into one observation.
2. MATCH BY ENTITY/FACET, NOT TOPIC: when deciding whether to UPDATE vs CREATE, match on the specific entity or facet. "Sold item X" updates only the X observation. "Now has 5 items" updates only the count observation. Do not update observations about different entities just because they share a general topic.
3. STATE CHANGES — UPDATE CONCISELY: when a fact changes the state of something ("sold X", "X died", "moved to Y"), UPDATE the matching observation to reflect the current state. Include dates when available. Keep it concise — only information about THAT specific facet. Example: "User owned a dog named Rex who died on March 15, 2025". Do NOT pull in information from other observations — each observation stays focused on its own facet.
4. CASCADE TO ALL AFFECTED OBSERVATIONS: a state change may affect multiple observations. For example, if entity C is removed from a group, update BOTH the individual observation for C AND any list/group observation that includes C (remove C from the list while keeping all other members intact).
5. NO COMPUTATION: you do not have the full picture — never calculate, derive, or adjust numeric values. If the user says "I have 2 dogs" and then "I have a dog named Rex", do NOT update the count to 3 — you don't know if Rex is one of the 2 or a new one. If the user says "I sold X", do NOT decrement a count. Only update a count when the user explicitly states a new count. Synthesize and consolidate what was stated, but never do arithmetic or logical deductions.
6. SAME FACET → UPDATE, NOT CREATE: a new count supersedes the old count — UPDATE the existing count observation, don't create a second one. If there's an existing observation for the same specific facet, always UPDATE it rather than creating a duplicate.
7. PRESERVE HISTORY: observations that record significant events (sold, died, moved, changed) are important history — never DELETE them. Only delete an observation when it is restated identically or truly meaningless. Be very conservative with deletes.
8. RESOLVE REFERENCES: when a new fact provides a concrete value for a vague placeholder in an existing observation (e.g., "home country""Sweden"), UPDATE to embed the resolved value.
9. NEVER merge observations about different people or unrelated topics."""
# Data section — format placeholders {facts_text} and {observations_text} are substituted at call time
_BATCH_DATA_SECTION = """
@@ -26,8 +40,8 @@ Each observation includes:
- source_memories: array of supporting facts with their text and dates
Compare the facts against existing observations:
- Same topic as an existing observation → UPDATE it (observation_id + source_fact_ids)
- New topic with durable knowledge → CREATE a new observation (source_fact_ids)
- Same facet as an existing observation → UPDATE it (observation_id + source_fact_ids)
- New facet with durable knowledge → CREATE a new observation (source_fact_ids)
- Cross-reference facts within the batch: a later fact may resolve a vague reference in an earlier one
- Purely ephemeral facts → omit them unless the MISSION above explicitly targets such data (e.g. timestamped events, session state, screen content)"""
@@ -66,7 +80,10 @@ Rules:
- Return {{"creates": [], "updates": [], "deletes": []}} if nothing durable is found."""
def build_batch_consolidation_prompt(observations_mission: str | None = None) -> str:
def build_batch_consolidation_prompt(
observations_mission: str | None = None,
observation_capacity_note: str | None = None,
) -> str:
"""
Build the consolidation prompt for batch mode (multiple facts per LLM call).
@@ -75,9 +92,13 @@ def build_batch_consolidation_prompt(observations_mission: str | None = None) ->
"""
mission = observations_mission or _DEFAULT_MISSION
capacity_section = ""
if observation_capacity_note:
capacity_section = f"\n\n## CAPACITY CONSTRAINT\n{observation_capacity_note}"
return (
"You are a memory consolidation system. Synthesize facts into observations "
"and merge with existing observations when appropriate.\n\n"
f"## MISSION\n{mission}\n\n"
f"## MISSION\n{mission}{capacity_section}\n\n"
f"{_PROCESSING_RULES}" + _BATCH_DATA_SECTION + _BATCH_OUTPUT_FORMAT
)
@@ -20,14 +20,18 @@ from ..config import (
DEFAULT_RERANKER_COHERE_MODEL,
DEFAULT_RERANKER_FLASHRANK_CACHE_DIR,
DEFAULT_RERANKER_FLASHRANK_MODEL,
DEFAULT_RERANKER_GOOGLE_MODEL,
DEFAULT_RERANKER_LITELLM_MAX_TOKENS_PER_DOC,
DEFAULT_RERANKER_LITELLM_MODEL,
DEFAULT_RERANKER_LITELLM_SDK_MODEL,
DEFAULT_RERANKER_LOCAL_BATCH_SIZE,
DEFAULT_RERANKER_LOCAL_FORCE_CPU,
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT,
DEFAULT_RERANKER_LOCAL_MODEL,
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE,
DEFAULT_RERANKER_PROVIDER,
DEFAULT_RERANKER_SILICONFLOW_BASE_URL,
DEFAULT_RERANKER_SILICONFLOW_MODEL,
DEFAULT_RERANKER_TEI_BATCH_SIZE,
DEFAULT_RERANKER_TEI_MAX_CONCURRENT,
DEFAULT_RERANKER_ZEROENTROPY_MODEL,
@@ -35,12 +39,14 @@ from ..config import (
ENV_RERANKER_COHERE_MODEL,
ENV_RERANKER_FLASHRANK_CACHE_DIR,
ENV_RERANKER_FLASHRANK_MODEL,
ENV_RERANKER_GOOGLE_PROJECT_ID,
ENV_RERANKER_LITELLM_SDK_API_KEY,
ENV_RERANKER_LOCAL_FORCE_CPU,
ENV_RERANKER_LOCAL_MAX_CONCURRENT,
ENV_RERANKER_LOCAL_MODEL,
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE,
ENV_RERANKER_PROVIDER,
ENV_RERANKER_SILICONFLOW_API_KEY,
ENV_RERANKER_TEI_BATCH_SIZE,
ENV_RERANKER_TEI_MAX_CONCURRENT,
ENV_RERANKER_TEI_URL,
@@ -111,6 +117,9 @@ class LocalSTCrossEncoder(CrossEncoderModel):
max_concurrent: int = 4,
force_cpu: bool = False,
trust_remote_code: bool = False,
fp16: bool = False,
bucket_batching: bool = False,
batch_size: int = DEFAULT_RERANKER_LOCAL_BATCH_SIZE,
):
"""
Initialize local SentenceTransformers cross-encoder.
@@ -125,10 +134,20 @@ class LocalSTCrossEncoder(CrossEncoderModel):
trust_remote_code: Allow loading models with custom code (security risk).
Required for some models like jina-reranker-v2-base-multilingual.
Default: False (disabled for security)
fp16: Use FP16 (half precision) inference. Faster on MPS and CUDA,
may be slower on CPU. Default: False (opt-in via env var).
bucket_batching: Sort pairs by token length before batching to reduce
padding waste. 36-54% speedup, quality-identical.
Default: False (opt-in via env var).
batch_size: Batch size for predict() calls. Optimal values vary by
hardware and model (MPS: 32, CUDA: 128+). Default: 32.
"""
self.model_name = model_name or DEFAULT_RERANKER_LOCAL_MODEL
self.force_cpu = force_cpu
self.trust_remote_code = trust_remote_code
self.fp16 = fp16
self.bucket_batching = bucket_batching
self.batch_size = batch_size
self._model = None
LocalSTCrossEncoder._max_concurrent = max_concurrent
@@ -176,6 +195,24 @@ class LocalSTCrossEncoder(CrossEncoderModel):
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
# Patch transformers 5.x compatibility for models using XLM-RoBERTa
# (e.g., jina-reranker-v2-base-multilingual). transformers 5.x removed
# create_position_ids_from_input_ids as a module-level function; the custom
# code in these models still references it. This monkey-patch restores it.
try:
import transformers.models.xlm_roberta.modeling_xlm_roberta as xlm_module
from transformers.models.xlm_roberta.modeling_xlm_roberta import XLMRobertaEmbeddings
if not hasattr(xlm_module, "create_position_ids_from_input_ids"):
setattr(
xlm_module,
"create_position_ids_from_input_ids",
XLMRobertaEmbeddings.create_position_ids_from_input_ids,
)
logger.info("Reranker: applied transformers 5.x compatibility patch for XLM-RoBERTa")
except Exception:
pass
# Suppress verbose transformers warnings during model loading
# This suppresses the "UNEXPECTED" warnings from CrossEncoder which are harmless
# but look alarming to users (e.g., "embeddings.position_ids | UNEXPECTED")
@@ -200,6 +237,12 @@ class LocalSTCrossEncoder(CrossEncoderModel):
# Restore original logging level
transformers_logger.setLevel(original_level)
# FP16 inference: convert model weights to half precision.
# Empirically validated: 27-36% faster on MPS, quality-identical (20/20 overlap).
if self.fp16 and device != "cpu":
self._model.model.half()
logger.info("Reranker: FP16 inference enabled")
# Initialize shared executor (limited workers naturally limits concurrency)
if LocalSTCrossEncoder._executor is None:
LocalSTCrossEncoder._executor = ThreadPoolExecutor(
@@ -211,8 +254,32 @@ class LocalSTCrossEncoder(CrossEncoderModel):
logger.info("Reranker: local provider initialized (using existing executor)")
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Synchronous prediction wrapper for thread pool execution."""
scores = self._model.predict(pairs, show_progress_bar=False)
"""Synchronous prediction wrapper for thread pool execution.
Supports two optimizations (controlled via .env):
- bucket_batching: sort pairs by token length to reduce padding waste (36-54% speedup)
- batch_size: explicit batch size for predict() calls (MPS optimal: 32)
"""
import numpy as np
if self.bucket_batching and len(pairs) > 1:
# Sort pairs by approximate token length to create homogeneous batches.
# This eliminates padding waste — short pairs aren't padded to the length
# of the longest pair in the batch. Quality-identical by construction.
lengths = [len(pairs[i][0]) + len(pairs[i][1]) for i in range(len(pairs))]
sorted_indices = sorted(range(len(pairs)), key=lambda i: lengths[i])
sorted_pairs = [pairs[i] for i in sorted_indices]
sorted_scores = self._model.predict(sorted_pairs, batch_size=self.batch_size, show_progress_bar=False)
sorted_scores = sorted_scores.tolist() if hasattr(sorted_scores, "tolist") else list(sorted_scores)
# Restore original order
scores = [0.0] * len(pairs)
for new_pos, orig_idx in enumerate(sorted_indices):
scores[orig_idx] = sorted_scores[new_pos]
return scores
scores = self._model.predict(pairs, batch_size=self.batch_size, show_progress_bar=False)
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
@@ -454,6 +521,84 @@ class RemoteTEICrossEncoder(CrossEncoderModel):
return await self._predict_async(pairs)
class _CohereCompatibleRerankClient:
"""
Internal HTTP client for Cohere-compatible /rerank endpoints.
Shared by all providers that speak the Cohere rerank wire format —
{model, query, documents[, top_n]} request and
{results: [{index, relevance_score}, ...]} response. This covers
SiliconFlow, ZeroEntropy, Jina, Voyage, BGE self-hosted, and Cohere
itself when reached via a custom base_url (e.g. Azure AI Foundry).
Not a CrossEncoderModel — providers compose it and expose their own
provider_name / initialization logging.
"""
def __init__(
self,
api_key: str,
model: str,
rerank_url: str,
timeout: float = 60.0,
include_top_n: bool = True,
):
self.api_key = api_key
self.model = model
self.rerank_url = rerank_url
self.timeout = timeout
self.include_top_n = include_top_n
self._async_client: httpx.AsyncClient | None = None
async def initialize(self) -> None:
if self._async_client is not None:
return
self._async_client = httpx.AsyncClient(
timeout=self.timeout,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
)
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
if self._async_client is None:
raise RuntimeError("Reranker not initialized. Call initialize() first.")
if not pairs:
return []
query_groups: dict[str, list[tuple[int, str]]] = {}
for idx, (query, text) in enumerate(pairs):
query_groups.setdefault(query, []).append((idx, text))
all_scores = [0.0] * len(pairs)
for query, indexed_texts in query_groups.items():
texts = [text for _, text in indexed_texts]
indices = [idx for idx, _ in indexed_texts]
body: dict[str, object] = {
"model": self.model,
"query": query,
"documents": texts,
"return_documents": False,
}
if self.include_top_n:
body["top_n"] = len(texts)
response = await self._async_client.post(self.rerank_url, json=body)
response.raise_for_status()
result = response.json()
for item in result.get("results", []):
original_idx = item["index"]
score = item["relevance_score"]
all_scores[indices[original_idx]] = score
return all_scores
class CohereCrossEncoder(CrossEncoderModel):
"""
Cohere cross-encoder implementation using the Cohere Rerank API.
@@ -482,6 +627,20 @@ class CohereCrossEncoder(CrossEncoderModel):
self.base_url = base_url
self.timeout = timeout
self._client = None
# Used when base_url is set (Azure AI Foundry and other Cohere-compatible hosts).
# Azure endpoints already include the full invoke path, so rerank_url == base_url
# and top_n is omitted to match the existing Azure contract.
self._http_client: _CohereCompatibleRerankClient | None = (
_CohereCompatibleRerankClient(
api_key=api_key,
model=model,
rerank_url=base_url,
timeout=timeout,
include_top_n=False,
)
if base_url
else None
)
@property
def provider_name(self) -> str:
@@ -489,23 +648,24 @@ class CohereCrossEncoder(CrossEncoderModel):
async def initialize(self) -> None:
"""Initialize the Cohere client."""
if self._client is not None:
if self._client is not None or (self._http_client and self._http_client._async_client):
return
try:
import cohere
except ImportError:
raise ImportError("cohere is required for CohereCrossEncoder. Install it with: pip install cohere")
base_url_msg = f" at {self.base_url}" if self.base_url else ""
logger.info(f"Reranker: initializing Cohere provider with model {self.model}{base_url_msg}")
# Build client kwargs, only including base_url if set (for Azure or custom endpoints)
client_kwargs = {"api_key": self.api_key, "timeout": self.timeout}
if self.base_url:
client_kwargs["base_url"] = self.base_url
self._client = cohere.Client(**client_kwargs)
logger.info("Reranker: Cohere provider initialized")
if self._http_client is not None:
await self._http_client.initialize()
logger.info("Reranker: Cohere provider initialized (Cohere-compatible HTTP endpoint)")
else:
# For native Cohere API, use the official SDK
try:
import cohere
except ImportError:
raise ImportError("cohere is required for CohereCrossEncoder. Install it with: pip install cohere")
self._client = cohere.Client(api_key=self.api_key, timeout=self.timeout)
logger.info("Reranker: Cohere provider initialized")
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
@@ -517,25 +677,24 @@ class CohereCrossEncoder(CrossEncoderModel):
Returns:
List of relevance scores
"""
if self._client is None:
if self._client is None and self._http_client is None:
raise RuntimeError("Reranker not initialized. Call initialize() first.")
if not pairs:
return []
# Run sync Cohere API calls in thread pool
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._predict_sync, pairs)
if self._http_client is not None:
return await self._http_client.predict(pairs)
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Synchronous predict implementation for Cohere API."""
# Group pairs by query for efficient batching
# Cohere rerank expects one query with multiple documents
# Run sync Cohere SDK calls in thread pool
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._predict_sync_sdk, pairs)
def _predict_sync_sdk(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Synchronous predict using the native Cohere SDK."""
query_groups: dict[str, list[tuple[int, str]]] = {}
for idx, (query, text) in enumerate(pairs):
if query not in query_groups:
query_groups[query] = []
query_groups[query].append((idx, text))
query_groups.setdefault(query, []).append((idx, text))
all_scores = [0.0] * len(pairs)
@@ -550,7 +709,6 @@ class CohereCrossEncoder(CrossEncoderModel):
return_documents=False,
)
# Map scores back to original positions
for result in response.results:
original_idx = result.index
score = result.relevance_score
@@ -567,94 +725,80 @@ class ZeroEntropyCrossEncoder(CrossEncoderModel):
See: https://docs.zeroentropy.dev/models
"""
RERANK_URL = "https://api.zeroentropy.dev/v1/models/rerank"
DEFAULT_BASE_URL = "https://api.zeroentropy.dev"
RERANK_PATH = "/v1/models/rerank"
def __init__(
self,
api_key: str,
model: str = DEFAULT_RERANKER_ZEROENTROPY_MODEL,
base_url: str | None = None,
timeout: float = 60.0,
):
"""
Initialize ZeroEntropy cross-encoder client.
Args:
api_key: ZeroEntropy API key
model: ZeroEntropy rerank model name (default: zerank-2)
timeout: Request timeout in seconds (default: 60.0)
"""
self.api_key = api_key
self.model = model
self.timeout = timeout
self._async_client: httpx.AsyncClient | None = None
self.base_url = base_url.rstrip("/") if base_url else self.DEFAULT_BASE_URL
self._client = _CohereCompatibleRerankClient(
api_key=api_key,
model=model,
rerank_url=f"{self.base_url}{self.RERANK_PATH}",
timeout=timeout,
)
@property
def provider_name(self) -> str:
return "zeroentropy"
async def initialize(self) -> None:
"""Initialize the async HTTP client."""
if self._async_client is not None:
if self._client._async_client is not None:
return
logger.info(f"Reranker: initializing ZeroEntropy provider with model {self.model}")
self._async_client = httpx.AsyncClient(
timeout=self.timeout,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
)
await self._client.initialize()
logger.info("Reranker: ZeroEntropy provider initialized")
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
Score query-document pairs using the ZeroEntropy Rerank API.
return await self._client.predict(pairs)
Args:
pairs: List of (query, document) tuples to score
Returns:
List of relevance scores
"""
if self._async_client is None:
raise RuntimeError("Reranker not initialized. Call initialize() first.")
class SiliconFlowCrossEncoder(CrossEncoderModel):
"""
SiliconFlow cross-encoder implementation.
if not pairs:
return []
SiliconFlow (https://siliconflow.cn) exposes a Cohere-compatible /rerank
endpoint. Shares the HTTP client with ZeroEntropy/Cohere-custom-endpoint
via _CohereCompatibleRerankClient.
"""
# Group pairs by query for efficient batching
query_groups: dict[str, list[tuple[int, str]]] = {}
for idx, (query, text) in enumerate(pairs):
if query not in query_groups:
query_groups[query] = []
query_groups[query].append((idx, text))
RERANK_PATH = "/rerank"
all_scores = [0.0] * len(pairs)
def __init__(
self,
api_key: str,
model: str = DEFAULT_RERANKER_SILICONFLOW_MODEL,
base_url: str = DEFAULT_RERANKER_SILICONFLOW_BASE_URL,
timeout: float = 60.0,
):
self.model = model
self.base_url = base_url.rstrip("/")
self._client = _CohereCompatibleRerankClient(
api_key=api_key,
model=model,
rerank_url=f"{self.base_url}{self.RERANK_PATH}",
timeout=timeout,
)
for query, indexed_texts in query_groups.items():
texts = [text for _, text in indexed_texts]
indices = [idx for idx, _ in indexed_texts]
@property
def provider_name(self) -> str:
return "siliconflow"
response = await self._async_client.post(
self.RERANK_URL,
json={
"model": self.model,
"query": query,
"documents": texts,
"top_n": len(texts),
},
)
response.raise_for_status()
result = response.json()
async def initialize(self) -> None:
if self._client._async_client is not None:
return
logger.info(f"Reranker: initializing SiliconFlow provider at {self.base_url} with model {self.model}")
await self._client.initialize()
logger.info("Reranker: SiliconFlow provider initialized")
# Map scores back to original positions
for item in result.get("results", []):
original_idx = item["index"]
score = item["relevance_score"]
all_scores[indices[original_idx]] = score
return all_scores
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
return await self._client.predict(pairs)
class RRFPassthroughCrossEncoder(CrossEncoderModel):
@@ -1106,14 +1250,31 @@ class JinaMLXCrossEncoder(CrossEncoderModel):
if self._reranker is not None:
return
# Pre-warm transformers.AutoTokenizer to fully populate the transformers
# namespace before mlx_lm imports it. transformers 5.x uses _LazyModule,
# which has an unguarded window where `from transformers import AutoTokenizer`
# raises ImportError if another thread is concurrently initializing the
# namespace (e.g. embeddings init in an executor thread).
# See: https://github.com/vectorize-io/hindsight/issues/994
import transformers
_ = transformers.AutoTokenizer
try:
import mlx.core # noqa: F401
import mlx_lm # noqa: F401
except ImportError:
except ImportError as exc:
# Only swallow "package not installed" errors. Anything else (e.g. a
# transitive import failure inside mlx_lm) must surface verbatim so
# the real cause is debuggable instead of being masked by a generic
# "install mlx" message.
msg = str(exc)
if "mlx" not in msg and "mlx_lm" not in msg:
raise
raise ImportError(
"mlx and mlx-lm are required for JinaMLXCrossEncoder. "
"Install with: pip install mlx>=0.31.0 mlx-lm>=0.31.1 safetensors>=0.6.2"
)
) from exc
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._load_model)
@@ -1167,6 +1328,164 @@ class JinaMLXCrossEncoder(CrossEncoderModel):
return await loop.run_in_executor(None, self._predict_sync, pairs)
class GoogleCrossEncoder(CrossEncoderModel):
"""
Google Discovery Engine cross-encoder using the Ranking REST API.
Uses httpx + google-auth for lightweight REST calls (no gRPC/protobuf).
Supports ADC (Application Default Credentials) or service account key file.
Available models:
- semantic-ranker-default-004: Best quality, 1024 tokens/record (recommended)
- semantic-ranker-fast-004: Lower latency, 1024 tokens/record
Max 200 records per API request. Location is always "global".
"""
MAX_RECORDS_PER_REQUEST = 200
API_BASE = "https://discoveryengine.googleapis.com/v1"
SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
def __init__(
self,
project_id: str,
model: str = DEFAULT_RERANKER_GOOGLE_MODEL,
service_account_key: str | None = None,
location: str = "global",
timeout: float = 60.0,
):
"""
Initialize Google Discovery Engine cross-encoder.
Args:
project_id: Google Cloud project ID
model: Ranking model name (default: semantic-ranker-default-004)
service_account_key: Path to service account JSON key file.
If None, uses Application Default Credentials (ADC).
location: API location (default: "global")
timeout: Request timeout in seconds (default: 60.0)
"""
self.project_id = project_id
self.model = model
self.service_account_key = service_account_key
self.location = location
self.timeout = timeout
self._credentials = None
self._client: httpx.Client | None = None
self._rank_url: str | None = None
@property
def provider_name(self) -> str:
return "google"
def _get_auth_headers(self) -> dict[str, str]:
"""Get Authorization header with a fresh access token."""
import google.auth.transport.requests
if not self._credentials.valid:
self._credentials.refresh(google.auth.transport.requests.Request())
return {"Authorization": f"Bearer {self._credentials.token}"}
async def initialize(self) -> None:
"""Initialize credentials and HTTP client."""
if self._client is not None:
return
auth_method = "ADC" if not self.service_account_key else "service_account"
logger.info(
f"Reranker: initializing Google Discovery Engine provider "
f"(project={self.project_id}, model={self.model}, auth={auth_method})"
)
if self.service_account_key:
try:
from google.oauth2 import service_account
except ImportError:
raise ImportError(
"google-auth is required for GoogleCrossEncoder. Install it with: pip install google-auth"
)
self._credentials = service_account.Credentials.from_service_account_file(
self.service_account_key,
scopes=self.SCOPES,
)
else:
try:
import google.auth
except ImportError:
raise ImportError(
"google-auth is required for GoogleCrossEncoder. Install it with: pip install google-auth"
)
self._credentials, _ = google.auth.default(scopes=self.SCOPES)
ranking_config = f"projects/{self.project_id}/locations/{self.location}/rankingConfigs/default_ranking_config"
self._rank_url = f"{self.API_BASE}/{ranking_config}:rank"
self._client = httpx.Client(timeout=self.timeout)
logger.info("Reranker: Google Discovery Engine provider initialized")
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Synchronous predict via REST API."""
if not pairs:
return []
# Group pairs by query
query_groups: dict[str, list[tuple[int, str]]] = {}
for idx, (query, text) in enumerate(pairs):
if query not in query_groups:
query_groups[query] = []
query_groups[query].append((idx, text))
all_scores = [0.0] * len(pairs)
for query, indexed_texts in query_groups.items():
texts = [text for _, text in indexed_texts]
indices = [idx for idx, _ in indexed_texts]
# Process in batches of MAX_RECORDS_PER_REQUEST
for batch_start in range(0, len(texts), self.MAX_RECORDS_PER_REQUEST):
batch_texts = texts[batch_start : batch_start + self.MAX_RECORDS_PER_REQUEST]
batch_indices = indices[batch_start : batch_start + self.MAX_RECORDS_PER_REQUEST]
records = [{"id": str(i), "content": text} for i, text in enumerate(batch_texts)]
response = self._client.post(
self._rank_url,
headers=self._get_auth_headers(),
json={
"model": self.model,
"query": query,
"records": records,
"topN": len(records),
},
)
response.raise_for_status()
result = response.json()
for record in result.get("records", []):
local_idx = int(record["id"])
all_scores[batch_indices[local_idx]] = record["score"]
return all_scores
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
Score query-document pairs using Google Discovery Engine Ranking API.
Args:
pairs: List of (query, document) tuples to score
Returns:
List of relevance scores (0-1, higher = more relevant)
"""
if self._client is None:
raise RuntimeError("Reranker not initialized. Call initialize() first.")
if not pairs:
return []
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._predict_sync, pairs)
def create_cross_encoder_from_env() -> CrossEncoderModel:
"""
Create a CrossEncoderModel instance based on configuration.
@@ -1196,6 +1515,9 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
max_concurrent=config.reranker_local_max_concurrent,
force_cpu=config.reranker_local_force_cpu,
trust_remote_code=config.reranker_local_trust_remote_code,
fp16=config.reranker_local_fp16,
bucket_batching=config.reranker_local_bucket_batching,
batch_size=config.reranker_local_batch_size,
)
elif provider == "cohere":
api_key = config.reranker_cohere_api_key
@@ -1206,6 +1528,18 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
model=config.reranker_cohere_model,
base_url=config.reranker_cohere_base_url,
)
elif provider == "openrouter":
api_key = config.reranker_openrouter_api_key
if not api_key:
raise ValueError(
"HINDSIGHT_API_RERANKER_OPENROUTER_API_KEY, HINDSIGHT_API_OPENROUTER_API_KEY, "
f"or HINDSIGHT_API_LLM_API_KEY is required when {ENV_RERANKER_PROVIDER} is 'openrouter'"
)
return CohereCrossEncoder(
api_key=api_key,
model=config.reranker_openrouter_model,
base_url="https://openrouter.ai/api/v1/rerank",
)
elif provider == "flashrank":
model = os.environ.get(ENV_RERANKER_FLASHRANK_MODEL, DEFAULT_RERANKER_FLASHRANK_MODEL)
cache_dir = os.environ.get(ENV_RERANKER_FLASHRANK_CACHE_DIR, DEFAULT_RERANKER_FLASHRANK_CACHE_DIR)
@@ -1239,11 +1573,34 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
api_key=api_key,
model=config.reranker_zeroentropy_model,
)
elif provider == "siliconflow":
api_key = config.reranker_siliconflow_api_key
if not api_key:
raise ValueError(
f"{ENV_RERANKER_SILICONFLOW_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'siliconflow'"
)
return SiliconFlowCrossEncoder(
api_key=api_key,
model=config.reranker_siliconflow_model,
base_url=config.reranker_siliconflow_base_url,
)
elif provider == "google":
project_id = config.reranker_google_project_id
if not project_id:
raise ValueError(
f"{ENV_RERANKER_GOOGLE_PROJECT_ID} (or HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID) "
f"is required when {ENV_RERANKER_PROVIDER} is 'google'"
)
return GoogleCrossEncoder(
project_id=project_id,
model=config.reranker_google_model,
service_account_key=config.reranker_google_service_account_key,
)
elif provider == "rrf":
return RRFPassthroughCrossEncoder()
elif provider == "jina-mlx":
return JinaMLXCrossEncoder()
else:
raise ValueError(
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'zeroentropy', 'flashrank', 'litellm', 'litellm-sdk', 'rrf', 'jina-mlx'"
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'zeroentropy', 'siliconflow', 'google', 'flashrank', 'litellm', 'litellm-sdk', 'rrf', 'jina-mlx'"
)
@@ -13,11 +13,13 @@ import logging
import os
import warnings
from abc import ABC, abstractmethod
from urllib.parse import parse_qs, urlparse, urlunparse
import httpx
from ..config import (
DEFAULT_EMBEDDINGS_COHERE_MODEL,
DEFAULT_EMBEDDINGS_GEMINI_MODEL,
DEFAULT_EMBEDDINGS_LITELLM_MODEL,
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL,
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU,
@@ -27,6 +29,7 @@ from ..config import (
DEFAULT_EMBEDDINGS_PROVIDER,
DEFAULT_LITELLM_API_BASE,
ENV_EMBEDDINGS_COHERE_API_KEY,
ENV_EMBEDDINGS_GEMINI_API_KEY,
ENV_EMBEDDINGS_LITELLM_SDK_API_KEY,
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
ENV_EMBEDDINGS_LOCAL_MODEL,
@@ -426,9 +429,19 @@ class OpenAIEmbeddings(Embeddings):
logger.info(f"Embeddings: initializing OpenAI provider with model {self.model}{base_url_msg}")
# Build client kwargs, only including base_url if set (for Azure or custom endpoints)
# Parse query parameters from base_url (e.g. ?api-version=xxx for Azure OpenAI)
# and pass them as default_query so they're included in every request.
client_kwargs = {"api_key": self.api_key, "max_retries": self.max_retries}
if self.base_url:
client_kwargs["base_url"] = self.base_url
parsed = urlparse(self.base_url)
if parsed.query:
clean_url = urlunparse(parsed._replace(query=""))
client_kwargs["base_url"] = clean_url
default_query = {k: v[0] for k, v in parse_qs(parsed.query).items()}
client_kwargs["default_query"] = default_query
self.base_url = clean_url
else:
client_kwargs["base_url"] = self.base_url
self._client = OpenAI(**client_kwargs)
# Try to get dimension from known models, otherwise do a test embedding
@@ -741,8 +754,10 @@ class LiteLLMSDKEmbeddings(Embeddings):
api_key: str,
model: str = DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL,
api_base: str | None = None,
output_dimensions: int | None = None,
batch_size: int = 100,
timeout: float = 60.0,
encoding_format: str | None = "float",
):
"""
Initialize LiteLLM SDK embeddings client.
@@ -751,14 +766,19 @@ class LiteLLMSDKEmbeddings(Embeddings):
api_key: API key for the embedding provider
model: Model name with provider prefix (e.g., "cohere/embed-english-v3.0")
api_base: Custom base URL for API (optional)
output_dimensions: Optional output embedding dimensions (provider-dependent)
batch_size: Maximum batch size for embedding requests (default: 100)
timeout: Request timeout in seconds (default: 60.0)
encoding_format: Encoding format for embeddings (default: "float").
Set to None or empty string to omit (needed for Voyage AI, Gemini).
"""
self.api_key = api_key
self.model = model
self.api_base = api_base
self.output_dimensions = output_dimensions
self.batch_size = batch_size
self.timeout = timeout
self.encoding_format = encoding_format or None
self._litellm = None # Will be set during initialization
self._dimension: int | None = None
@@ -794,10 +814,13 @@ class LiteLLMSDKEmbeddings(Embeddings):
"model": self.model,
"input": ["test"],
"api_key": self.api_key,
"encoding_format": "float",
}
if self.encoding_format:
embed_kwargs["encoding_format"] = self.encoding_format
if self.api_base:
embed_kwargs["api_base"] = self.api_base
if self.output_dimensions is not None:
embed_kwargs["dimensions"] = self.output_dimensions
# Use async embedding method (standard in litellm)
response = await self._litellm.aembedding(**embed_kwargs)
@@ -841,10 +864,13 @@ class LiteLLMSDKEmbeddings(Embeddings):
"model": self.model,
"input": batch,
"api_key": self.api_key,
"encoding_format": "float",
}
if self.encoding_format:
embed_kwargs["encoding_format"] = self.encoding_format
if self.api_base:
embed_kwargs["api_base"] = self.api_base
if self.output_dimensions is not None:
embed_kwargs["dimensions"] = self.output_dimensions
# Use sync embedding (litellm doesn't have async in thread-safe way)
response = self._litellm.embedding(**embed_kwargs)
@@ -866,6 +892,179 @@ class LiteLLMSDKEmbeddings(Embeddings):
return all_embeddings
class GeminiEmbeddings(Embeddings):
"""
Google embeddings via the google.genai SDK.
Supports both:
1. Gemini API (api.generativeai.google.com) with API key authentication
2. Vertex AI with service account or Application Default Credentials (ADC)
Uses the embed_content API: client.models.embed_content(model, contents)
"""
def __init__(
self,
model: str = DEFAULT_EMBEDDINGS_GEMINI_MODEL,
api_key: str | None = None,
vertexai_project_id: str | None = None,
vertexai_region: str | None = None,
vertexai_service_account_key: str | None = None,
output_dimensionality: int | None = None,
batch_size: int = 100,
):
self.model = model
self.api_key = api_key
self.vertexai_project_id = vertexai_project_id
self.vertexai_region = vertexai_region or "us-central1"
self.vertexai_service_account_key = vertexai_service_account_key
self.output_dimensionality = output_dimensionality
self.batch_size = batch_size
self._client = None
self._dimension: int | None = None
self._is_vertexai = vertexai_project_id is not None
self._embed_config = None # EmbedContentConfig, built during initialize()
@property
def provider_name(self) -> str:
return "google"
@property
def dimension(self) -> int:
if self._dimension is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
return self._dimension
async def initialize(self) -> None:
"""Initialize the Google genai client and detect embedding dimension."""
if self._client is not None:
return
from google import genai
from google.genai import types as genai_types
if self._is_vertexai:
self._init_vertexai(genai)
else:
self._init_gemini(genai)
# Build EmbedContentConfig if output_dimensionality is set
if self.output_dimensionality is not None:
self._embed_config = genai_types.EmbedContentConfig(
output_dimensionality=self.output_dimensionality,
)
# Detect dimension via a test embedding (respects output_dimensionality)
embed_kwargs = {"model": self.model, "contents": ["test"]}
if self._embed_config is not None:
embed_kwargs["config"] = self._embed_config
result = self._client.models.embed_content(**embed_kwargs) # type: ignore[union-attr]
if result.embeddings and len(result.embeddings) > 0:
self._dimension = len(result.embeddings[0].values)
auth_mode = "vertex_ai" if self._is_vertexai else "api_key"
logger.info(
f"Embeddings: google provider initialized (auth: {auth_mode}, model: {self.model}, dim: {self._dimension})"
)
def _init_gemini(self, genai) -> None:
"""Initialize Gemini API client with API key."""
if not self.api_key:
raise ValueError("Gemini embeddings provider requires an API key")
self._client = genai.Client(api_key=self.api_key)
logger.info(f"Embeddings: initializing Gemini provider with model {self.model}")
def _init_vertexai(self, genai) -> None:
"""Initialize Vertex AI client with project, region, and credentials."""
if not self.vertexai_project_id:
raise ValueError(
"HINDSIGHT_API_EMBEDDINGS_VERTEXAI_PROJECT_ID (or HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID) "
"is required for Vertex AI embeddings provider."
)
auth_method = "ADC"
credentials = None
if self.vertexai_service_account_key:
try:
from google.oauth2 import service_account
except ImportError:
raise ImportError(
"Vertex AI service account auth requires 'google-auth' package. "
"Install with: pip install google-auth"
)
credentials = service_account.Credentials.from_service_account_file(
self.vertexai_service_account_key,
scopes=["https://www.googleapis.com/auth/cloud-platform"],
)
auth_method = "service_account"
logger.info(f"Embeddings: Vertex AI using service account key: {self.vertexai_service_account_key}")
# Strip google/ prefix from model name — native SDK uses bare names
if self.model.startswith("google/"):
self.model = self.model[len("google/") :]
client_kwargs = {
"vertexai": True,
"project": self.vertexai_project_id,
"location": self.vertexai_region,
}
if credentials is not None:
client_kwargs["credentials"] = credentials
self._client = genai.Client(**client_kwargs)
logger.info(
f"Embeddings: initializing Vertex AI provider "
f"(project={self.vertexai_project_id}, region={self.vertexai_region}, "
f"model={self.model}, auth={auth_method})"
)
def encode(self, texts: list[str]) -> list[list[float]]:
"""
Generate embeddings using the Google genai SDK.
Args:
texts: List of text strings to encode
Returns:
List of embedding vectors
"""
if self._client is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
if not texts:
return []
all_embeddings = []
# Process in batches
for i in range(0, len(texts), self.batch_size):
batch = texts[i : i + self.batch_size]
embed_kwargs = {"model": self.model, "contents": batch}
if self._embed_config is not None:
embed_kwargs["config"] = self._embed_config
result = self._client.models.embed_content(**embed_kwargs)
all_embeddings.extend([emb.values for emb in result.embeddings])
# L2-normalize when output_dimensionality is set — Gemini only returns
# normalized vectors at full 3072 dims; truncated dims need re-normalization
# for accurate cosine similarity.
if self.output_dimensionality is not None:
import numpy as np
arr = np.array(all_embeddings)
norms = np.linalg.norm(arr, axis=1, keepdims=True)
norms[norms == 0] = 1
all_embeddings = (arr / norms).tolist()
return all_embeddings
def create_embeddings_from_env() -> Embeddings:
"""
Create an Embeddings instance based on configuration.
@@ -902,6 +1101,18 @@ def create_embeddings_from_env() -> Embeddings:
model = os.environ.get(ENV_EMBEDDINGS_OPENAI_MODEL, DEFAULT_EMBEDDINGS_OPENAI_MODEL)
base_url = os.environ.get(ENV_EMBEDDINGS_OPENAI_BASE_URL) or None
return OpenAIEmbeddings(api_key=api_key, model=model, base_url=base_url)
elif provider == "openrouter":
api_key = config.embeddings_openrouter_api_key
if not api_key:
raise ValueError(
"HINDSIGHT_API_EMBEDDINGS_OPENROUTER_API_KEY, HINDSIGHT_API_OPENROUTER_API_KEY, "
f"or {ENV_LLM_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'openrouter'"
)
return OpenAIEmbeddings(
api_key=api_key,
model=config.embeddings_openrouter_model,
base_url="https://openrouter.ai/api/v1",
)
elif provider == "cohere":
api_key = config.embeddings_cohere_api_key
if not api_key:
@@ -927,9 +1138,30 @@ def create_embeddings_from_env() -> Embeddings:
api_key=api_key,
model=config.embeddings_litellm_sdk_model,
api_base=config.embeddings_litellm_sdk_api_base,
output_dimensions=config.embeddings_litellm_sdk_output_dimensions,
encoding_format=config.embeddings_litellm_sdk_encoding_format,
)
elif provider == "google":
vertexai_project_id = config.embeddings_vertexai_project_id
if vertexai_project_id:
api_key = None # Vertex AI uses ADC or service account
else:
api_key = config.embeddings_gemini_api_key
if not api_key:
raise ValueError(
f"{ENV_EMBEDDINGS_GEMINI_API_KEY} or {ENV_LLM_API_KEY} is required "
f"when {ENV_EMBEDDINGS_PROVIDER} is 'google' (set VERTEXAI_PROJECT_ID for Vertex AI auth instead)"
)
return GeminiEmbeddings(
model=config.embeddings_gemini_model,
api_key=api_key,
vertexai_project_id=vertexai_project_id,
vertexai_region=config.embeddings_vertexai_region,
vertexai_service_account_key=config.embeddings_vertexai_service_account_key,
output_dimensionality=config.embeddings_gemini_output_dimensionality,
)
else:
raise ValueError(
f"Unknown embeddings provider: {provider}. "
f"Supported: 'local', 'tei', 'openai', 'cohere', 'litellm', 'litellm-sdk'"
f"Supported: 'local', 'tei', 'openai', 'cohere', 'google', 'litellm', 'litellm-sdk'"
)
@@ -75,6 +75,7 @@ class EntityResolver:
"""
self.pool = pool
self.entity_lookup = entity_lookup
self._pg_trgm_checked = False
# Keyed by asyncio task id so concurrent retain batches never mix their
# pending updates. flush_pending_stats() pops only the calling task's items.
self._pending_stats: dict[int, list[_EntityStat]] = {}
@@ -85,6 +86,19 @@ class EntityResolver:
task = asyncio.current_task()
return id(task) if task is not None else 0
def discard_pending_stats(self) -> None:
"""
Discard accumulated entity stats and co-occurrence counts for the current task.
Call this on any exception path between resolve_entities_batch /
link_units_to_entities_batch and flush_pending_stats() to prevent the
per-task dicts from growing unbounded when tasks fail before flushing.
Safe to call even if no entries exist for the current task.
"""
key = self._task_key()
self._pending_stats.pop(key, None)
self._pending_cooccurrences.pop(key, None)
async def flush_pending_stats(self) -> None:
"""
Flush accumulated entity stats and co-occurrence counts for the current task.
@@ -202,6 +216,20 @@ class EntityResolver:
taxonomy_lookup: set[str] | None = None,
) -> list[str]:
if self.entity_lookup == "trigram":
# Auto-detect pg_trgm availability on first call and fall back to
# "full" strategy if the extension is not installed. See #626.
if not self._pg_trgm_checked:
self._pg_trgm_checked = True
has_trgm = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')")
if not has_trgm:
logger.warning(
"pg_trgm extension is not available — falling back to 'full' "
"entity lookup strategy. Install pg_trgm for faster entity "
"resolution on large banks. See: "
"https://github.com/vectorize-io/hindsight/issues/626"
)
self.entity_lookup = "full"
return await self._resolve_entities_batch_full(conn, bank_id, entities_data, unit_event_date)
return await self._resolve_entities_batch_trigram(conn, bank_id, entities_data, unit_event_date)
return await self._resolve_entities_batch_full(conn, bank_id, entities_data, unit_event_date)
@@ -289,8 +317,13 @@ class EntityResolver:
entity_texts = list(set(e["text"] for e in entities_data))
# Fetch candidates for all unique entity texts in a single batched query.
# The trigram % operator uses the GIN index; the substring conditions cover
# exact prefix/suffix matches that trigrams might miss at low similarity.
# Uses the GIN trigram index on LOWER(canonical_name) for case-insensitive
# similarity lookup. Previous version also had LIKE '%...' substring fallbacks,
# but those forced full sequential scans of the entities table and caused
# TimeoutErrors on banks with 10k+ entities. Lowering the similarity threshold
# to 0.15 (from default 0.3) catches most substring relationships while
# staying fully index-based.
await conn.execute("SET pg_trgm.similarity_threshold = 0.15")
rows = await conn.fetch(
f"""
SELECT DISTINCT ON (e.id)
@@ -299,16 +332,13 @@ class EntityResolver:
FROM unnest($2::text[]) AS q(query_text)
JOIN {fq_table("entities")} e ON (
e.bank_id = $1
AND (
e.canonical_name % q.query_text
OR LOWER(e.canonical_name) LIKE '%' || LOWER(q.query_text) || '%'
OR LOWER(q.query_text) LIKE '%' || LOWER(e.canonical_name) || '%'
)
AND LOWER(e.canonical_name) % LOWER(q.query_text)
)
""",
bank_id,
entity_texts,
)
await conn.execute("RESET pg_trgm.similarity_threshold")
# Group candidates by query_text
all_candidates: dict[str, list] = {t: [] for t in entity_texts}
@@ -477,19 +507,42 @@ class EntityResolver:
id_by_name: dict[str, str] = {row["name_lower"]: row["id"] for row in inserted_rows}
# Fallback SELECT for names that conflicted (another worker won the race).
missing = [n for n, _ in sorted_groups if n not in id_by_name]
if missing:
#
# IMPORTANT: we must let PostgreSQL do the lowercasing on BOTH sides of the
# comparison. Python's str.lower() and PostgreSQL's LOWER() differ for some
# Unicode characters — most notably Turkish İ (U+0130):
# Python: 'İstanbul'.lower() == 'i\u0307stanbul' (i + combining dot, 2 chars)
# PostgreSQL: LOWER('İstanbul') == 'istanbul' (plain i, 1 char)
# Passing a Python-lowercased name to "LOWER(canonical_name) = ANY($2::text[])"
# would fail to match the stored entity, leaving entity_id as None and causing
# a NOT NULL constraint violation on unit_entities.entity_id.
#
# Fix: pass the original (mixed-case) input names and use
# "LOWER(canonical_name) = ANY(SELECT LOWER(n) FROM unnest($2) AS n)" so
# PostgreSQL lowercases both sides identically. The query also returns the
# original input_name so we can index id_by_name by Python's lower() of that
# name, which is what the assignment loop below uses as its lookup key.
missing_original = [g.name for name_lower, g in sorted_groups if name_lower not in id_by_name]
if missing_original:
existing_rows = await conn.fetch(
f"""
SELECT id, LOWER(canonical_name) AS name_lower
FROM {fq_table("entities")}
WHERE bank_id = $1 AND LOWER(canonical_name) = ANY($2::text[])
SELECT e.id, LOWER(e.canonical_name) AS name_lower, inputs.input_name
FROM {fq_table("entities")} e
JOIN (
SELECT LOWER(n) AS input_name_lower, n AS input_name
FROM unnest($2::text[]) AS n
) AS inputs ON LOWER(e.canonical_name) = inputs.input_name_lower
WHERE e.bank_id = $1
""",
bank_id,
missing,
missing_original,
)
for row in existing_rows:
id_by_name[row["name_lower"]] = row["id"]
# Also index by Python's lower() of the original input name so the
# assignment loop (which uses Python-lowercased keys) finds it even
# when Python and PostgreSQL produce different lowercase strings.
id_by_name[row["input_name"].lower()] = row["id"]
# Assign entity IDs back and queue one stat per original mention so that
# flush_pending_stats() increments mention_count by the true mention count,
@@ -757,14 +810,19 @@ class EntityResolver:
return await self._link_units_to_entities_batch_impl(conn, unit_entity_pairs)
async def _link_units_to_entities_batch_impl(self, conn, unit_entity_pairs: list[tuple[str, str]]):
# Batch insert all unit-entity links
await conn.executemany(
# Sorted bulk insert to prevent deadlocks from inconsistent lock ordering
# across concurrent transactions on the unit_entities unique index.
sorted_pairs = sorted(unit_entity_pairs)
unit_ids = [p[0] for p in sorted_pairs]
entity_ids = [p[1] for p in sorted_pairs]
await conn.execute(
f"""
INSERT INTO {fq_table("unit_entities")} (unit_id, entity_id)
VALUES ($1, $2)
SELECT u, e FROM unnest($1::uuid[], $2::uuid[]) AS t(u, e)
ON CONFLICT DO NOTHING
""",
unit_entity_pairs,
unit_ids,
entity_ids,
)
# Build map of unit -> entities for co-occurrence calculation
@@ -240,6 +240,7 @@ class MemoryEngineInterface(ABC):
bank_id: str,
*,
fact_type: str | None = None,
delete_bank_profile: bool = True,
request_context: "RequestContext",
) -> dict[str, int]:
"""
@@ -248,6 +249,8 @@ class MemoryEngineInterface(ABC):
Args:
bank_id: The memory bank ID.
fact_type: If specified, only delete memories of this type.
delete_bank_profile: If True, also delete the bank profile row itself.
If False, only delete memories/entities/documents but preserve the bank.
request_context: Request context for authentication.
Returns:
@@ -122,10 +122,14 @@ _PROVIDERS_WITHOUT_API_KEY = frozenset(
{
"ollama",
"lmstudio",
"llamacpp",
"openai-codex",
"claude-code",
"mock",
"none",
"vertexai",
"litellm",
"bedrock",
}
)
@@ -143,6 +147,7 @@ def create_llm_provider(
reasoning_effort: str,
groq_service_tier: str | None = None,
openai_service_tier: str | None = None,
extra_body: dict[str, Any] | None = None,
vertexai_project_id: str | None = None,
vertexai_region: str | None = None,
vertexai_credentials: Any = None,
@@ -159,6 +164,7 @@ def create_llm_provider(
reasoning_effort: Reasoning effort level for supported providers.
groq_service_tier: Groq service tier (for Groq provider) - "on_demand", "flex", or "auto".
openai_service_tier: OpenAI service tier (for OpenAI provider) - None (default) or "flex" (50% cheaper).
extra_body: Extra body params merged into OpenAI-compatible API calls.
vertexai_project_id: Vertex AI project ID (for VertexAI provider).
vertexai_region: Vertex AI region (for VertexAI provider).
vertexai_credentials: Vertex AI credentials object (for VertexAI provider).
@@ -172,7 +178,10 @@ def create_llm_provider(
ClaudeCodeLLM,
CodexLLM,
GeminiLLM,
LiteLLMLLM,
LlamaCppLLM,
MockLLM,
NoneLLM,
OpenAICompatibleLLM,
)
@@ -205,6 +214,15 @@ def create_llm_provider(
reasoning_effort=reasoning_effort,
)
elif provider_lower == "none":
return NoneLLM(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
)
elif provider_lower in ("gemini", "vertexai"):
return GeminiLLM(
provider=provider,
@@ -227,7 +245,45 @@ def create_llm_provider(
reasoning_effort=reasoning_effort,
)
elif provider_lower in ("openai", "groq", "ollama", "lmstudio", "minimax"):
elif provider_lower == "litellm":
return LiteLLMLLM(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
)
elif provider_lower == "bedrock":
# Bedrock is a first-class alias backed by LiteLLM with auto-prefixed model names
bedrock_model = model if model.startswith("bedrock/") else f"bedrock/{model}"
return LiteLLMLLM(
provider=provider,
api_key=api_key,
base_url=base_url,
model=bedrock_model,
reasoning_effort=reasoning_effort,
)
elif provider_lower == "llamacpp":
from ..config import get_config
config = get_config()
return LlamaCppLLM(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
model_path=config.llamacpp_model_path,
gpu_layers=config.llamacpp_gpu_layers,
context_size=config.llamacpp_context_size,
chat_format=config.llamacpp_chat_format,
no_grammar=config.llamacpp_no_grammar,
extra_args=config.llamacpp_extra_args,
)
elif provider_lower in ("openai", "groq", "ollama", "lmstudio", "minimax", "volcano", "openrouter"):
return OpenAICompatibleLLM(
provider=provider,
api_key=api_key,
@@ -236,6 +292,7 @@ def create_llm_provider(
reasoning_effort=reasoning_effort,
groq_service_tier=groq_service_tier,
openai_service_tier=openai_service_tier,
extra_body=extra_body,
)
else:
@@ -259,6 +316,7 @@ class LLMProvider:
groq_service_tier: str | None = None,
openai_service_tier: str | None = None,
gemini_safety_settings: list | None = None,
extra_body: dict[str, Any] | None = None,
):
"""
Initialize LLM provider.
@@ -272,6 +330,7 @@ class LLMProvider:
groq_service_tier: Groq service tier ("on_demand", "flex", "auto") - from config.
openai_service_tier: OpenAI service tier (None or "flex") - from config.
gemini_safety_settings: Safety settings for Gemini/VertexAI providers.
extra_body: Extra body params merged into OpenAI-compatible API calls.
"""
self.provider = provider.lower()
self.api_key = api_key
@@ -283,6 +342,8 @@ class LLMProvider:
self.openai_service_tier = openai_service_tier
# Gemini safety settings (instance default; can be overridden per-request via context var)
self.gemini_safety_settings = gemini_safety_settings
# Extra body params for OpenAI-compatible providers (e.g. chat_template_kwargs)
self.extra_body = extra_body
# Validate provider
valid_providers = [
@@ -292,11 +353,17 @@ class LLMProvider:
"gemini",
"anthropic",
"lmstudio",
"llamacpp",
"vertexai",
"openai-codex",
"claude-code",
"mock",
"none",
"minimax",
"litellm",
"bedrock",
"volcano",
"openrouter",
]
if self.provider not in valid_providers:
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
@@ -311,6 +378,8 @@ class LLMProvider:
self.base_url = "http://localhost:1234/v1"
elif self.provider == "minimax":
self.base_url = "https://api.minimax.io/v1"
elif self.provider == "openrouter":
self.base_url = "https://openrouter.ai/api/v1"
# Prepare Vertex AI config (if applicable)
vertexai_project_id = None
@@ -375,6 +444,7 @@ class LLMProvider:
reasoning_effort=self.reasoning_effort,
groq_service_tier=self.groq_service_tier,
openai_service_tier=self.openai_service_tier,
extra_body=self.extra_body,
vertexai_project_id=vertexai_project_id,
vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials,
@@ -466,6 +536,15 @@ class LLMProvider:
OutputTooLongError: If output exceeds token limits.
Exception: Re-raises API errors after retries exhausted.
"""
# Stage breadcrumb so the worker log shows which LLM call a task is
# currently inside; the stage_age field then reveals long JSON-schema
# retry loops (e.g. a small model that can't satisfy strict_schema).
# No-op outside a worker context.
from ..worker.stage import set_stage
structured = "+structured" if response_format is not None else ""
set_stage(f"llm.{self.provider}.{scope}{structured}")
async with _global_llm_semaphore:
# Delegate to provider implementation
result = await self._provider_impl.call(
@@ -522,6 +601,10 @@ class LLMProvider:
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
from ..worker.stage import set_stage
set_stage(f"llm.{self.provider}.{scope}+tools")
async with _global_llm_semaphore:
# Delegate to provider implementation
result = await self._provider_impl.call_with_tools(
@@ -633,7 +716,7 @@ class LLMProvider:
# Reduce Claude Agent SDK logging verbosity
import logging as sdk_logging
from claude_agent_sdk import query # noqa: F401
from claude_agent_sdk import query # noqa: F401 # type: ignore[unresolved-import]
sdk_logging.getLogger("claude_agent_sdk").setLevel(sdk_logging.WARNING)
sdk_logging.getLogger("claude_agent_sdk._internal").setLevel(sdk_logging.WARNING)
@@ -665,64 +748,45 @@ class LLMProvider:
return ConfiguredLLMProvider(self, config.llm_gemini_safety_settings)
async def cleanup(self) -> None:
"""Clean up resources."""
pass
"""Clean up resources (e.g. stop llamacpp subprocess)."""
if self._provider_impl:
await self._provider_impl.cleanup()
@classmethod
def for_memory(cls) -> "LLMProvider":
"""Create provider for memory operations from environment variables."""
provider = os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq")
api_key = os.getenv("HINDSIGHT_API_LLM_API_KEY", "")
def from_env(cls) -> "LLMProvider":
"""Create provider from environment variables using config.py constants."""
from ..config import (
DEFAULT_LLM_MODEL,
DEFAULT_LLM_PROVIDER,
ENV_LLM_API_KEY,
ENV_LLM_BASE_URL,
ENV_LLM_EXTRA_BODY,
ENV_LLM_MODEL,
ENV_LLM_PROVIDER,
)
# API key not needed for openai-codex (uses OAuth), claude-code (uses Keychain OAuth),
# ollama (local), or vertexai (uses GCP service account credentials)
if not api_key and provider not in ("openai-codex", "claude-code", "ollama", "vertexai"):
provider = os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER)
api_key = os.getenv(ENV_LLM_API_KEY, "")
if not api_key and not requires_api_key(provider):
pass # Provider handles its own auth
elif not api_key:
raise ValueError(
"HINDSIGHT_API_LLM_API_KEY environment variable is required (unless using openai-codex or claude-code)"
f"{ENV_LLM_API_KEY} environment variable is required (unless using openai-codex, claude-code, or litellm)"
)
base_url = os.getenv("HINDSIGHT_API_LLM_BASE_URL", "")
model = os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b")
base_url = os.getenv(ENV_LLM_BASE_URL, "")
model = os.getenv(ENV_LLM_MODEL, DEFAULT_LLM_MODEL)
extra_body = json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null"))
return cls(provider=provider, api_key=api_key, base_url=base_url, model=model, reasoning_effort="low")
@classmethod
def for_answer_generation(cls) -> "LLMProvider":
"""Create provider for answer generation. Falls back to memory config if not set."""
provider = os.getenv("HINDSIGHT_API_ANSWER_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"))
api_key = os.getenv("HINDSIGHT_API_ANSWER_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY", ""))
# API key not needed for openai-codex (uses OAuth), claude-code (uses Keychain OAuth),
# ollama (local), or vertexai (uses GCP service account credentials)
if not api_key and provider not in ("openai-codex", "claude-code", "ollama", "vertexai"):
raise ValueError(
"HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_API_ANSWER_LLM_API_KEY environment variable is required "
"(unless using openai-codex or claude-code)"
)
base_url = os.getenv("HINDSIGHT_API_ANSWER_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", ""))
model = os.getenv("HINDSIGHT_API_ANSWER_LLM_MODEL", os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b"))
return cls(provider=provider, api_key=api_key, base_url=base_url, model=model, reasoning_effort="high")
@classmethod
def for_judge(cls) -> "LLMProvider":
"""Create provider for judge/evaluator operations. Falls back to memory config if not set."""
provider = os.getenv("HINDSIGHT_API_JUDGE_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"))
api_key = os.getenv("HINDSIGHT_API_JUDGE_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY", ""))
# API key not needed for openai-codex (uses OAuth), claude-code (uses Keychain OAuth),
# ollama (local), or vertexai (uses GCP service account credentials)
if not api_key and provider not in ("openai-codex", "claude-code", "ollama", "vertexai"):
raise ValueError(
"HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_API_JUDGE_LLM_API_KEY environment variable is required "
"(unless using openai-codex or claude-code)"
)
base_url = os.getenv("HINDSIGHT_API_JUDGE_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", ""))
model = os.getenv("HINDSIGHT_API_JUDGE_LLM_MODEL", os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b"))
return cls(provider=provider, api_key=api_key, base_url=base_url, model=model, reasoning_effort="high")
return cls(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort="low",
extra_body=extra_body,
)
class ConfiguredLLMProvider:
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,20 @@ from .anthropic_llm import AnthropicLLM
from .claude_code_llm import ClaudeCodeLLM
from .codex_llm import CodexLLM
from .gemini_llm import GeminiLLM
from .litellm_llm import LiteLLMLLM
from .llamacpp_llm import LlamaCppLLM
from .mock_llm import MockLLM
from .none_llm import NoneLLM
from .openai_compatible_llm import OpenAICompatibleLLM
__all__ = ["AnthropicLLM", "ClaudeCodeLLM", "CodexLLM", "GeminiLLM", "MockLLM", "OpenAICompatibleLLM"]
__all__ = [
"AnthropicLLM",
"ClaudeCodeLLM",
"CodexLLM",
"GeminiLLM",
"LlamaCppLLM",
"LiteLLMLLM",
"MockLLM",
"NoneLLM",
"OpenAICompatibleLLM",
]
@@ -68,7 +68,7 @@ class ClaudeCodeLLM(LLMInterface):
# Reduce Claude Agent SDK logging verbosity
import logging as sdk_logging
from claude_agent_sdk import query # noqa: F401
from claude_agent_sdk import query # noqa: F401 # type: ignore[unresolved-import]
sdk_logging.getLogger("claude_agent_sdk").setLevel(sdk_logging.WARNING)
sdk_logging.getLogger("claude_agent_sdk._internal").setLevel(sdk_logging.WARNING)
@@ -141,7 +141,12 @@ class ClaudeCodeLLM(LLMInterface):
OutputTooLongError: If output exceeds token limits (not supported by Claude Agent SDK).
Exception: Re-raises API errors after retries exhausted.
"""
from claude_agent_sdk import AssistantMessage, ClaudeAgentOptions, TextBlock, query
from claude_agent_sdk import ( # type: ignore[unresolved-import]
AssistantMessage,
ClaudeAgentOptions,
TextBlock,
query,
)
start_time = time.time()
@@ -326,12 +331,16 @@ class ClaudeCodeLLM(LLMInterface):
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools (not used by Claude Agent SDK).
tool_choice: How to choose tools - "auto", "none", "required", or specific function dict.
- "auto": Model decides whether to call tools (default)
- "required": Model must call at least one tool
- "none": Model must not call any tools
- {"type": "function", "function": {"name": "..."}}: Force specific tool call
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
from claude_agent_sdk import (
from claude_agent_sdk import ( # type: ignore[unresolved-import]
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
@@ -405,16 +414,57 @@ class ClaudeCodeLLM(LLMInterface):
tool_call_id = msg.get("tool_call_id", "")
user_content += f"\n\n[Tool result for {tool_call_id}: {content}]"
# Handle tool_choice parameter to filter tools and adjust instructions
# The Claude Agent SDK doesn't have a native tool_choice parameter, so we
# enforce it via allowed_tools filtering and system prompt instructions.
# Format tool names for SDK MCP servers: mcp__{server_name}__{tool_name}
# This is required by the Claude Agent SDK for MCP server tools
allowed_tool_names = [f"mcp__hindsight_tools__{name}" for name in tool_names]
mcp_servers_config = {"hindsight_tools": mcp_server} if sdk_tools else {}
# Process tool_choice
if isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
# Force a specific tool: filter allowed_tools to only that tool and add instruction
forced_name = tool_choice.get("function", {}).get("name")
if forced_name:
# Filter to only the forced tool (with MCP prefix)
forced_tool_mcp_name = f"mcp__hindsight_tools__{forced_name}"
if forced_tool_mcp_name in allowed_tool_names:
allowed_tool_names = [forced_tool_mcp_name]
# Add strong instruction to system prompt
force_instruction = (
f"\n\nIMPORTANT: You MUST call the '{forced_name}' tool. Do not respond with text only."
)
system_prompt += force_instruction
logger.debug(f"Claude Code: Forcing tool call to '{forced_name}'")
else:
logger.warning(f"Claude Code: Forced tool '{forced_name}' not found in available tools")
elif tool_choice == "required":
# Must call at least one tool
tool_instruction = (
"\n\nIMPORTANT: You MUST call at least one of the available tools. Do not respond with text only."
)
system_prompt += tool_instruction
logger.debug("Claude Code: Tool call required")
elif tool_choice == "none":
# No tools should be called - disable all tools
allowed_tool_names = []
mcp_servers_config = {}
logger.debug("Claude Code: Tools disabled (tool_choice=none)")
# else: tool_choice == "auto" or unspecified - use default behavior (no changes needed)
# Configure SDK options with MCP server
# tools=[] disables built-in CLI tools (Read, Write, Bash, ToolSearch, etc.)
# Without this, Claude Code CLI defers MCP tools when too many built-in tools
# are loaded, forcing Claude to use ToolSearch first — which wastes the max_turns
# budget and prevents direct MCP tool calls.
options = ClaudeAgentOptions(
system_prompt=system_prompt if system_prompt else None,
max_turns=1, # Single-turn for API-style interactions
mcp_servers={"hindsight_tools": mcp_server} if sdk_tools else {},
allowed_tools=allowed_tool_names if allowed_tool_names else [],
tools=[], # Disable built-in tools so MCP tools load eagerly
max_turns=2, # Allow tool call + tool result round-trip
mcp_servers=mcp_servers_config,
allowed_tools=allowed_tool_names,
)
# Call Claude Agent SDK with retry logic
@@ -126,6 +126,32 @@ class CodexLLM(LLMInterface):
}
return mapping.get(effort.lower(), "auto")
def _normalize_tool_choice(self, tool_choice: str | dict[str, Any]) -> str | dict[str, Any]:
"""Normalize forced function tool choice for the Codex Responses API.
Older agent paths may still pass OpenAI chat-completions style named
tool choice payloads such as:
{"type": "function", "function": {"name": "recall"}}
Codex Responses expects the named function at the top level instead:
{"type": "function", "name": "recall"}
"""
if not isinstance(tool_choice, dict):
return tool_choice
if str(tool_choice.get("type") or "").strip() != "function":
return tool_choice
function_payload = tool_choice.get("function")
if isinstance(function_payload, dict):
function_name = str(function_payload.get("name") or "").strip()
if function_name:
return {"type": "function", "name": function_name}
function_name = str(tool_choice.get("name") or "").strip()
if function_name:
return {"type": "function", "name": function_name}
return tool_choice
async def verify_connection(self) -> None:
"""Verify Codex connection by making a simple test call."""
try:
@@ -140,6 +166,10 @@ class CodexLLM(LLMInterface):
)
logger.info(f"Codex LLM verified: {self.model}")
except Exception as e:
# 429 means quota exhausted, not a configuration error — warn but allow startup
if "429" in str(e) or "usage_limit_reached" in str(e):
logger.warning(f"Codex LLM quota exhausted for {self.model}, continuing startup: {e}")
return
raise RuntimeError(f"Codex LLM connection verification failed for {self.model}: {e}") from e
async def call(
@@ -263,24 +293,27 @@ class CodexLLM(LLMInterface):
)
# Record trace span
from hindsight_api.tracing import get_span_recorder
try:
from hindsight_api.tracing import get_span_recorder
# Estimate tokens for tracing
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
estimated_output = len(content) // 4
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=result if isinstance(result, str) else json.dumps(result),
input_tokens=estimated_input,
output_tokens=estimated_output,
duration=duration,
finish_reason=None,
error=None,
)
# Estimate tokens for tracing
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
estimated_output = len(content) // 4
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=result if isinstance(result, str) else result.model_dump_json(),
input_tokens=estimated_input,
output_tokens=estimated_output,
duration=duration,
finish_reason=None,
error=None,
)
except Exception:
pass # logging failure must never affect the operation
if return_usage:
# Codex doesn't provide token counts, estimate based on content
@@ -422,7 +455,7 @@ class CodexLLM(LLMInterface):
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or specific function.
tool_choice: How to choose tools - "auto", "none", "required", or a specific function.
Returns:
LLMToolCallResult with content and/or tool_calls.
@@ -479,7 +512,7 @@ class CodexLLM(LLMInterface):
"instructions": system_instruction,
"input": user_messages,
"tools": codex_tools,
"tool_choice": tool_choice,
"tool_choice": self._normalize_tool_choice(tool_choice),
"parallel_tool_calls": True,
"reasoning": {"summary": reasoning_summary},
"store": False,
@@ -526,26 +559,31 @@ class CodexLLM(LLMInterface):
)
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
try:
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls] if tool_calls else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=0, # Codex doesn't provide token counts
output_tokens=0,
duration=duration,
finish_reason="tool_calls" if tool_calls else "stop",
error=None,
tool_calls=tool_calls_dict,
)
span_recorder = get_span_recorder()
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
if tool_calls
else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=0, # Codex doesn't provide token counts
output_tokens=0,
duration=duration,
finish_reason="tool_calls" if tool_calls else "stop",
error=None,
tool_calls=tool_calls_dict,
)
except Exception:
pass # logging failure must never affect the operation
return LLMToolCallResult(
content=content,
@@ -7,6 +7,7 @@ This provider supports both:
"""
import asyncio
import base64
import json
import logging
import os
@@ -22,6 +23,7 @@ from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.llm_wrapper import parse_llm_json
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
from hindsight_api.worker.stage import set_stage
logger = logging.getLogger(__name__)
@@ -241,6 +243,8 @@ class GeminiLLM(LLMInterface):
last_exception = None
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.gemini.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await asyncio.wait_for(
self._client.aio.models.generate_content(
@@ -470,9 +474,12 @@ class GeminiLLM(LLMInterface):
fn_name = fn.get("name", "")
fn_args_str = fn.get("arguments", "{}")
fn_args = parse_llm_json(fn_args_str)
parts.append(
genai_types.Part(function_call=genai_types.FunctionCall(name=fn_name, args=fn_args))
)
thought_signature = tc.get("thought_signature")
fc_kwargs: dict[str, Any] = {"name": fn_name, "args": fn_args}
part_kwargs: dict[str, Any] = {"function_call": genai_types.FunctionCall(**fc_kwargs)}
if thought_signature:
part_kwargs["thought_signature"] = base64.b64decode(thought_signature)
parts.append(genai_types.Part(**part_kwargs))
gemini_contents.append(genai_types.Content(role="model", parts=parts))
else:
gemini_contents.append(genai_types.Content(role="model", parts=[genai_types.Part(text=content)]))
@@ -523,6 +530,8 @@ class GeminiLLM(LLMInterface):
last_exception = None
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.gemini.tools.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await asyncio.wait_for(
self._client.aio.models.generate_content(
@@ -545,11 +554,16 @@ class GeminiLLM(LLMInterface):
content = part.text
if hasattr(part, "function_call") and part.function_call:
fc = part.function_call
_raw_ts = getattr(part, "thought_signature", None)
thought_signature = (
base64.b64encode(_raw_ts).decode("ascii") if isinstance(_raw_ts, bytes) else _raw_ts
)
tool_calls.append(
LLMToolCall(
id=f"gemini_{len(tool_calls)}",
name=fc.name,
arguments=dict(fc.args) if fc.args else {},
thought_signature=thought_signature,
)
)
@@ -0,0 +1,385 @@
"""
LiteLLM LLM provider for universal model support.
This provider enables using 100+ LLM providers via the LiteLLM SDK, including:
- AWS Bedrock (bedrock/anthropic.claude-3-5-sonnet-...)
- Azure OpenAI (azure/gpt-4o)
- Together AI (together_ai/meta-llama/...)
- Any other LiteLLM-supported provider
Uses litellm.acompletion() for async chat completions.
Authentication for cloud providers (e.g., AWS Bedrock via boto3 credential chain)
is handled automatically by LiteLLM.
"""
import asyncio
import json
import logging
import time
from typing import Any
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
from hindsight_api.worker.stage import set_stage
logger = logging.getLogger(__name__)
class LiteLLMLLM(LLMInterface):
"""
LLM provider using the LiteLLM SDK for universal model support.
Supports any model accessible via litellm.acompletion(), including AWS Bedrock,
Azure OpenAI, Together AI, Fireworks AI, and more.
Model names follow LiteLLM conventions with provider prefixes:
- bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0
- azure/gpt-4o
- together_ai/meta-llama/Llama-3-70b-chat-hf
- fireworks_ai/accounts/fireworks/models/llama-v3p1-70b-instruct
"""
def __init__(
self,
provider: str,
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
timeout: float = 300.0,
**kwargs: Any,
):
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
self.timeout = timeout
self._litellm: Any = None
try:
import litellm
self._litellm = litellm
# Suppress LiteLLM's verbose logging
litellm.suppress_debug_info = True # type: ignore[assignment]
# Drop unsupported params instead of raising errors (e.g. tool_choice on some Bedrock models)
litellm.drop_params = True # type: ignore[assignment]
logging.getLogger("LiteLLM").setLevel(logging.WARNING)
logger.info(f"LiteLLM SDK initialized for model: {self.model}")
except ImportError as e:
raise RuntimeError("LiteLLM SDK not installed. Run: uv add litellm or pip install litellm") from e
async def verify_connection(self) -> None:
try:
test_messages = [{"role": "user", "content": "test"}]
await self.call(
messages=test_messages,
max_completion_tokens=50,
temperature=0.0,
scope="verification",
max_retries=0,
)
logger.info("LiteLLM connection verified successfully")
except OutputTooLongError:
# Truncation is fine for verification — it means the connection works
logger.info("LiteLLM connection verified successfully (response truncated)")
except Exception as e:
logger.error(f"LiteLLM connection verification failed: {e}")
raise RuntimeError(f"Failed to verify LiteLLM connection: {e}") from e
def _build_common_kwargs(
self,
messages: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
) -> dict[str, Any]:
"""Build common kwargs for litellm calls."""
kwargs: dict[str, Any] = {
"model": self.model,
"messages": messages,
"timeout": self.timeout,
}
if self.api_key:
kwargs["api_key"] = self.api_key
if self.base_url:
kwargs["api_base"] = self.base_url
if max_completion_tokens is not None:
kwargs["max_completion_tokens"] = max_completion_tokens
if temperature is not None:
kwargs["temperature"] = temperature
return kwargs
async def call(
self,
messages: list[dict[str, str]],
response_format: Any | None = None,
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
) -> Any:
start_time = time.time()
call_kwargs = self._build_common_kwargs(messages, max_completion_tokens, temperature)
# Add JSON schema response format if provided
if response_format is not None and hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
call_kwargs["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": response_format.__name__ if hasattr(response_format, "__name__") else "response",
"schema": schema,
"strict": strict_schema,
},
}
last_exception = None
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.litellm.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await self._litellm.acompletion(**call_kwargs)
content = response.choices[0].message.content or ""
finish_reason = response.choices[0].finish_reason
# Check for length-limited output
if finish_reason == "length":
raise OutputTooLongError("LiteLLM response was truncated due to token limit")
if response_format is not None:
# Strip markdown code fences if present
clean_content = content
if "```json" in content:
clean_content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
clean_content = content.split("```")[1].split("```")[0].strip()
try:
json_data = json.loads(clean_content)
except json.JSONDecodeError:
json_data = json.loads(content)
if skip_validation:
result = json_data
else:
result = response_format.model_validate(json_data)
else:
result = content
# Extract usage
input_tokens = getattr(response.usage, "prompt_tokens", 0) or 0
output_tokens = getattr(response.usage, "completion_tokens", 0) or 0
total_tokens = input_tokens + output_tokens
# Record metrics
duration = time.time() - start_time
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
# Record trace span
from hindsight_api.tracing import _serialize_for_span, get_span_recorder
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=_serialize_for_span(result),
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
)
if duration > 10.0:
logger.info(
f"slow llm call: scope={scope}, model={self.provider}/{self.model}, "
f"input_tokens={input_tokens}, output_tokens={output_tokens}, "
f"time={duration:.3f}s"
)
if return_usage:
token_usage = TokenUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
)
return result, token_usage
return result
except OutputTooLongError:
raise
except json.JSONDecodeError as e:
last_exception = e
if attempt < max_retries:
logger.warning("LiteLLM returned invalid JSON, retrying...")
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
logger.error(f"LiteLLM returned invalid JSON after {max_retries + 1} attempts")
raise
except Exception as e:
error_str = str(e).lower()
# Fast fail on auth errors
if "401" in error_str or "403" in error_str or "unauthorized" in error_str:
logger.error(f"LiteLLM auth error, not retrying: {e}")
raise
last_exception = e
if attempt < max_retries:
# Retry on rate limits, connection errors, server errors
is_retryable = any(
keyword in error_str
for keyword in ("rate", "limit", "timeout", "connection", "500", "502", "503", "529")
)
if is_retryable:
backoff = min(initial_backoff * (2**attempt), max_backoff)
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
await asyncio.sleep(backoff + jitter)
continue
logger.error(f"LiteLLM API error after {attempt + 1} attempts: {e}")
raise
if last_exception:
raise last_exception
raise RuntimeError("LiteLLM call failed after all retries")
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
start_time = time.time()
call_kwargs = self._build_common_kwargs(messages, max_completion_tokens, temperature)
call_kwargs["tools"] = tools
call_kwargs["tool_choice"] = tool_choice
last_exception = None
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.litellm.tools.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await self._litellm.acompletion(**call_kwargs)
message = response.choices[0].message
content = message.content
finish_reason = response.choices[0].finish_reason
# Extract tool calls
tool_calls: list[LLMToolCall] = []
if message.tool_calls:
for tc in message.tool_calls:
arguments = tc.function.arguments
if isinstance(arguments, str):
arguments = json.loads(arguments)
tool_calls.append(
LLMToolCall(
id=tc.id,
name=tc.function.name,
arguments=arguments,
)
)
# Extract usage
input_tokens = getattr(response.usage, "prompt_tokens", 0) or 0
output_tokens = getattr(response.usage, "completion_tokens", 0) or 0
# Record metrics
duration = time.time() - start_time
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
# Record trace span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
if tool_calls
else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
tool_calls=tool_calls_dict,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason or ("tool_calls" if tool_calls else "stop"),
input_tokens=input_tokens,
output_tokens=output_tokens,
)
except Exception as e:
error_str = str(e).lower()
if "401" in error_str or "403" in error_str or "unauthorized" in error_str:
raise
last_exception = e
if attempt < max_retries:
is_retryable = any(
keyword in error_str
for keyword in ("rate", "limit", "timeout", "connection", "500", "502", "503", "529")
)
if is_retryable:
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
continue
logger.error(f"LiteLLM tool call error after {attempt + 1} attempts: {e}")
raise
if last_exception:
raise last_exception
raise RuntimeError("LiteLLM tool call failed after all retries")
async def cleanup(self) -> None:
"""Clean up resources."""
pass
@@ -0,0 +1,428 @@
"""
Built-in llama.cpp LLM provider for fully offline operation.
Manages a llama-cpp-python server as a subprocess, downloads GGUF models
from HuggingFace on first use, and delegates inference to the OpenAI-compatible API.
Usage:
HINDSIGHT_API_LLM_PROVIDER=llamacpp
HINDSIGHT_API_LLAMACPP_MODEL_PATH=~/.hindsight/models/gemma-4-E2B-it-Q4_K_M.gguf
HINDSIGHT_API_LLAMACPP_GPU_LAYERS=-1 # -1 = all layers on GPU
HINDSIGHT_API_LLAMACPP_CONTEXT_SIZE=8192
"""
import asyncio
import logging
import os
import signal
import socket
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
from hindsight_api.engine.llm_interface import LLMInterface
from hindsight_api.engine.response_models import LLMToolCallResult
logger = logging.getLogger(__name__)
# Default GGUF model for offline mode
DEFAULT_LLAMACPP_HF_REPO = "bartowski/google_gemma-4-E2B-it-GGUF"
DEFAULT_LLAMACPP_HF_FILENAME = "google_gemma-4-E2B-it-Q4_K_M.gguf"
DEFAULT_LLAMACPP_MODEL_ALIAS = "gemma-4-e2b-it"
MODELS_DIR = Path.home() / ".hindsight" / "models"
# Singleton server instance — shared across all LlamaCppLLM instances
# (retain, reflect, consolidation each create their own LLMProvider,
# but they should all share one llama.cpp server process)
_shared_server: "LlamaCppServer | None" = None
_shared_server_lock = asyncio.Lock()
def _find_free_port() -> int:
"""Find a free TCP port on localhost."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _download_default_model() -> Path:
"""Download the default GGUF model from HuggingFace if not already cached.
Returns:
Path to the downloaded GGUF file.
"""
try:
from huggingface_hub import hf_hub_download
except ImportError:
raise ImportError(
"huggingface-hub is required for automatic model download. "
"Install with: pip install 'hindsight-api-slim[local-llm]'"
)
MODELS_DIR.mkdir(parents=True, exist_ok=True)
target = MODELS_DIR / DEFAULT_LLAMACPP_HF_FILENAME
if target.exists():
logger.info(f"Using cached model: {target}")
return target
logger.info(
f"Downloading {DEFAULT_LLAMACPP_HF_FILENAME} from {DEFAULT_LLAMACPP_HF_REPO} (~3.5 GB, first run only)..."
)
downloaded = hf_hub_download(
repo_id=DEFAULT_LLAMACPP_HF_REPO,
filename=DEFAULT_LLAMACPP_HF_FILENAME,
local_dir=str(MODELS_DIR),
)
logger.info(f"Model downloaded: {downloaded}")
return Path(downloaded)
def _resolve_model_path(model_path: str | None) -> Path:
"""Resolve the model path, downloading the default if needed.
Args:
model_path: Explicit path to a GGUF file, or None to use the default.
Returns:
Resolved Path to the GGUF file.
"""
if model_path:
p = Path(model_path).expanduser()
if not p.exists():
raise FileNotFoundError(
f"GGUF model not found: {p}\n"
f"Set HINDSIGHT_API_LLAMACPP_MODEL_PATH to a valid .gguf file, "
f"or remove the setting to auto-download the default model."
)
return p
return _download_default_model()
class LlamaCppServer:
"""Manages a llama-cpp-python OpenAI-compatible server as a subprocess."""
def __init__(
self,
model_path: Path,
port: int,
gpu_layers: int = -1,
context_size: int = 8192,
chat_format: str | None = None,
extra_args: str | None = None,
):
self.model_path = model_path
self.port = port
self.gpu_layers = gpu_layers
self.context_size = context_size
self.chat_format = chat_format
self.extra_args = extra_args
self._process: subprocess.Popen | None = None
@property
def base_url(self) -> str:
return f"http://127.0.0.1:{self.port}/v1"
async def start(self) -> None:
"""Start the llama.cpp server subprocess."""
cmd = [
sys.executable,
"-m",
"llama_cpp.server",
"--model",
str(self.model_path),
"--host",
"127.0.0.1",
"--port",
str(self.port),
"--n_gpu_layers",
str(self.gpu_layers),
"--n_ctx",
str(self.context_size),
"--flash_attn",
"true",
"--n_batch",
"2048",
# Prompt cache: reuse KV cache for repeated system prompts
"--cache",
"true",
]
# Only pass chat_format if explicitly set (most GGUF models have it embedded)
if self.chat_format:
cmd.extend(["--chat_format", self.chat_format])
# User-provided extra args (e.g. "--type_k 1 --type_v 1 --n_threads 8")
if self.extra_args:
cmd.extend(self.extra_args.split())
logger.info(f"Starting llama.cpp server: {' '.join(cmd)}")
# Write stderr to a log file to avoid pipe buffer deadlock
# (llama.cpp outputs a lot of model metadata on stderr during loading)
self._log_path = MODELS_DIR / "llamacpp_server.log"
self._log_file = open(self._log_path, "w")
self._process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=self._log_file,
# Ensure the subprocess is killed when the parent exits
preexec_fn=os.setsid if hasattr(os, "setsid") else None,
)
# Wait for the server to be ready
await self._wait_for_ready()
async def _wait_for_ready(self, timeout: float = 120.0) -> None:
"""Wait for the llama.cpp server to accept connections."""
import httpx
start = time.monotonic()
url = f"http://127.0.0.1:{self.port}/v1/models"
last_log = start
while time.monotonic() - start < timeout:
# Check if process died
if self._process and self._process.poll() is not None:
stderr = ""
try:
stderr = self._log_path.read_text()[-2000:]
except Exception:
pass
raise RuntimeError(f"llama.cpp server exited with code {self._process.returncode}.\nstderr: {stderr}")
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=5.0)
if resp.status_code == 200:
logger.info(f"llama.cpp server ready on port {self.port}")
return
except (httpx.ConnectError, httpx.TimeoutException, httpx.ConnectTimeout):
pass
# Log progress every 15s
now = time.monotonic()
if now - last_log > 15:
elapsed = int(now - start)
logger.info(f"Waiting for llama.cpp server to load model... ({elapsed}s)")
last_log = now
await asyncio.sleep(1.0)
# Timeout — read the log to help debug
stderr = ""
try:
stderr = self._log_path.read_text()[-2000:]
except Exception:
pass
raise TimeoutError(
f"llama.cpp server did not become ready within {timeout}s.\n"
f"Check model compatibility and available memory.\n"
f"Server log: {stderr}"
)
async def stop(self) -> None:
"""Stop the llama.cpp server subprocess."""
if self._process is None:
return
logger.info("Stopping llama.cpp server...")
try:
# Send SIGTERM to the process group
if hasattr(os, "killpg"):
os.killpg(os.getpgid(self._process.pid), signal.SIGTERM)
else:
self._process.terminate()
# Wait up to 10s for graceful shutdown
try:
self._process.wait(timeout=10)
except subprocess.TimeoutExpired:
if hasattr(os, "killpg"):
os.killpg(os.getpgid(self._process.pid), signal.SIGKILL)
else:
self._process.kill()
self._process.wait(timeout=5)
except (ProcessLookupError, OSError):
pass # Process already exited
finally:
self._process = None
if hasattr(self, "_log_file") and self._log_file:
self._log_file.close()
self._log_file = None
logger.info("llama.cpp server stopped")
class LlamaCppLLM(LLMInterface):
"""
Built-in llama.cpp provider.
Manages a llama-cpp-python server subprocess and delegates to OpenAICompatibleLLM
for actual inference calls. Handles model downloading and server lifecycle.
"""
def __init__(
self,
provider: str,
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
model_path: str | None = None,
gpu_layers: int = -1,
context_size: int = 8192,
chat_format: str | None = None,
no_grammar: bool = False,
extra_args: str | None = None,
**kwargs: Any,
):
super().__init__(
provider=provider,
api_key=api_key or "llamacpp",
base_url=base_url or "",
model=model or DEFAULT_LLAMACPP_MODEL_ALIAS,
reasoning_effort=reasoning_effort,
)
self._model_path_str = model_path
self._gpu_layers = gpu_layers
self._context_size = context_size
self._chat_format = chat_format
self._no_grammar = no_grammar
self._extra_args = extra_args
self._server: LlamaCppServer | None = None
self._delegate: Any = None # OpenAICompatibleLLM, created after server starts
self._initialized = False
async def _ensure_initialized(self) -> None:
"""Lazy initialization: download model + start shared server on first use."""
if self._initialized:
return
global _shared_server
from .openai_compatible_llm import OpenAICompatibleLLM
async with _shared_server_lock:
if _shared_server is None:
# Resolve and potentially download the model
model_path = _resolve_model_path(self._model_path_str)
logger.info(f"Using GGUF model: {model_path}")
# Start the shared llama.cpp server
port = _find_free_port()
_shared_server = LlamaCppServer(
model_path=model_path,
port=port,
gpu_layers=self._gpu_layers,
context_size=self._context_size,
chat_format=self._chat_format,
extra_args=self._extra_args,
)
await _shared_server.start()
self._server = _shared_server
# Create the delegate that talks to the shared server's OpenAI-compatible API
if self._no_grammar:
logger.info("Grammar enforcement disabled (HINDSIGHT_API_LLAMACPP_NO_GRAMMAR=true)")
self._delegate = OpenAICompatibleLLM(
provider="llamacpp",
api_key="llamacpp",
base_url=self._server.base_url,
model=self.model,
reasoning_effort=self.reasoning_effort,
)
self._initialized = True
async def verify_connection(self) -> None:
"""Verify the llama.cpp server is running and can generate text."""
await self._ensure_initialized()
# Make a simple test call to verify the model can actually generate
await self._delegate.call(
messages=[{"role": "user", "content": "Say 'ok'"}],
max_completion_tokens=10,
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
scope="verification",
)
logger.info("llama.cpp LLM verification passed")
async def call(
self,
messages: list[dict[str, str]],
response_format: Any | None = None,
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
) -> Any:
"""Delegate call to the OpenAI-compatible API."""
await self._ensure_initialized()
return await self._delegate.call(
messages=messages,
response_format=response_format,
max_completion_tokens=max_completion_tokens,
temperature=temperature,
scope=scope,
max_retries=max_retries,
initial_backoff=initial_backoff,
max_backoff=max_backoff,
skip_validation=skip_validation,
strict_schema=strict_schema,
return_usage=return_usage,
)
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""Delegate tool calls to the OpenAI-compatible API."""
await self._ensure_initialized()
return await self._delegate.call_with_tools(
messages=messages,
tools=tools,
max_completion_tokens=max_completion_tokens,
temperature=temperature,
scope=scope,
max_retries=max_retries,
initial_backoff=initial_backoff,
max_backoff=max_backoff,
tool_choice=tool_choice,
)
async def cleanup(self) -> None:
"""Stop the shared llama.cpp server."""
global _shared_server
if self._delegate:
await self._delegate.cleanup()
self._delegate = None
# Stop the shared server (only the first cleanup call actually stops it)
async with _shared_server_lock:
if _shared_server is not None:
await _shared_server.stop()
_shared_server = None
self._server = None
self._initialized = False
@@ -0,0 +1,78 @@
"""
No-op LLM provider for chunk-only storage mode.
When the LLM provider is set to "none", the system operates without any LLM dependency.
Retain uses chunks mode (no fact extraction), and reflect/consolidation are disabled.
This provider acts as a safety net — if any code path unexpectedly tries to call the LLM,
it raises a clear error instead of a confusing connection failure.
"""
import logging
from typing import Any
from ..llm_interface import LLMInterface
from ..response_models import LLMToolCallResult
logger = logging.getLogger(__name__)
class LLMNotAvailableError(Exception):
"""Raised when an operation requires an LLM but the provider is set to 'none'."""
pass
class NoneLLM(LLMInterface):
"""
No-op LLM provider that rejects all LLM calls.
Used when HINDSIGHT_API_LLM_PROVIDER=none to run Hindsight as a chunk store
with semantic search but without LLM-based features (fact extraction, reflect,
consolidation).
"""
async def verify_connection(self) -> None:
"""No-op — no LLM connection to verify."""
logger.debug("NoneLLM: no LLM connection to verify (provider=none)")
async def call(
self,
messages: list[dict[str, str]],
response_format: Any | None = None,
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
) -> Any:
"""Raise LLMNotAvailableError — no LLM is configured."""
raise LLMNotAvailableError(
"LLM provider is set to 'none'. This operation requires an LLM. "
"Set HINDSIGHT_API_LLM_PROVIDER to a real provider (e.g., openai, anthropic, gemini)."
)
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""Raise LLMNotAvailableError — no LLM is configured."""
raise LLMNotAvailableError(
"LLM provider is set to 'none'. This operation requires an LLM. "
"Set HINDSIGHT_API_LLM_PROVIDER to a real provider (e.g., openai, anthropic, gemini)."
)
async def cleanup(self) -> None:
"""No-op — nothing to clean up."""
pass
@@ -6,7 +6,7 @@ This provider handles all OpenAI API-compatible models including:
- Groq: Fast inference with seed control and service tiers
- Ollama: Local models with native streaming API support
- LMStudio: Local models with OpenAI-compatible API
- MiniMax: MiniMax-M2.5 models with 204K context window
- MiniMax: MiniMax-M2.7 models with 1M context window
Features:
- Reasoning models with extended thinking (o1, o3, GPT-5 families)
@@ -24,6 +24,7 @@ import os
import re
import time
from typing import Any
from urllib.parse import parse_qs, urlparse, urlunparse
import httpx
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
@@ -32,6 +33,7 @@ from hindsight_api.config import DEFAULT_LLM_TIMEOUT, ENV_LLM_TIMEOUT
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
from hindsight_api.worker.stage import set_stage
logger = logging.getLogger(__name__)
@@ -39,6 +41,25 @@ logger = logging.getLogger(__name__)
DEFAULT_LLM_SEED = 4242
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.
"""
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
class OpenAICompatibleLLM(LLMInterface):
"""
LLM provider for OpenAI-compatible APIs.
@@ -48,7 +69,7 @@ class OpenAICompatibleLLM(LLMInterface):
- Groq: Fast inference with seed control and service tiers
- Ollama: Local models with native streaming API for better structured output
- LMStudio: Local models with OpenAI-compatible API
- MiniMax: MiniMax-M2.5 models via OpenAI-compatible API (https://api.minimax.io/v1)
- MiniMax: MiniMax-M2.7 models via OpenAI-compatible API (https://api.minimax.io/v1)
"""
def __init__(
@@ -60,6 +81,7 @@ class OpenAICompatibleLLM(LLMInterface):
reasoning_effort: str = "low",
timeout: float | None = None,
groq_service_tier: str | None = None,
extra_body: dict[str, Any] | None = None,
**kwargs: Any,
):
"""
@@ -73,12 +95,13 @@ class OpenAICompatibleLLM(LLMInterface):
reasoning_effort: Reasoning effort level for supported models ("low", "medium", "high").
timeout: Request timeout in seconds (uses env var or 300s default).
groq_service_tier: Groq service tier ("on_demand", "flex", "auto").
extra_body: Extra body params merged into every API call.
**kwargs: Additional provider-specific parameters.
"""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
# Validate provider
valid_providers = ["openai", "groq", "ollama", "lmstudio", "minimax"]
valid_providers = ["openai", "groq", "ollama", "lmstudio", "llamacpp", "minimax", "volcano", "openrouter"]
if self.provider not in valid_providers:
raise ValueError(f"OpenAICompatibleLLM only supports: {', '.join(valid_providers)}. Got: {self.provider}")
@@ -92,26 +115,38 @@ class OpenAICompatibleLLM(LLMInterface):
self.base_url = "http://localhost:1234/v1"
elif self.provider == "minimax":
self.base_url = "https://api.minimax.io/v1"
elif self.provider == "openrouter":
self.base_url = "https://openrouter.ai/api/v1"
# For ollama/lmstudio, use dummy key if not provided
if self.provider in ("ollama", "lmstudio") and not self.api_key:
self.api_key = "local"
# Validate API key for cloud providers
if self.provider in ("openai", "groq", "minimax") and not self.api_key:
if self.provider in ("openai", "groq", "minimax", "openrouter") and not self.api_key:
raise ValueError(f"API key is required for {self.provider}")
# Service tier configuration (from config, not env vars)
self.groq_service_tier = groq_service_tier
self.openai_service_tier = kwargs.get("openai_service_tier")
# User-configured extra body params (merged into every API call)
self._config_extra_body = extra_body or {}
# Get timeout config
self.timeout = timeout or float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT)))
# Create OpenAI client
# Create OpenAI client — extract query params from base_url (e.g. Azure api-version)
client_kwargs: dict[str, Any] = {"api_key": self.api_key, "max_retries": 0}
if self.base_url:
client_kwargs["base_url"] = self.base_url
parsed = urlparse(self.base_url)
if parsed.query:
clean_url = urlunparse(parsed._replace(query=""))
client_kwargs["base_url"] = clean_url
default_query = {k: v[0] for k, v in parse_qs(parsed.query).items()}
client_kwargs["default_query"] = default_query
self.base_url = clean_url
else:
client_kwargs["base_url"] = self.base_url
if self.timeout:
client_kwargs["timeout"] = self.timeout
@@ -159,6 +194,36 @@ class OpenAICompatibleLLM(LLMInterface):
return None
def _max_tokens_param_name(self) -> str:
"""Return the correct parameter name for limiting response tokens.
Native OpenAI, Azure OpenAI, Groq, and llamacpp accept 'max_completion_tokens'.
Mistral and other OpenAI-compatible endpoints that haven't adopted the newer
parameter name require 'max_tokens', so when the openai provider is configured
with a non-Azure custom base_url we fall back to the widely-supported
'max_tokens'.
Reasoning models (GPT-5, o1, o3) only accept 'max_completion_tokens' and reject
'max_tokens' outright, so they always use the new parameter name regardless of
base_url.
"""
# Reasoning models (GPT-5, o1, o3, ...) only accept max_completion_tokens.
# Azure OpenAI + GPT-5 is the canonical example: issue #978.
if self._supports_reasoning_model():
return "max_completion_tokens"
# Native OpenAI (no custom base URL), Groq, and llamacpp use max_completion_tokens
if self.provider in ("groq", "llamacpp"):
return "max_completion_tokens"
if self.provider == "openai" and not self.base_url:
return "max_completion_tokens"
# Azure OpenAI is fully OpenAI-API-compatible — detect it by hostname so users
# can keep provider=openai + an Azure base_url (the documented setup).
if self.provider == "openai" and self.base_url and ".openai.azure.com" in self.base_url:
return "max_completion_tokens"
# openai with custom base_url, ollama, lmstudio, minimax, volcano —
# use the widely-supported max_tokens
return "max_tokens"
async def call(
self,
messages: list[dict[str, str]],
@@ -231,9 +296,7 @@ class OpenAICompatibleLLM(LLMInterface):
# For reasoning models, enforce minimum to ensure space for reasoning + output
if is_reasoning_model and max_completion_tokens < 16000:
max_completion_tokens = 16000
call_params["max_completion_tokens"] = max_completion_tokens
# Temperature - reasoning models don't support custom temperature
call_params[self._max_tokens_param_name()] = max_completion_tokens
if temperature is not None and not is_reasoning_model:
# MiniMax requires temperature in (0.0, 1.0] — clamp accordingly
if self.provider == "minimax":
@@ -245,17 +308,17 @@ class OpenAICompatibleLLM(LLMInterface):
call_params["reasoning_effort"] = self.reasoning_effort
# Provider-specific parameters
extra_body: dict[str, Any] = {**self._config_extra_body}
if self.provider == "groq":
call_params["seed"] = DEFAULT_LLM_SEED
extra_body: dict[str, Any] = {}
# Add service_tier if configured
if self.groq_service_tier:
extra_body["service_tier"] = self.groq_service_tier
# Add reasoning parameters for reasoning models
if is_reasoning_model:
extra_body["include_reasoning"] = False
if extra_body:
call_params["extra_body"] = extra_body
if extra_body:
call_params["extra_body"] = extra_body
# Prepare response format ONCE before retry loop
if response_format is not None:
@@ -288,13 +351,23 @@ class OpenAICompatibleLLM(LLMInterface):
first_msg = call_params["messages"][0]
if isinstance(first_msg, dict) and isinstance(first_msg.get("content"), str):
first_msg["content"] = schema_msg + "\n\n" + first_msg["content"]
if self.provider not in ("lmstudio", "ollama"):
# LM Studio and Ollama don't support json_object response format reliably
# Providers that skip json_object grammar enforcement
skip_grammar = self.provider in ("lmstudio", "ollama", "volcano")
if self.provider == "llamacpp":
from hindsight_api.config import get_config
skip_grammar = get_config().llamacpp_no_grammar
if not skip_grammar:
call_params["response_format"] = {"type": "json_object"}
last_exception = None
for attempt in range(max_retries + 1):
# Surface attempt count in worker stage so JSON-schema retry loops
# are visible from logs (small models on strict structured output
# often loop here). Cheap no-op outside worker context.
if attempt > 0:
set_stage(f"llm.{self.provider}.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
if response_format is not None:
response = await self._client.chat.completions.create(**call_params)
@@ -313,20 +386,14 @@ class OpenAICompatibleLLM(LLMInterface):
if len(content) < original_len:
logger.debug(f"Stripped {original_len - len(content)} chars of reasoning tokens")
# For local models, they may wrap JSON in markdown code blocks
if self.provider in ("lmstudio", "ollama"):
clean_content = content
if "```json" in content:
clean_content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
clean_content = content.split("```")[1].split("```")[0].strip()
try:
json_data = json.loads(clean_content)
except json.JSONDecodeError:
# Fallback to parsing raw content
json_data = json.loads(content)
else:
# Log raw LLM response for debugging JSON parse issues
# Strip markdown code fences if present — any provider may
# produce these (confirmed with MiniMax, some Ollama models,
# Claude via proxies). No-op when content is already bare JSON.
clean_content = _strip_code_fences(content)
try:
json_data = json.loads(clean_content)
except json.JSONDecodeError:
# Fallback to parsing raw content in case stripping was wrong
try:
json_data = json.loads(content)
except json.JSONDecodeError as json_err:
@@ -551,7 +618,7 @@ class OpenAICompatibleLLM(LLMInterface):
}
if max_completion_tokens is not None:
call_params["max_completion_tokens"] = max_completion_tokens
call_params[self._max_tokens_param_name()] = max_completion_tokens
if temperature is not None:
# MiniMax requires temperature in (0.0, 1.0] — clamp accordingly
if self.provider == "minimax":
@@ -559,12 +626,17 @@ class OpenAICompatibleLLM(LLMInterface):
call_params["temperature"] = temperature
# Provider-specific parameters
extra_body: dict[str, Any] = {**self._config_extra_body}
if self.provider == "groq":
call_params["seed"] = DEFAULT_LLM_SEED
if extra_body:
call_params["extra_body"] = extra_body
last_exception = None
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.{self.provider}.tools.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await self._client.chat.completions.create(**call_params)
@@ -714,6 +786,8 @@ class OpenAICompatibleLLM(LLMInterface):
async with httpx.AsyncClient(timeout=300.0) as client:
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.ollama_native.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await client.post(native_url, json=payload)
response.raise_for_status()
@@ -721,26 +795,33 @@ class OpenAICompatibleLLM(LLMInterface):
result = response.json()
content = result.get("message", {}).get("content", "")
# Parse JSON response
# Strip markdown code fences if present (safety net —
# Ollama with schema enforcement usually returns bare JSON,
# but some models may still wrap in fences)
clean_content = _strip_code_fences(content)
try:
json_data = json.loads(content)
except json.JSONDecodeError as json_err:
content_preview = content[:500] if content else "<empty>"
if content and len(content) > 700:
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
logger.warning(
f"Ollama JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
f" Model: ollama/{self.model}\n"
f" Content length: {len(content) if content else 0} chars\n"
f" Content preview: {content_preview!r}"
)
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = json_err
continue
else:
raise
json_data = json.loads(clean_content)
except json.JSONDecodeError:
# Fallback to raw content
try:
json_data = json.loads(content)
except json.JSONDecodeError as json_err:
content_preview = content[:500] if content else "<empty>"
if content and len(content) > 700:
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
logger.warning(
f"Ollama JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
f" Model: ollama/{self.model}\n"
f" Content length: {len(content) if content else 0} chars\n"
f" Content preview: {content_preview!r}"
)
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = json_err
continue
else:
raise
# Extract token usage from Ollama response
duration = time.time() - start_time
@@ -137,7 +137,21 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
"RETURN_AS_TIMEZONE_AWARE": False,
}
results = self._search_dates(query, settings=settings)
# Wrap dateparser in a defensive try/except. dateparser has been
# observed to crash with internal errors (e.g., IndexError from
# locale.translate_search) on certain query inputs. A parser bug
# should not bring down the whole search/consolidation pipeline —
# treat any failure as "no temporal constraint found" so the caller
# can fall back to non-temporal retrieval.
try:
results = self._search_dates(query, settings=settings)
except Exception as e:
logger.warning(
"dateparser raised %s on query (treating as no temporal constraint): %s",
type(e).__name__,
e,
)
return QueryAnalysis(temporal_constraint=None)
if not results:
return QueryAnalysis(temporal_constraint=None)
@@ -316,6 +316,8 @@ async def run_reflect_agent(
response_schema: dict | None = None,
directives: list[dict[str, Any]] | None = None,
has_mental_models: bool = False,
include_observations: bool = True,
include_recall: bool = True,
budget: str | None = None,
max_context_tokens: int = 100_000,
) -> ReflectAgentResult:
@@ -355,7 +357,14 @@ async def run_reflect_agent(
directive_rules = _extract_directive_rules(directives) if directives else None
# Get tools for this agent (with directive compliance field if directives exist)
tools = get_reflect_tools(directive_rules=directive_rules)
tools = get_reflect_tools(
directive_rules=directive_rules,
include_mental_models=has_mental_models,
include_observations=include_observations,
include_recall=include_recall,
)
# Build set of enabled tool names to guard against LLM hallucinating disabled tool calls
enabled_tools: frozenset[str] = frozenset(t["function"]["name"] for t in tools if t.get("type") == "function")
# Build initial messages (directives are injected into system prompt at START and END)
system_prompt = build_system_prompt_for_tools(
@@ -538,19 +547,18 @@ async def run_reflect_agent(
llm_start = time.time()
# Determine tool_choice for this iteration.
# Force the full hierarchical retrieval path before allowing auto:
# With mental models:
# 0 → search_mental_models, 1 → search_observations, 2 → recall, 3+ → auto
# Without mental models:
# 0 → search_observations, 1 → recall, 2+ → auto
if iteration == 0 and has_mental_models:
iter_tool_choice: str | dict = {"type": "function", "function": {"name": "search_mental_models"}}
elif iteration == 0:
iter_tool_choice = {"type": "function", "function": {"name": "search_observations"}}
elif iteration == 1 and has_mental_models:
iter_tool_choice = {"type": "function", "function": {"name": "search_observations"}}
elif iteration == 1 or (iteration == 2 and has_mental_models):
iter_tool_choice = {"type": "function", "function": {"name": "recall"}}
# Force the full hierarchical retrieval path (only for enabled tools) before allowing auto.
# Build the forced sequence from the tools that are actually enabled.
forced_sequence = []
if has_mental_models:
forced_sequence.append("search_mental_models")
if include_observations:
forced_sequence.append("search_observations")
if include_recall:
forced_sequence.append("recall")
if iteration < len(forced_sequence):
iter_tool_choice: str | dict = {"type": "function", "function": {"name": forced_sequence[iteration]}}
else:
iter_tool_choice = "auto"
@@ -769,7 +777,17 @@ async def run_reflect_agent(
# Execute other tools in parallel (exclude done tool in all its format variants)
other_tools = [tc for tc in result.tool_calls if not _is_done_tool(tc.name)]
if other_tools:
# Add assistant message with tool calls
# Partition into enabled vs hallucinated (not in enabled_tools set)
allowed_tools = []
hallucinated_tools = []
for tc in other_tools:
norm = _normalize_tool_name(tc.name)
if enabled_tools is not None and norm not in enabled_tools and norm not in ("done", "expand"):
hallucinated_tools.append(tc)
else:
allowed_tools.append(tc)
# Build assistant message with all tool calls (LLM requires them for history)
messages.append(
{
"role": "assistant",
@@ -777,6 +795,23 @@ async def run_reflect_agent(
}
)
# Immediately reject hallucinated tool calls without adding to trace
for tc in hallucinated_tools:
messages.append(
{
"role": "tool",
"tool_call_id": tc.id,
"name": tc.name,
"content": json.dumps(
{
"error": f"Tool '{_normalize_tool_name(tc.name)}' is not available. Use only the tools provided to you."
}
),
}
)
other_tools = allowed_tools
# Execute tools in parallel
tool_tasks = [
_execute_tool_with_timing(
@@ -785,6 +820,7 @@ async def run_reflect_agent(
search_observations_fn,
recall_fn,
expand_fn,
enabled_tools=enabled_tools,
)
for tc in other_tools
]
@@ -895,7 +931,7 @@ async def run_reflect_agent(
def _tool_call_to_dict(tc: "LLMToolCall") -> dict[str, Any]:
"""Convert LLMToolCall to OpenAI message format."""
return {
d: dict[str, Any] = {
"id": tc.id,
"type": "function",
"function": {
@@ -903,6 +939,9 @@ def _tool_call_to_dict(tc: "LLMToolCall") -> dict[str, Any]:
"arguments": json.dumps(tc.arguments),
},
}
if tc.thought_signature is not None:
d["thought_signature"] = tc.thought_signature
return d
async def _process_done_tool(
@@ -971,6 +1010,7 @@ async def _execute_tool_with_timing(
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
recall_fn: Callable[[str, int, int], Awaitable[dict[str, Any]]],
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
enabled_tools: frozenset[str] | None = None,
) -> tuple[dict[str, Any], int]:
"""Execute a tool call and return result with timing."""
from hindsight_api.tracing import get_tracer
@@ -1004,6 +1044,7 @@ async def _execute_tool_with_timing(
search_observations_fn,
recall_fn,
expand_fn,
enabled_tools=enabled_tools,
)
# Set success attributes
@@ -1043,11 +1084,16 @@ async def _execute_tool(
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
recall_fn: Callable[[str, int, int], Awaitable[dict[str, Any]]],
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
enabled_tools: frozenset[str] | None = None,
) -> dict[str, Any]:
"""Execute a single tool by name."""
# Normalize tool name for various LLM output formats
tool_name = _normalize_tool_name(tool_name)
# Guard against LLMs hallucinating calls to tools that were not provided
if enabled_tools is not None and tool_name not in enabled_tools and tool_name not in ("done", "expand"):
return {"error": f"Tool '{tool_name}' is not available. Use only the tools provided to you."}
if tool_name == "search_mental_models":
query = args.get("query")
if not query:
@@ -9,6 +9,7 @@ Implements hierarchical retrieval:
import logging
import uuid
from dataclasses import replace
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
@@ -29,6 +30,7 @@ async def tool_search_mental_models(
max_results: int = 5,
tags: list[str] | None = None,
tags_match: str = "any",
tag_groups: "list | None" = None,
exclude_ids: list[str] | None = None,
pending_consolidation: int = 0,
) -> dict[str, Any]:
@@ -52,7 +54,7 @@ async def tool_search_mental_models(
Dict with matching mental models including content and freshness info
"""
from ..memory_engine import fq_table
from ..search.tags import build_tags_where_clause
from ..search.tags import build_tag_groups_where_clause, build_tags_where_clause
# Build filters dynamically
filters = ""
@@ -65,6 +67,11 @@ async def tool_search_mental_models(
filters += f" {tag_clause}"
params.extend(tag_params)
if tag_groups:
groups_clause, groups_params, next_param = build_tag_groups_where_clause(tag_groups, next_param)
filters += f" {groups_clause}"
params.extend(groups_params)
if exclude_ids:
filters += f" AND id != ALL(${next_param}::text[])"
params.append(exclude_ids)
@@ -125,11 +132,13 @@ async def tool_search_observations(
max_tokens: int = 5000,
tags: list[str] | None = None,
tags_match: str = "any",
tag_groups: "list | None" = None,
last_consolidated_at: datetime | None = None,
pending_consolidation: int = 0,
source_facts_max_tokens: int = -1,
) -> dict[str, Any]:
"""
Search consolidated observations using recall with include_source_facts.
Search consolidated observations using recall.
Observations are auto-generated from memories. Returns freshness info
so the agent knows if it should also verify with recall().
@@ -144,23 +153,35 @@ async def tool_search_observations(
tags_match: How to match tags - "any" (OR), "all" (AND)
last_consolidated_at: When consolidation last ran (for staleness check)
pending_consolidation: Number of memories waiting to be consolidated
source_facts_max_tokens: Token budget for source facts (-1 = disabled, 0+ = enabled with limit)
Returns:
Dict with matching observations including freshness info and source memories
"""
include_source_facts = source_facts_max_tokens != -1
recall_kwargs: dict[str, Any] = {}
if include_source_facts and source_facts_max_tokens > 0:
recall_kwargs["max_source_facts_tokens"] = source_facts_max_tokens
# Use an internal request context so this recall is not billed as a
# user-facing operation. The reflect caller is already billed for the
# overall reflect operation; double-billing the sub-recalls would
# overcharge the customer.
internal_ctx = replace(request_context, internal=True)
result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
fact_type=["observation"],
max_tokens=max_tokens,
enable_trace=False,
request_context=request_context,
request_context=internal_ctx,
tags=tags,
tags_match=tags_match,
include_source_facts=True,
max_source_facts_tokens=-1, # No token limit — include all source facts
tag_groups=tag_groups,
include_source_facts=include_source_facts,
_connection_budget=1,
_quiet=True,
**recall_kwargs,
)
is_stale = pending_consolidation > 0
@@ -189,8 +210,11 @@ async def tool_recall(
max_tokens: int = 2048,
tags: list[str] | None = None,
tags_match: str = "any",
tag_groups: "list | None" = None,
connection_budget: int = 1,
max_chunk_tokens: int = 1000,
fact_types: list[str] | None = None,
include_chunks: bool = True,
) -> dict[str, Any]:
"""
Search memories using TEMPR retrieval.
@@ -207,21 +231,26 @@ async def tool_recall(
tags: Filter by tags (includes untagged memories)
tags_match: How to match tags - "any" (OR), "all" (AND), or "exact"
connection_budget: Max DB connections for this recall (default 1 for internal ops)
max_chunk_tokens: Maximum tokens for raw source chunk text (default 1000, always included)
max_chunk_tokens: Maximum tokens for raw source chunk text (default 1000)
fact_types: Optional filter for fact types to retrieve. Defaults to ["experience", "world"].
include_chunks: Whether to fetch raw chunk text alongside facts (default True).
Returns:
Dict with list of matching memories including raw chunk text
Dict with list of matching memories including raw chunk text (when include_chunks)
"""
include_chunks = True
# Only world/experience are valid for raw recall (observation is handled by search_observations)
recall_fact_type = [ft for ft in (fact_types or ["experience", "world"]) if ft in ("world", "experience")]
internal_ctx = replace(request_context, internal=True)
result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
fact_type=["experience", "world"],
fact_type=recall_fact_type,
max_tokens=max_tokens,
enable_trace=False,
request_context=request_context,
request_context=internal_ctx,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
_connection_budget=connection_budget,
_quiet=True, # Suppress logging for internal operations
include_chunks=include_chunks,
@@ -227,7 +227,12 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
}
def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
def get_reflect_tools(
directive_rules: list[str] | None = None,
include_mental_models: bool = True,
include_observations: bool = True,
include_recall: bool = True,
) -> list[dict]:
"""
Get the list of tools for the reflect agent.
@@ -239,16 +244,23 @@ def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
Args:
directive_rules: Optional list of directive rule strings. If provided,
the done() tool will require directive compliance confirmation.
include_mental_models: Whether to include the search_mental_models tool.
include_observations: Whether to include the search_observations tool.
include_recall: Whether to include the recall tool.
Returns:
List of tool definitions in OpenAI format
"""
tools = [
TOOL_SEARCH_MENTAL_MODELS,
TOOL_SEARCH_OBSERVATIONS,
TOOL_RECALL,
TOOL_EXPAND,
]
tools = []
if include_mental_models:
tools.append(TOOL_SEARCH_MENTAL_MODELS)
if include_observations:
tools.append(TOOL_SEARCH_OBSERVATIONS)
if include_recall:
tools.append(TOOL_RECALL)
tools.append(TOOL_EXPAND)
# Use directive-aware done tool if directives are present
if directive_rules:
@@ -8,9 +8,8 @@ API stability even if internal models change.
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
# Valid fact types for recall operations (excludes 'opinion' which is deprecated)
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "observation"])
@@ -20,6 +19,10 @@ class LLMToolCall(BaseModel):
id: str = Field(description="Unique identifier for this tool call")
name: str = Field(description="Name of the tool to call")
arguments: dict[str, Any] = Field(description="Arguments to pass to the tool")
thought_signature: str | None = Field(
default=None,
description="Opaque token required by Gemini 3.1+ thinking models to preserve thought context across turns",
)
class LLMToolCallResult(BaseModel):
@@ -155,6 +158,19 @@ class MemoryFact(BaseModel):
mentioned_at: str | None = Field(None, description="ISO format date when the fact was mentioned/learned")
document_id: str | None = Field(None, description="ID of the document this memory belongs to")
metadata: dict[str, str] | None = Field(None, description="User-defined metadata")
@field_validator("metadata", mode="before")
@classmethod
def parse_metadata(cls, v: Any) -> dict[str, str] | None:
"""Parse metadata from JSON string if needed (asyncpg may return JSONB as str)."""
if v is None:
return None
if isinstance(v, str):
import json
return json.loads(v)
return v
chunk_id: str | None = Field(
None, description="ID of the chunk this fact was extracted from (format: bank_id_document_id_chunk_index)"
)
@@ -10,32 +10,47 @@ from typing import TypedDict
from pydantic import BaseModel, Field
from ...config import get_config
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table, get_current_schema
from ..response_models import DispositionTraits
logger = logging.getLogger(__name__)
# Fact types that get per-bank partial HNSW indexes, mapped to their 4-char index suffix.
_HNSW_FACT_TYPES: dict[str, str] = {
# Fact types that get per-bank partial vector indexes, mapped to their 4-char index suffix.
_BANK_INDEX_FACT_TYPES: dict[str, str] = {
"world": "worl",
"experience": "expr",
"observation": "obsv",
}
def _hnsw_index_name(ft: str, internal_id: str) -> str:
"""Deterministic, schema-safe HNSW index name for a (bank, fact_type) pair.
def _bank_index_name(ft: str, internal_id: str) -> str:
"""Deterministic, schema-safe vector index name for a (bank, fact_type) pair.
Uses the first 16 hex chars of internal_id (8 bytes of entropy) unique
enough in practice, fits comfortably within PostgreSQL's 63-char identifier limit.
"""
uid = str(internal_id).replace("-", "")[:16]
return f"idx_mu_emb_{_HNSW_FACT_TYPES[ft]}_{uid}"
return f"idx_mu_emb_{_BANK_INDEX_FACT_TYPES[ft]}_{uid}"
async def create_bank_hnsw_indexes(conn, bank_id: str, internal_id: str) -> None:
"""Create per-(bank, fact_type) partial HNSW indexes for a newly created bank.
def _vector_index_clause() -> str:
"""Return the USING clause for vector index creation based on the configured extension."""
ext = get_config().vector_extension
if ext == "pgvectorscale":
return "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)"
elif ext == "vchord":
return "USING vchordrq (embedding vector_l2_ops)"
else: # pgvector (default)
return "USING hnsw (embedding vector_cosine_ops)"
async def create_bank_vector_indexes(conn, bank_id: str, internal_id: str) -> None:
"""Create per-(bank, fact_type) partial vector indexes for a newly created bank.
Respects the HINDSIGHT_API_VECTOR_EXTENSION config to use the appropriate
index type (HNSW for pgvector, DiskANN for pgvectorscale, vchordrq for vchord).
Called immediately after the bank row is first inserted. Safe on empty banks
(index build is instant). Idempotent via CREATE INDEX IF NOT EXISTS.
@@ -43,24 +58,25 @@ async def create_bank_hnsw_indexes(conn, bank_id: str, internal_id: str) -> None
"""
table = fq_table("memory_units")
escaped = bank_id.replace("'", "''")
for ft in _HNSW_FACT_TYPES:
idx = _hnsw_index_name(ft, internal_id)
using_clause = _vector_index_clause()
for ft in _BANK_INDEX_FACT_TYPES:
idx = _bank_index_name(ft, internal_id)
await conn.execute(
f"CREATE INDEX IF NOT EXISTS {idx} "
f"ON {table} USING hnsw (embedding vector_cosine_ops) "
f"ON {table} {using_clause} "
f"WHERE fact_type = '{ft}' AND bank_id = '{escaped}'"
)
async def drop_bank_hnsw_indexes(conn, internal_id: str) -> None:
"""Drop per-(bank, fact_type) partial HNSW indexes for a bank being deleted.
async def drop_bank_vector_indexes(conn, internal_id: str) -> None:
"""Drop per-(bank, fact_type) partial vector indexes for a bank being deleted.
Called before the bank row is deleted so internal_id is still known.
Idempotent via DROP INDEX IF EXISTS.
"""
schema = get_current_schema()
for ft in _HNSW_FACT_TYPES:
idx = _hnsw_index_name(ft, internal_id)
for ft in _BANK_INDEX_FACT_TYPES:
idx = _bank_index_name(ft, internal_id)
await conn.execute(f"DROP INDEX IF EXISTS {schema}.{idx}")
@@ -97,6 +113,22 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
Returns:
BankProfile with name, typed DispositionTraits, and mission
"""
profile, _ = await get_or_create_bank_profile(pool, bank_id)
return profile
async def get_or_create_bank_profile(pool, bank_id: str) -> tuple[BankProfile, bool]:
"""
Get bank profile, auto-creating with defaults if it doesn't exist.
Same as get_bank_profile, but also returns a flag indicating whether the
bank was freshly created on this call. Used by the memory engine to apply
the HINDSIGHT_API_DEFAULT_BANK_TEMPLATE hook on first bank creation.
Returns:
Tuple of (BankProfile, created) where created is True if the bank
did not exist before this call.
"""
async with acquire_with_retry(pool) as conn:
# Try to get existing bank
row = await conn.fetchrow(
@@ -113,15 +145,18 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
if isinstance(disposition_data, str):
disposition_data = json.loads(disposition_data)
return BankProfile(
name=row["name"],
disposition=DispositionTraits(**disposition_data),
mission=row["mission"] or "",
return (
BankProfile(
name=row["name"],
disposition=DispositionTraits(**disposition_data),
mission=row["mission"] or "",
),
False,
)
# Bank doesn't exist, create with defaults.
# Generate internal_id here so we control the value and can use it
# immediately for HNSW index creation without a RETURNING round-trip.
# immediately for vector index creation without a RETURNING round-trip.
internal_id = uuid.uuid4()
inserted = await conn.fetchval(
f"""
@@ -137,11 +172,15 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
internal_id,
)
if inserted:
# Fresh insert — create per-bank HNSW indexes (instant on empty bank)
await create_bank_hnsw_indexes(conn, bank_id, str(internal_id))
created = inserted is not None
if created:
# Fresh insert — create per-bank vector indexes (instant on empty bank)
await create_bank_vector_indexes(conn, bank_id, str(internal_id))
return BankProfile(name=bank_id, disposition=DispositionTraits(**DEFAULT_DISPOSITION), mission="")
return (
BankProfile(name=bank_id, disposition=DispositionTraits(**DEFAULT_DISPOSITION), mission=""),
created,
)
async def update_bank_disposition(pool, bank_id: str, disposition: dict[str, int]) -> None:
@@ -4,7 +4,9 @@ Chunk storage for retain pipeline.
Handles storage of document chunks in the database.
"""
import hashlib
import logging
from dataclasses import dataclass
from ..memory_engine import fq_table
from .types import ChunkMetadata
@@ -12,6 +14,61 @@ from .types import ChunkMetadata
logger = logging.getLogger(__name__)
def compute_chunk_hash(chunk_text: str) -> str:
"""Compute SHA256 hash of chunk text for delta comparison."""
return hashlib.sha256(chunk_text.encode()).hexdigest()
@dataclass
class ExistingChunk:
"""Represents a chunk already stored in the database."""
chunk_id: str
chunk_index: int
content_hash: str | None
async def load_existing_chunks(conn, bank_id: str, document_id: str) -> list[ExistingChunk]:
"""
Load existing chunk metadata for a document.
Returns list of ExistingChunk with chunk_id, chunk_index, and content_hash.
"""
rows = await conn.fetch(
f"""
SELECT chunk_id, chunk_index, content_hash
FROM {fq_table("chunks")}
WHERE document_id = $1 AND bank_id = $2
ORDER BY chunk_index
""",
document_id,
bank_id,
)
return [
ExistingChunk(
chunk_id=row["chunk_id"],
chunk_index=row["chunk_index"],
content_hash=row["content_hash"],
)
for row in rows
]
async def delete_chunks_by_ids(conn, chunk_ids: list[str]) -> None:
"""
Delete specific chunks by their IDs.
This cascades to memory_units (via FK with CASCADE delete)
and their links.
"""
if not chunk_ids:
return
await conn.execute(
f"DELETE FROM {fq_table('chunks')} WHERE chunk_id = ANY($1::text[])",
chunk_ids,
)
async def store_chunks_batch(conn, bank_id: str, document_id: str, chunks: list[ChunkMetadata]) -> dict[int, str]:
"""
Store document chunks in the database.
@@ -32,6 +89,7 @@ async def store_chunks_batch(conn, bank_id: str, document_id: str, chunks: list[
chunk_ids = []
chunk_texts = []
chunk_indices = []
content_hashes = []
chunk_id_map = {}
for chunk in chunks:
@@ -39,19 +97,30 @@ async def store_chunks_batch(conn, bank_id: str, document_id: str, chunks: list[
chunk_ids.append(chunk_id)
chunk_texts.append(chunk.chunk_text)
chunk_indices.append(chunk.chunk_index)
content_hashes.append(compute_chunk_hash(chunk.chunk_text))
chunk_id_map[chunk.chunk_index] = chunk_id
# Batch insert all chunks
# Batch upsert all chunks. ON CONFLICT makes this idempotent: re-submitting
# a retain under the same document_id (the pattern in vectorize-io/hindsight#977)
# may produce chunk_ids that already exist when upstream cascade-delete or
# delta-retain paths don't run (or race with a concurrent task). Overwriting
# is the correct behavior per the document_id grouping semantics — the caller
# intends this chunk to hold the latest content at that (document_id, index).
await conn.execute(
f"""
INSERT INTO {fq_table("chunks")} (chunk_id, document_id, bank_id, chunk_text, chunk_index)
SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::text[], $5::integer[])
INSERT INTO {fq_table("chunks")} (chunk_id, document_id, bank_id, chunk_text, chunk_index, content_hash)
SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::text[], $5::integer[], $6::text[])
ON CONFLICT (chunk_id) DO UPDATE SET
chunk_text = EXCLUDED.chunk_text,
chunk_index = EXCLUDED.chunk_index,
content_hash = EXCLUDED.content_hash
""",
chunk_ids,
[document_id] * len(chunk_texts),
[bank_id] * len(chunk_texts),
chunk_texts,
chunk_indices,
content_hashes,
)
return chunk_id_map
@@ -47,6 +47,16 @@ async def generate_embeddings_batch(embeddings_backend, texts: list[str]) -> lis
embeddings_backend.encode,
texts,
)
return embeddings
except Exception as e:
raise Exception(f"Failed to generate batch embeddings: {str(e)}")
# Guarantee 1:1 alignment with input texts. A silent length mismatch here
# propagates downstream as zip() drops items, eventually surfacing as an
# IndexError in retain mapping (see issue #1037).
if len(embeddings) != len(texts):
raise RuntimeError(
f"Embeddings backend returned {len(embeddings)} vectors for {len(texts)} input texts; "
"expected exact 1:1 alignment"
)
return embeddings
@@ -12,61 +12,27 @@ from .types import EntityLink, ProcessedFact
logger = logging.getLogger(__name__)
async def process_entities_batch(
entity_resolver,
conn,
bank_id: str,
unit_ids: list[str],
def _prepare_facts_for_entity_processing(
facts: list[ProcessedFact],
log_buffer: list[str] = None,
user_entities_per_content: dict[int, list[dict]] = None,
entity_labels: list | None = None,
) -> list[EntityLink]:
user_entities_per_content: dict[int, list[dict]] | None = None,
) -> tuple[list[str], list, list[list[dict]]]:
"""
Process entities for all facts and create entity links.
This function:
1. Extracts entity mentions from fact texts
2. Merges user-provided entities with LLM-extracted entities
3. Resolves entity names to canonical entities
4. Creates entity records in the database
5. Returns entity links ready for insertion
Args:
entity_resolver: EntityResolver instance for entity resolution
conn: Database connection
bank_id: Bank identifier
unit_ids: List of unit IDs (same length as facts)
facts: List of ProcessedFact objects
log_buffer: Optional buffer for detailed logging
user_entities_per_content: Dict mapping content_index to list of user-provided entities
Extract fact texts, dates, and merged entity lists from ProcessedFact objects.
Returns:
List of EntityLink objects for batch insertion
Tuple of (fact_texts, fact_dates, entities_per_fact)
"""
if not unit_ids or not facts:
return []
if len(unit_ids) != len(facts):
raise ValueError(f"Mismatch between unit_ids ({len(unit_ids)}) and facts ({len(facts)})")
user_entities_per_content = user_entities_per_content or {}
# Extract data for link_utils function
fact_texts = [fact.fact_text for fact in facts]
# Use occurred_start if available, otherwise use mentioned_at for entity timestamps
fact_dates = [fact.occurred_start if fact.occurred_start is not None else fact.mentioned_at for fact in facts]
# Convert EntityRef objects to dict format and merge with user-provided entities
entities_per_fact = []
for fact in facts:
# Start with LLM-extracted entities
llm_entities = [{"text": entity.name, "type": "CONCEPT"} for entity in (fact.entities or [])]
# Get user entities for this content (use content_index from fact)
user_entities = user_entities_per_content.get(fact.content_index, [])
# Merge with case-insensitive deduplication
seen_texts = {e["text"].lower() for e in llm_entities}
for user_entity in user_entities:
if user_entity["text"].lower() not in seen_texts:
@@ -80,8 +46,48 @@ async def process_entities_batch(
entities_per_fact.append(llm_entities)
# Use existing link_utils function for entity processing
entity_links = await link_utils.extract_entities_batch_optimized(
return fact_texts, fact_dates, entities_per_fact
async def resolve_entities(
entity_resolver,
conn,
bank_id: str,
unit_ids: list[str],
facts: list[ProcessedFact],
log_buffer: list[str] = None,
user_entities_per_content: dict[int, list[dict]] = None,
entity_labels: list | None = None,
) -> tuple[list[str], list[tuple], dict[str, list[str]]]:
"""
Phase 1: Resolve entity names to canonical IDs (read-heavy).
Should be called on a SEPARATE connection OUTSIDE the main write transaction
to avoid holding the transaction open during expensive trigram scans.
Args:
entity_resolver: EntityResolver instance
conn: Database connection (separate from the main write transaction)
bank_id: Bank identifier
unit_ids: Placeholder unit IDs (used only for grouping)
facts: List of ProcessedFact objects
log_buffer: Optional buffer for detailed logging
user_entities_per_content: Dict mapping content_index to user-provided entities
entity_labels: Optional entity label taxonomy
Returns:
Tuple of (resolved_entity_ids, entity_to_unit, unit_to_entity_ids)
to pass to build_entity_links().
"""
if not unit_ids or not facts:
return [], [], {}
if len(unit_ids) != len(facts):
raise ValueError(f"Mismatch between unit_ids ({len(unit_ids)}) and facts ({len(facts)})")
fact_texts, fact_dates, entities_per_fact = _prepare_facts_for_entity_processing(facts, user_entities_per_content)
return await link_utils.resolve_entities_only(
entity_resolver,
conn,
bank_id,
@@ -90,22 +96,67 @@ async def process_entities_batch(
"", # context (not used in current implementation)
fact_dates,
entities_per_fact,
log_buffer, # Pass log_buffer for detailed logging
log_buffer,
entity_labels=entity_labels,
)
return entity_links
async def build_entity_links(
entity_resolver,
conn,
bank_id: str,
unit_ids: list[str],
resolved_entity_ids: list[str],
entity_to_unit: list[tuple],
unit_to_entity_ids: dict[str, list[str]],
log_buffer: list[str] = None,
skip_unit_entities_insert: bool = False,
) -> list[EntityLink]:
"""
Build entity links for UI graph visualization.
Queries unit_entities to find shared entities between new and existing units,
then generates EntityLink objects. When called from Phase 3 (post-transaction),
set skip_unit_entities_insert=True since unit_entities were already inserted
in Phase 2.
Args:
entity_resolver: EntityResolver instance
conn: Database connection
bank_id: Bank identifier
unit_ids: Actual unit IDs (must already be inserted in the DB)
resolved_entity_ids: From resolve_entities()
entity_to_unit: From resolve_entities()
unit_to_entity_ids: From resolve_entities()
log_buffer: Optional buffer for detailed logging
skip_unit_entities_insert: Skip unit_entities INSERT (already done in Phase 2)
Returns:
List of EntityLink objects for batch insertion
"""
return await link_utils.build_entity_links_from_resolved(
entity_resolver,
conn,
bank_id,
unit_ids,
resolved_entity_ids,
entity_to_unit,
unit_to_entity_ids,
log_buffer,
skip_unit_entities_insert=skip_unit_entities_insert,
)
async def insert_entity_links_batch(conn, entity_links: list[EntityLink]) -> None:
async def insert_entity_links_batch(conn, entity_links: list[EntityLink], bank_id: str) -> None:
"""
Insert entity links in batch.
Args:
conn: Database connection
entity_links: List of EntityLink objects
bank_id: Bank identifier (stored directly on memory_links for fast filtering)
"""
if not entity_links:
return
await link_utils.insert_entity_links_batch(conn, entity_links)
await link_utils.insert_entity_links_batch(conn, entity_links, bank_id)
@@ -87,7 +87,7 @@ class Fact(BaseModel):
# Required fields
fact: str = Field(description="Combined fact text: what | when | where | who | why")
fact_type: Literal["world", "experience", "opinion"] = Field(description="Perspective: world/experience/opinion")
fact_type: Literal["world", "experience"] = Field(description="Perspective: world/experience")
# Optional temporal fields
occurred_start: str | None = None
@@ -159,7 +159,9 @@ class ExtractedFact(BaseModel):
fact_kind: str = Field(default="conversation", description="'event' or 'conversation'")
occurred_start: str | None = Field(default=None, description="ISO timestamp for events")
occurred_end: str | None = Field(default=None, description="ISO timestamp for event end")
fact_type: Literal["world", "assistant"] = Field(description="'world' or 'assistant'")
fact_type: Literal["world", "assistant"] = Field(
description="'world' = objective/external facts. 'assistant' = first-person actions, experiences, or observations by the speaker."
)
entities: list[Entity] | None = Field(default=None, description="People, places, concepts")
causal_relations: list[FactCausalRelation] | None = Field(
default=None, description="Links to previous facts (target_index < this fact's index)"
@@ -261,7 +263,7 @@ class ExtractedFactVerbose(BaseModel):
)
fact_type: Literal["world", "assistant"] = Field(
description="'world' = about the user/others (background, experiences). 'assistant' = experience with the assistant."
description="'world' = objective/external facts about other people, events, general knowledge. 'assistant' = first-person actions, experiences, or observations by the speaker (e.g., 'I changed X', 'I discovered Y')."
)
entities: list[Entity] | None = Field(
@@ -332,6 +334,45 @@ class FactExtractionResponseNoCausal(BaseModel):
facts: list[ExtractedFactNoCausal] = Field(description="List of extracted factual statements")
class VerbatimExtractedFact(BaseModel):
"""
Schema for verbatim extraction mode.
Omits 'what' entirely the original chunk text is used as fact_text in code.
The LLM only extracts metadata: entities, temporal info, location, people.
"""
model_config = ConfigDict(
json_schema_mode="validation",
json_schema_extra={"required": ["when", "where", "who", "fact_type"]},
)
when: str = Field(description="When it happened. 'N/A' if unknown.")
where: str = Field(description="Location if relevant. 'N/A' if none.")
who: str = Field(description="People involved with relationships. 'N/A' if general.")
fact_kind: str = Field(default="conversation", description="'event' or 'conversation'")
occurred_start: str | None = Field(default=None, description="ISO timestamp for events")
occurred_end: str | None = Field(default=None, description="ISO timestamp for event end")
fact_type: Literal["world", "assistant"] = Field(
description="'world' = objective/external facts. 'assistant' = first-person actions, experiences, or observations by the speaker."
)
entities: list[Entity] | None = Field(default=None, description="People, places, concepts")
@field_validator("entities", mode="before")
@classmethod
def ensure_entities_list(cls, v):
if v is None:
return []
return v
class VerbatimFactExtractionResponse(BaseModel):
"""Response for verbatim extraction mode (one entry per chunk, no fact text)."""
facts: list[VerbatimExtractedFact] = Field(description="List of metadata entries (one per chunk)")
def chunk_text(text: str, max_chars: int) -> list[str]:
"""
Split text into chunks, preserving conversation structure when possible.
@@ -462,8 +503,8 @@ fact_kind:
- "conversation": Ongoing state, preference, trait (no dates)
fact_type:
- "world": About user's life, other people, external events
- "assistant": Interactions with assistant (requests, recommendations)
- "world": About other people, external events, general knowledge, objective facts
- "assistant": First-person actions, experiences, or observations by the speaker/author (e.g., "I changed X", "I discovered Y", "I debugged Z"). Also includes interactions with the user (requests, recommendations). If the narrator describes something they did, tried, learned, or decided use "assistant".
TEMPORAL HANDLING
@@ -552,13 +593,34 @@ CUSTOM_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
examples="", # No examples for custom mode
)
# Verbatim mode: preserve the original text exactly, but still extract metadata
_VERBATIM_GUIDELINES = """══════════════════════════════════════════════════════════════════════════
VERBATIM MODE Extract metadata only
The original text will be stored as-is in code. Your ONLY job is to extract metadata.
RULES:
- Produce EXACTLY ONE entry per input chunk.
- DO NOT include a "what" field it is not part of the output schema.
- Extract all entities (people, places, organizations, objects, concepts).
- Extract temporal information (occurred_start, occurred_end, fact_kind, when).
- Extract location (where) and people (who).
- fact_type: use "world" unless the content is clearly an interaction with the assistant."""
VERBATIM_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
retain_mission_section="{retain_mission_section}",
extraction_guidelines=_VERBATIM_GUIDELINES,
examples="",
)
# Verbose extraction prompt - detailed, comprehensive facts (legacy mode)
VERBOSE_FACT_EXTRACTION_PROMPT = """Extract facts from text into structured format with FIVE required dimensions - BE EXTREMELY DETAILED.
LANGUAGE: MANDATORY Detect the language of the input text and produce ALL output in that EXACT same language. You are STRICTLY FORBIDDEN from translating or switching to any other language. Every single word of your output must be in the same language as the input. Do NOT output in a different language under any circumstance.
{retain_mission_section}
FACT FORMAT - ALL FIVE DIMENSIONS REQUIRED - MAXIMUM VERBOSITY
@@ -769,7 +831,13 @@ def _build_extraction_prompt_and_schema(config) -> tuple[str, type]:
custom_instructions=config.retain_custom_instructions,
)
elif extraction_mode == "verbose":
prompt = VERBOSE_FACT_EXTRACTION_PROMPT
prompt = VERBOSE_FACT_EXTRACTION_PROMPT.format(
retain_mission_section=retain_mission_section,
)
elif extraction_mode == "verbatim":
prompt = VERBATIM_FACT_EXTRACTION_PROMPT.format(
retain_mission_section=retain_mission_section,
)
else:
base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
prompt = base_prompt.format(
@@ -777,7 +845,11 @@ def _build_extraction_prompt_and_schema(config) -> tuple[str, type]:
)
# Add causal relationships section if enabled
if extract_causal_links:
# Verbatim mode never uses causal relations (no fact text to relate causally)
if extraction_mode == "verbatim":
base_fact_class = VerbatimExtractedFact
base_response_class = VerbatimFactExtractionResponse
elif extract_causal_links:
prompt = prompt + CAUSAL_RELATIONSHIPS_SECTION
base_fact_class = ExtractedFactVerbose if extraction_mode == "verbose" else ExtractedFact
base_response_class = FactExtractionResponseVerbose if extraction_mode == "verbose" else FactExtractionResponse
@@ -837,6 +909,7 @@ def _build_user_message(
event_date: datetime | None,
context: str,
metadata: dict[str, str] | None = None,
agent_name: str | None = None,
) -> str:
"""Build user message for fact extraction."""
from .orchestrator import parse_datetime_flexible
@@ -855,11 +928,15 @@ def _build_user_message(
metadata_lines = "\n".join(f" {k}: {v}" for k, v in metadata.items())
metadata_section = f"\nMetadata:\n{metadata_lines}"
narrator_section = ""
if agent_name:
narrator_section = f'\nNarrator: {agent_name} (AI agent — first-person statements like "I did X" are the agent\'s own actions; classify as "assistant")'
return f"""Extract facts from the following text chunk.
Chunk: {chunk_index + 1}/{total_chunks}
Event Date: {event_date_str}
Context: {sanitized_context}{metadata_section}
Context: {sanitized_context}{metadata_section}{narrator_section}
Text:
{sanitized_chunk}"""
@@ -923,7 +1000,7 @@ async def _extract_facts_from_chunk(
extract_causal_links = config.retain_extract_causal_links
# Build user message using helper function
user_message = _build_user_message(chunk, chunk_index, total_chunks, event_date, context, metadata)
user_message = _build_user_message(chunk, chunk_index, total_chunks, event_date, context, metadata, agent_name)
# Retry logic for JSON validation errors
# Use retain-specific overrides if set, otherwise fall back to global LLM config
@@ -983,7 +1060,7 @@ async def _extract_facts_from_chunk(
f"LLM response missing 'facts' field or returned empty list. "
f"Response: {extraction_response_json}. "
f"Input: "
f"date: {event_date.isoformat()}, "
f"date: {event_date.isoformat() if event_date else 'unset'}, "
f"context: {context if context else 'none'}, "
f"text: {chunk}"
)
@@ -1012,33 +1089,21 @@ async def _extract_facts_from_chunk(
if not what:
what = get_value("factual_core")
if not what:
logger.warning(f"Skipping fact {i}: missing 'what' field")
continue
# In verbatim mode, 'what' is intentionally absent — text is backfilled from chunk
if extraction_mode != "verbatim":
logger.warning(f"Skipping fact {i}: missing 'what' field")
continue
# Critical field: fact_type
# LLM uses "assistant" but we convert to "experience" for storage
original_fact_type = llm_fact.get("fact_type")
fact_type = original_fact_type
# Convert "assistant" → "experience" for storage
if fact_type == "assistant":
# Critical field: fact_type — "assistant" maps to "experience", everything else is "world".
# If fact_type is unexpected, fall back to fact_kind before defaulting to "world".
raw_fact_type = llm_fact.get("fact_type")
if raw_fact_type == "assistant":
fact_type = "experience"
# Validate fact_type (after conversion)
if fact_type not in ["world", "experience", "opinion"]:
# Try to fix common mistakes - check if they swapped fact_type and fact_kind
fact_kind = llm_fact.get("fact_kind")
if fact_kind == "assistant":
fact_type = "experience"
elif fact_kind in ["world", "experience", "opinion"]:
fact_type = fact_kind
else:
# Default to 'world' if we can't determine
fact_type = "world"
logger.warning(
f"Fact {i}: defaulting to fact_type='world' "
f"(original fact_type={original_fact_type!r}, fact_kind={fact_kind!r})"
)
elif raw_fact_type == "world":
fact_type = "world"
else:
raw_fact_kind = llm_fact.get("fact_kind")
fact_type = "experience" if raw_fact_kind == "assistant" else "world"
# Get fact_kind for temporal handling (but don't store it)
fact_kind = llm_fact.get("fact_kind", "conversation")
@@ -1046,19 +1111,23 @@ async def _extract_facts_from_chunk(
fact_kind = "conversation"
# Build combined fact text from the 4 dimensions: what | when | who | why
# In verbatim mode, leave combined_text empty — _collapse_to_verbatim backfills it
fact_data = {}
combined_parts = [what]
if extraction_mode == "verbatim":
combined_text = ""
else:
combined_parts = [what]
if when:
combined_parts.append(f"When: {when}")
if when:
combined_parts.append(f"When: {when}")
if who:
combined_parts.append(f"Involving: {who}")
if who:
combined_parts.append(f"Involving: {who}")
if why:
combined_parts.append(why)
if why:
combined_parts.append(why)
combined_text = " | ".join(combined_parts)
combined_text = " | ".join(combined_parts)
# Add temporal fields
# For events: occurred_start/occurred_end (when the event happened)
@@ -1400,28 +1469,76 @@ async def extract_facts_from_text(
f"chunk_size={config.retain_chunk_size:,}) - starting parallel LLM extraction"
)
tasks = [
_extract_facts_with_auto_split(
chunk=chunk,
chunk_index=i,
total_chunks=len(chunks),
event_date=event_date,
context=context,
llm_config=llm_config,
config=config,
agent_name=agent_name,
metadata=metadata,
)
for i, chunk in enumerate(chunks)
]
chunk_results = await asyncio.gather(*tasks)
# Per-chunk retry wrapper: each chunk gets up to MAX_CHUNK_RETRIES attempts.
# This handles transient LLM failures (timeouts, rate limits, malformed responses)
# without discarding the entire batch. If a chunk still fails after all retries,
# the ENTIRE retain fails — we do not accept partial extraction.
MAX_CHUNK_RETRIES = 3
CHUNK_RETRY_BASE_DELAY = 2.0 # seconds, doubles each retry
async def _extract_chunk_with_retry(chunk: str, chunk_index: int) -> tuple:
"""Extract facts from a single chunk with retries on failure."""
last_exception = None
for attempt in range(MAX_CHUNK_RETRIES):
try:
return await _extract_facts_with_auto_split(
chunk=chunk,
chunk_index=chunk_index,
total_chunks=len(chunks),
event_date=event_date,
context=context,
llm_config=llm_config,
config=config,
agent_name=agent_name,
metadata=metadata,
)
except Exception as e:
last_exception = e
if attempt < MAX_CHUNK_RETRIES - 1:
delay = CHUNK_RETRY_BASE_DELAY * (2**attempt)
logger.warning(
f"Chunk {chunk_index}/{len(chunks)} extraction failed "
f"(attempt {attempt + 1}/{MAX_CHUNK_RETRIES}): "
f"{type(e).__name__}. Retrying in {delay:.0f}s..."
)
await asyncio.sleep(delay)
else:
logger.error(
f"Chunk {chunk_index}/{len(chunks)} extraction failed after "
f"{MAX_CHUNK_RETRIES} attempts: {type(e).__name__}: {e}"
)
raise last_exception
tasks = [_extract_chunk_with_retry(chunk, i) for i, chunk in enumerate(chunks)]
# return_exceptions=True so we can collect all results even if some chunks
# exhausted their retries. We check for failures below and fail the retain
# if ANY chunk could not be extracted — partial extraction is not acceptable.
chunk_results = await asyncio.gather(*tasks, return_exceptions=True)
all_facts = []
chunk_metadata = [] # [(chunk_text, fact_count), ...]
total_usage = TokenUsage()
for chunk, (chunk_facts, chunk_usage) in zip(chunks, chunk_results):
failed_chunks = []
for i, (chunk, result) in enumerate(zip(chunks, chunk_results)):
if isinstance(result, Exception):
failed_chunks.append((i, result))
continue
chunk_facts, chunk_usage = result
all_facts.extend(chunk_facts)
chunk_metadata.append((chunk, len(chunk_facts)))
total_usage = total_usage + chunk_usage
if failed_chunks:
# Fail the entire retain — partial extraction is not acceptable.
# All successfully extracted facts are discarded because the transaction
# hasn't committed yet. The worker poller will retry the entire task.
failed_summary = ", ".join(f"chunk {idx}: {type(err).__name__}" for idx, err in failed_chunks[:5])
raise RuntimeError(
f"Fact extraction failed: {len(failed_chunks)}/{len(chunks)} chunks failed "
f"after {MAX_CHUNK_RETRIES} retries each. First failures: {failed_summary}"
)
return all_facts, chunk_metadata, total_usage
@@ -1520,7 +1637,13 @@ async def extract_facts_from_contents_batch_api(
# Build user message using helper function
user_message = _build_user_message(
chunk, chunk_index_in_content, len(chunks), item.event_date, item.context, item.metadata or None
chunk,
chunk_index_in_content,
len(chunks),
item.event_date,
item.context,
item.metadata or None,
agent_name,
)
# Build request body using helper function
@@ -1682,23 +1805,17 @@ async def extract_facts_from_contents_batch_api(
who = get_value("who")
why = get_value("why")
# Critical field: fact_type
original_fact_type = llm_fact.get("fact_type")
fact_type = original_fact_type
# Convert "assistant" → "experience"
if fact_type == "assistant":
# Critical field: fact_type — only "assistant" maps to "experience", everything else is "world"
# Critical field: fact_type — "assistant" maps to "experience", everything else is "world".
# If fact_type is unexpected, fall back to fact_kind before defaulting to "world".
raw_fact_type = llm_fact.get("fact_type")
if raw_fact_type == "assistant":
fact_type = "experience"
# Validate fact_type
if fact_type not in ["world", "experience", "opinion"]:
fact_kind = llm_fact.get("fact_kind")
if fact_kind == "assistant":
fact_type = "experience"
elif fact_kind in ["world", "experience", "opinion"]:
fact_type = fact_kind
else:
fact_type = "world"
elif raw_fact_type == "world":
fact_type = "world"
else:
raw_fact_kind = llm_fact.get("fact_kind")
fact_type = "experience" if raw_fact_kind == "assistant" else "world"
# Build combined fact text
combined_parts = [what]
@@ -1889,6 +2006,52 @@ async def extract_facts_from_contents_batch_api(
return extracted_facts, chunks_metadata, total_usage
def _extract_facts_chunks(
contents: list[RetainContent],
config,
) -> tuple[list[ExtractedFactType], list[ChunkMetadata], TokenUsage]:
"""
chunks mode: no LLM call, no entity extraction.
Each chunk becomes one memory unit with the raw text as fact_text.
User-provided entities from RetainContent.entities are picked up downstream
by entity_processing.py they are the sole source of entity data in this mode.
"""
extracted_facts: list[ExtractedFactType] = []
chunks_metadata: list[ChunkMetadata] = []
global_chunk_idx = 0
for content_index, content in enumerate(contents):
chunks = chunk_text(content.content, config.retain_chunk_size)
for chunk in chunks:
chunks_metadata.append(
ChunkMetadata(
chunk_text=chunk,
fact_count=1,
content_index=content_index,
chunk_index=global_chunk_idx,
)
)
extracted_facts.append(
ExtractedFactType(
fact_text=chunk,
fact_type="world",
entities=[],
content_index=content_index,
chunk_index=global_chunk_idx,
context=content.context,
mentioned_at=content.event_date,
metadata=content.metadata,
tags=content.tags,
observation_scopes=content.observation_scopes,
)
)
global_chunk_idx += 1
_add_temporal_offsets(extracted_facts, contents)
return extracted_facts, chunks_metadata, TokenUsage()
async def extract_facts_from_contents(
contents: list[RetainContent],
llm_config,
@@ -1924,6 +2087,11 @@ async def extract_facts_from_contents(
if not contents:
return [], [], TokenUsage()
# chunks mode: skip LLM entirely, store each chunk as-is
# Must come before the batch-API check so no LLM queue/locks are acquired
if config.retain_extraction_mode == "chunks":
return _extract_facts_chunks(contents, config)
# Route to batch API if enabled
if config.retain_batch_enabled:
return await extract_facts_from_contents_batch_api(
@@ -1946,8 +2114,9 @@ async def extract_facts_from_contents(
)
fact_extraction_tasks.append(task)
# Step 2: Wait for all fact extractions to complete
all_fact_results = await asyncio.gather(*fact_extraction_tasks)
# Step 2: Wait for all fact extractions to complete.
# Use return_exceptions=True so one content item failure doesn't discard the rest.
all_fact_results = await asyncio.gather(*fact_extraction_tasks, return_exceptions=True)
# Step 3: Flatten and convert to typed objects
extracted_facts: list[ExtractedFactType] = []
@@ -1957,9 +2126,16 @@ async def extract_facts_from_contents(
global_chunk_idx = 0
global_fact_idx = 0
for content_index, (content, (facts_from_llm, chunks_from_llm, content_usage)) in enumerate(
zip(contents, all_fact_results)
):
# Filter out failed content items
valid_results = []
for content, result in zip(contents, all_fact_results):
if isinstance(result, Exception):
logger.warning(f"Content extraction failed (skipping): {type(result).__name__}: {result}")
valid_results.append((content, ([], [], TokenUsage())))
else:
valid_results.append((content, result))
for content_index, (content, (facts_from_llm, chunks_from_llm, content_usage)) in enumerate(valid_results):
total_usage = total_usage + content_usage
chunk_start_idx = global_chunk_idx
@@ -2013,15 +2189,46 @@ async def extract_facts_from_contents(
global_fact_idx += 1
fact_idx_in_content += 1
# Step 4: Add time offsets to preserve ordering within each content
# Step 4: For verbatim mode, collapse to one fact per chunk with original text
if config.retain_extraction_mode == "verbatim":
extracted_facts = _collapse_to_verbatim(extracted_facts, chunks_metadata)
# Step 5: Add time offsets to preserve ordering within each content
_add_temporal_offsets(extracted_facts, contents)
# Step 5: Auto-tag facts from label groups with tag=True
# Step 6: Auto-tag facts from label groups with tag=True
_inject_label_tags(extracted_facts, config)
return extracted_facts, chunks_metadata, total_usage
def _collapse_to_verbatim(facts: list[ExtractedFactType], chunks: list[ChunkMetadata]) -> list[ExtractedFactType]:
"""
For verbatim mode: ensure one fact per chunk with the original chunk text preserved.
The LLM prompt asks for exactly one fact per chunk, but if it returns more,
this collapses them: keeps the first fact as representative, overrides its
fact_text with the raw chunk text, and merges entities from any extra facts.
"""
chunk_text_map = {c.chunk_index: c.chunk_text for c in chunks}
seen: dict[int, ExtractedFactType] = {}
result: list[ExtractedFactType] = []
for fact in facts:
if fact.chunk_index not in seen:
fact.fact_text = chunk_text_map.get(fact.chunk_index, fact.fact_text)
seen[fact.chunk_index] = fact
result.append(fact)
else:
# Merge entities from extra facts into the representative
representative = seen[fact.chunk_index]
for entity in fact.entities:
if entity not in representative.entities:
representative.entities.append(entity)
return result
def _parse_datetime(date_str: str):
"""Parse ISO datetime string."""
from dateutil import parser as date_parser
@@ -10,13 +10,30 @@ import uuid
from ...config import get_config
from ..memory_engine import fq_table
from .bank_utils import DEFAULT_DISPOSITION, create_bank_hnsw_indexes
from .bank_utils import DEFAULT_DISPOSITION, create_bank_vector_indexes
from .fact_extraction import _sanitize_text
from .types import ProcessedFact
logger = logging.getLogger(__name__)
async def get_document_content(
conn,
bank_id: str,
document_id: str,
) -> str | None:
"""Fetch the original_text of an existing document.
Returns None if the document does not exist.
"""
row = await conn.fetchval(
f"SELECT original_text FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2",
document_id,
bank_id,
)
return row
async def insert_facts_batch(
conn, bank_id: str, facts: list[ProcessedFact], document_id: str | None = None
) -> list[str]:
@@ -44,7 +61,6 @@ async def insert_facts_batch(
mentioned_ats = []
contexts = []
fact_types = []
confidence_scores = []
metadata_jsons = []
chunk_ids = []
document_ids = []
@@ -64,8 +80,6 @@ async def insert_facts_batch(
mentioned_ats.append(fact.mentioned_at)
contexts.append(_sanitize_text(fact.context))
fact_types.append(fact.fact_type)
# confidence_score is only for opinion facts
confidence_scores.append(1.0 if fact.fact_type == "opinion" else None)
metadata_jsons.append(json.dumps(fact.metadata))
chunk_ids.append(fact.chunk_id)
# Use per-fact document_id if available, otherwise fallback to batch-level document_id
@@ -81,9 +95,15 @@ async def insert_facts_batch(
if fact.entities:
signal_parts.extend(e.name for e in fact.entities)
if fact.occurred_start:
signal_parts.append(fact.occurred_start.strftime("%B %-d %Y"))
try:
signal_parts.append(fact.occurred_start.strftime("%B %d %Y").lstrip("0").replace(" 0", " "))
except (ValueError, AttributeError):
pass
if fact.occurred_end and fact.occurred_end != fact.occurred_start:
signal_parts.append(fact.occurred_end.strftime("%B %-d %Y"))
try:
signal_parts.append(fact.occurred_end.strftime("%B %d %Y").lstrip("0").replace(" 0", " "))
except (ValueError, AttributeError):
pass
text_signals_list.append(" ".join(signal_parts) if signal_parts else None)
# Batch insert all facts
@@ -97,18 +117,18 @@ async def insert_facts_batch(
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[], $15::jsonb[], $16::text[]
$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, confidence_score, metadata, chunk_id, document_id, tags_json,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags,
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, confidence_score, metadata, chunk_id, document_id,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
@@ -129,18 +149,18 @@ async def insert_facts_batch(
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[], $15::jsonb[], $16::text[]
$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, confidence_score, metadata, chunk_id, document_id, tags_json,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags,
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, confidence_score, metadata, chunk_id, document_id,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
@@ -162,7 +182,6 @@ async def insert_facts_batch(
mentioned_ats,
contexts,
fact_types,
confidence_scores,
metadata_jsons,
chunk_ids,
document_ids,
@@ -201,8 +220,8 @@ async def ensure_bank_exists(conn, bank_id: str) -> None:
internal_id,
)
if inserted:
# Fresh insert — create per-bank HNSW indexes
await create_bank_hnsw_indexes(conn, bank_id, str(internal_id))
# Fresh insert — create per-bank vector indexes
await create_bank_vector_indexes(conn, bank_id, str(internal_id))
async def handle_document_tracking(
@@ -215,7 +234,10 @@ async def handle_document_tracking(
document_tags: list[str] | None = None,
) -> None:
"""
Handle document tracking in the database.
Handle document tracking in the database (full-replace mode).
Deletes the existing document (cascading to all units and links) on the
first batch, then inserts the new document record.
Args:
conn: Database connection
@@ -232,22 +254,58 @@ async def handle_document_tracking(
combined_content = _sanitize_text(combined_content) or ""
content_hash = hashlib.sha256(combined_content.encode()).hexdigest()
# Always delete old document first if it exists (cascades to units and links)
# Delete old document first (cascades to units and links)
# Only delete on the first batch to avoid deleting data we just inserted
if is_first_batch:
await conn.fetchval(
f"DELETE FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2 RETURNING id", document_id, bank_id
f"DELETE FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2 RETURNING id",
document_id,
bank_id,
)
# Insert document (or update if exists from concurrent operations)
await _upsert_document_row(conn, bank_id, document_id, combined_content, content_hash, retain_params, document_tags)
async def upsert_document_metadata(
conn,
bank_id: str,
document_id: str,
combined_content: str,
retain_params: dict | None = None,
document_tags: list[str] | None = None,
) -> None:
"""
Update document metadata without deleting existing facts/chunks.
Used by delta retain: the document row is upserted but chunks and
memory_units are managed separately at the chunk level.
"""
import hashlib
combined_content = _sanitize_text(combined_content) or ""
content_hash = hashlib.sha256(combined_content.encode()).hexdigest()
await _upsert_document_row(conn, bank_id, document_id, combined_content, content_hash, retain_params, document_tags)
async def _upsert_document_row(
conn,
bank_id: str,
document_id: str,
combined_content: str,
content_hash: str,
retain_params: dict | None = None,
document_tags: list[str] | None = None,
) -> None:
"""Insert or update a document row."""
await conn.execute(
f"""
INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, metadata, retain_params, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7)
INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, retain_params, tags)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id, bank_id) DO UPDATE
SET original_text = EXCLUDED.original_text,
content_hash = EXCLUDED.content_hash,
metadata = EXCLUDED.metadata,
retain_params = EXCLUDED.retain_params,
tags = EXCLUDED.tags,
updated_at = NOW()
@@ -256,7 +314,37 @@ async def handle_document_tracking(
bank_id,
combined_content,
content_hash,
json.dumps({}), # Empty metadata dict
json.dumps(retain_params) if retain_params else None,
document_tags or [],
)
async def update_memory_units_tags(
conn,
bank_id: str,
document_id: str,
tags: list[str],
) -> int:
"""
Update tags on all memory_units belonging to a document.
Used during delta retain to propagate tag changes to unchanged facts.
Returns:
Number of memory units updated.
"""
result = await conn.execute(
f"""
UPDATE {fq_table("memory_units")}
SET tags = $3, updated_at = NOW()
WHERE bank_id = $1 AND document_id = $2
""",
bank_id,
document_id,
tags or [],
)
# result is a status string like "UPDATE 5"
try:
return int(result.split()[-1])
except (ValueError, IndexError):
return 0
@@ -32,17 +32,26 @@ async def create_temporal_links_batch(conn, bank_id: str, unit_ids: list[str]) -
return await link_utils.create_temporal_links_batch_per_fact(conn, bank_id, unit_ids, log_buffer=[])
async def create_semantic_links_batch(conn, bank_id: str, unit_ids: list[str], embeddings: list[list[float]]) -> int:
async def create_semantic_links_batch(
conn,
bank_id: str,
unit_ids: list[str],
embeddings: list[list[float]],
pre_computed_ann_links: list[tuple] | None = None,
) -> int:
"""
Create semantic links between facts.
Links facts that are semantically similar based on embeddings.
When pre_computed_ann_links are provided (from Phase 1), they are used
instead of running ANN queries inside the transaction.
Args:
conn: Database connection
bank_id: Bank identifier
unit_ids: List of unit IDs to create links for
embeddings: List of embedding vectors (same length as unit_ids)
pre_computed_ann_links: Pre-computed ANN results from Phase 1
Returns:
Number of semantic links created
@@ -53,10 +62,12 @@ async def create_semantic_links_batch(conn, bank_id: str, unit_ids: list[str], e
if len(unit_ids) != len(embeddings):
raise ValueError(f"Mismatch between unit_ids ({len(unit_ids)}) and embeddings ({len(embeddings)})")
return await link_utils.create_semantic_links_batch(conn, bank_id, unit_ids, embeddings, log_buffer=[])
return await link_utils.create_semantic_links_batch(
conn, bank_id, unit_ids, embeddings, log_buffer=[], pre_computed_ann_links=pre_computed_ann_links
)
async def create_causal_links_batch(conn, unit_ids: list[str], facts: list[ProcessedFact]) -> int:
async def create_causal_links_batch(conn, bank_id: str, unit_ids: list[str], facts: list[ProcessedFact]) -> int:
"""
Create causal links between facts.
@@ -94,6 +105,6 @@ async def create_causal_links_batch(conn, unit_ids: list[str], facts: list[Proce
else:
causal_relations_per_fact.append([])
link_count = await link_utils.create_causal_links_batch(conn, unit_ids, causal_relations_per_fact)
link_count = await link_utils.create_causal_links_batch(conn, bank_id, unit_ids, causal_relations_per_fact)
return link_count
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -25,6 +25,9 @@ class RetainContentDict(TypedDict, total=False):
observation_scopes: How to scope observations for consolidation (optional).
"per_tag" runs one pass per individual tag; "combined" (default) runs a
single pass with all tags; a list[list[str]] specifies exact passes.
update_mode: How to handle existing documents with the same document_id (optional).
"replace" (default) deletes old data and reprocesses. "append" concatenates
new content to the existing document and reprocesses.
"""
content: str # Required
@@ -37,6 +40,7 @@ class RetainContentDict(TypedDict, total=False):
observation_scopes: (
Literal["per_tag", "combined", "all_combinations"] | list[list[str]]
) # Observation scopes for consolidation
update_mode: Literal["replace", "append"]
@dataclass
@@ -107,7 +111,7 @@ class ExtractedFact:
"""
fact_text: str
fact_type: str # "world", "experience", "opinion", "observation"
fact_type: str # "world", "experience", "observation"
entities: list[str] = field(default_factory=list)
occurred_start: datetime | None = None
occurred_end: datetime | None = None
@@ -221,6 +225,45 @@ class ProcessedFact:
)
@dataclass
class Phase3Context:
"""
Data passed from Phase 2 to Phase 3 for entity link building.
Contains the unit IDs and entity resolution data needed to build
entity links for UI graph visualization after the write transaction commits.
"""
unit_ids: list[str] = field(default_factory=list)
resolved_entity_ids: list[str] = field(default_factory=list)
entity_to_unit: list[tuple] = field(default_factory=list)
unit_to_entity_ids: dict[str, list[str]] = field(default_factory=dict)
@dataclass
class EntityResolutionResult:
"""
Result of Phase 1 entity resolution.
Contains resolved entity IDs and the mapping data needed to remap
placeholder unit IDs to real IDs after fact insertion in Phase 2.
"""
resolved_entity_ids: list[str]
entity_to_unit: list[tuple]
unit_to_entity_ids: dict[str, list[str]]
@dataclass
class Phase1Result:
"""
Full result of Phase 1 (entity resolution + optional semantic ANN).
"""
entities: EntityResolutionResult
semantic_ann_links: list[tuple]
@dataclass
class EntityLink:
"""
@@ -248,7 +291,6 @@ class RetainBatch:
contents: list[RetainContent]
document_id: str | None = None
fact_type_override: str | None = None
confidence_score: float | None = None
document_tags: list[str] = field(default_factory=list) # Tags applied to all items
# Extracted data (populated during processing)
@@ -3,12 +3,11 @@ Search module for memory retrieval.
Provides modular search architecture:
- Retrieval: 4-way parallel (semantic + BM25 + graph + temporal)
- Graph retrieval: Pluggable strategies (BFS, PPR)
- Graph retrieval: Link expansion strategy
- Reranking: Pluggable strategies (heuristic, cross-encoder)
"""
from .graph_retrieval import BFSGraphRetriever, GraphRetriever
from .mpfp_retrieval import MPFPGraphRetriever
from .graph_retrieval import GraphRetriever
from .reranking import CrossEncoderReranker
from .retrieval import (
ParallelRetrievalResult,
@@ -21,7 +20,5 @@ __all__ = [
"set_default_graph_retriever",
"ParallelRetrievalResult",
"GraphRetriever",
"BFSGraphRetriever",
"MPFPGraphRetriever",
"CrossEncoderReranker",
]
@@ -2,17 +2,15 @@
Graph retrieval strategies for memory recall.
This module provides an abstraction for graph-based memory retrieval,
allowing different algorithms (BFS spreading activation, PPR, etc.) to be
swapped without changing the rest of the recall pipeline.
allowing different algorithms to be swapped without changing the rest
of the recall pipeline.
"""
import logging
from abc import ABC, abstractmethod
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from .tags import TagsMatch, filter_results_by_tags
from .types import MPFPTimings, RetrievalResult
from .tags import TagGroup, TagsMatch
from .types import GraphRetrievalTimings, RetrievalResult
logger = logging.getLogger(__name__)
@@ -29,7 +27,7 @@ class GraphRetriever(ABC):
@property
@abstractmethod
def name(self) -> str:
"""Return identifier for this retrieval strategy (e.g., 'bfs', 'mpfp')."""
"""Return identifier for this retrieval strategy (e.g., 'link_expansion')."""
pass
@abstractmethod
@@ -46,7 +44,8 @@ class GraphRetriever(ABC):
adjacency=None, # TypedAdjacency, optional pre-loaded graph
tags: list[str] | None = None, # Visibility scope tags for filtering
tags_match: TagsMatch = "any", # How to match tags: 'any' (OR) or 'all' (AND)
) -> tuple[list[RetrievalResult], MPFPTimings | None]:
tag_groups: list[TagGroup] | None = None, # Compound boolean tag filter groups
) -> tuple[list[RetrievalResult], GraphRetrievalTimings | None]:
"""
Retrieve relevant facts via graph traversal.
@@ -54,211 +53,15 @@ class GraphRetriever(ABC):
pool: Database connection pool
query_embedding_str: Query embedding as string (for finding entry points)
bank_id: Memory bank identifier
fact_type: Fact type to filter ('world', 'experience', 'opinion', 'observation')
fact_type: Fact type to filter ('world', 'experience', 'observation')
budget: Maximum number of nodes to explore/return
query_text: Original query text (optional, for some strategies)
semantic_seeds: Pre-computed semantic entry points (from semantic retrieval)
temporal_seeds: Pre-computed temporal entry points (from temporal retrieval)
adjacency: Pre-loaded typed adjacency graph (optional, for MPFP)
adjacency: Pre-loaded typed adjacency graph (optional)
tags: Optional list of tags for visibility filtering (OR matching)
Returns:
Tuple of (List of RetrievalResult with activation scores, optional timing info)
"""
pass
class BFSGraphRetriever(GraphRetriever):
"""
Graph retrieval using BFS-style spreading activation.
Starting from semantic entry points, spreads activation through
the memory graph (entity, temporal, causal links) using breadth-first
traversal with decaying activation.
This is the original Hindsight graph retrieval algorithm.
"""
def __init__(
self,
entry_point_limit: int = 5,
entry_point_threshold: float = 0.5,
activation_decay: float = 0.8,
min_activation: float = 0.1,
batch_size: int = 20,
):
"""
Initialize BFS graph retriever.
Args:
entry_point_limit: Maximum number of entry points to start from
entry_point_threshold: Minimum semantic similarity for entry points
activation_decay: Decay factor per hop (activation *= decay)
min_activation: Minimum activation to continue spreading
batch_size: Number of nodes to process per batch (for neighbor fetching)
"""
self.entry_point_limit = entry_point_limit
self.entry_point_threshold = entry_point_threshold
self.activation_decay = activation_decay
self.min_activation = min_activation
self.batch_size = batch_size
@property
def name(self) -> str:
return "bfs"
async def retrieve(
self,
pool,
query_embedding_str: str,
bank_id: str,
fact_type: str,
budget: int,
query_text: str | None = None,
semantic_seeds: list[RetrievalResult] | None = None,
temporal_seeds: list[RetrievalResult] | None = None,
adjacency=None, # Not used by BFS
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
) -> tuple[list[RetrievalResult], MPFPTimings | None]:
"""
Retrieve facts using BFS spreading activation.
Algorithm:
1. Find entry points (top semantic matches above threshold)
2. BFS traversal: visit neighbors, propagate decaying activation
3. Boost causal links (causes, enables, prevents)
4. Return visited nodes up to budget
Note: BFS finds its own entry points via embedding search.
The semantic_seeds, temporal_seeds, and adjacency parameters are accepted
for interface compatibility but not used.
"""
async with acquire_with_retry(pool) as conn:
results = await self._retrieve_with_conn(
conn, query_embedding_str, bank_id, fact_type, budget, tags=tags, tags_match=tags_match
)
return results, None
async def _retrieve_with_conn(
self,
conn,
query_embedding_str: str,
bank_id: str,
fact_type: str,
budget: int,
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
) -> list[RetrievalResult]:
"""Internal implementation with connection."""
from .tags import build_tags_where_clause_simple
tags_clause = build_tags_where_clause_simple(tags, 6, match=tags_match)
params = [query_embedding_str, bank_id, fact_type, self.entry_point_threshold, self.entry_point_limit]
if tags:
params.append(tags)
# Step 1: Find entry points
entry_points = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND embedding IS NOT NULL
AND fact_type = $3
AND (1 - (embedding <=> $1::vector)) >= $4
{tags_clause}
ORDER BY embedding <=> $1::vector
LIMIT $5
""",
*params,
)
if not entry_points:
logger.debug(
f"[BFS] No entry points found for fact_type={fact_type} (tags={tags}, tags_match={tags_match})"
)
return []
logger.debug(
f"[BFS] Found {len(entry_points)} entry points for fact_type={fact_type} "
f"(tags={tags}, tags_match={tags_match})"
)
# Step 2: BFS spreading activation
visited = set()
results = []
queue = [(RetrievalResult.from_db_row(dict(r)), r["similarity"]) for r in entry_points]
budget_remaining = budget
while queue and budget_remaining > 0:
# Collect a batch of nodes to process
batch_nodes = []
batch_activations = {}
while queue and len(batch_nodes) < self.batch_size and budget_remaining > 0:
current, activation = queue.pop(0)
unit_id = current.id
if unit_id not in visited:
visited.add(unit_id)
budget_remaining -= 1
current.activation = activation
results.append(current)
batch_nodes.append(current.id)
batch_activations[unit_id] = activation
# Batch fetch neighbors
if batch_nodes and budget_remaining > 0:
max_neighbors = len(batch_nodes) * 20
neighbors = await conn.fetch(
f"""
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.occurred_end,
mu.mentioned_at, mu.fact_type,
mu.document_id, mu.chunk_id, mu.tags,
ml.weight, ml.link_type, ml.from_unit_id
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.weight >= $2
AND mu.fact_type = $3
ORDER BY ml.weight DESC
LIMIT $4
""",
batch_nodes,
self.min_activation,
fact_type,
max_neighbors,
)
for n in neighbors:
neighbor_id = str(n["id"])
if neighbor_id not in visited:
parent_id = str(n["from_unit_id"])
parent_activation = batch_activations.get(parent_id, 0.5)
# Boost causal links
link_type = n["link_type"]
base_weight = n["weight"]
if link_type in ("causes", "caused_by"):
causal_boost = 2.0
elif link_type in ("enables", "prevents"):
causal_boost = 1.5
else:
causal_boost = 1.0
effective_weight = base_weight * causal_boost
new_activation = parent_activation * effective_weight * self.activation_decay
if new_activation > self.min_activation:
neighbor_result = RetrievalResult.from_db_row(dict(n))
queue.append((neighbor_result, new_activation))
# Apply tags filtering (BFS may traverse into memories that don't match tags criteria)
if tags:
results = filter_results_by_tags(results, tags, match=tags_match)
return results
@@ -4,32 +4,37 @@ Link Expansion graph retrieval.
Expands from semantic/temporal seeds through three parallel, first-class signals
stored in memory_links:
1. Entity links precomputed co-occurrence graph (created at retain time, bounded to
MAX_LINKS_PER_ENTITY per entity). Score = number of distinct shared
entities between the seed set and each candidate.
1. Entity links query-time self-join through unit_entities. Score = number of distinct
shared entities between the seed set and each candidate, computed via
COUNT(DISTINCT entity_id). Uses a LATERAL per-entity cap
(graph_per_entity_limit, default 200) to prevent high-fanout entities
from exploding the self-join intermediate rows.
2. Semantic links precomputed kNN graph (each new fact linked to its top-5 most
similar existing facts at insert time, similarity >= 0.7). Checked
in both directions since the graph is not symmetric. Score = weight.
3. Causal links explicit causal chains (causes/caused_by/enables/prevents).
Score = weight + 1.0 (boosted as highest-quality signal).
All three signals are bounded at retain time, so no LATERAL fan-out caps are needed
at query time. Each expansion is a simple aggregation over a small result set.
Entity expansion is bounded by graph_per_entity_limit (LATERAL cap per entity).
A timeout fallback (graph_expansion_timeout) drops entity expansion entirely if the
query still exceeds the budget.
For non-observation fact types the three expansions are issued as a single CTE query
(one roundtrip, one connection) with a `source` discriminator column so the Python
merge step can apply per-signal score transformations.
"""
import asyncio
import logging
import math
import time
from ...config import get_config
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from .graph_retrieval import GraphRetriever
from .tags import TagsMatch, filter_results_by_tags
from .types import MPFPTimings, RetrievalResult
from .tags import TagGroup, TagsMatch, filter_results_by_tag_groups, filter_results_by_tags
from .types import GraphRetrievalTimings, RetrievalResult
logger = logging.getLogger(__name__)
@@ -43,19 +48,23 @@ async def _find_semantic_seeds(
threshold: float = 0.3,
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
tag_groups: list[TagGroup] | None = None,
) -> list[RetrievalResult]:
"""Find semantic seeds via embedding search."""
from .tags import build_tags_where_clause_simple
from .tags import build_tag_groups_where_clause, build_tags_where_clause_simple
tags_clause = build_tags_where_clause_simple(tags, 6, match=tags_match)
tag_groups_param_start = 6 + (1 if tags else 0)
groups_clause, groups_params, _ = build_tag_groups_where_clause(tag_groups, tag_groups_param_start)
params = [query_embedding_str, bank_id, fact_type, threshold, limit]
if tags:
params.append(tags)
params.extend(groups_params)
rows = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, fact_type, document_id, chunk_id, tags,
mentioned_at, fact_type, document_id, chunk_id, tags, proof_count,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
@@ -63,6 +72,7 @@ async def _find_semantic_seeds(
AND fact_type = $3
AND (1 - (embedding <=> $1::vector)) >= $4
{tags_clause}
{groups_clause}
ORDER BY embedding <=> $1::vector
LIMIT $5
""",
@@ -110,7 +120,8 @@ class LinkExpansionRetriever(GraphRetriever):
adjacency=None,
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
) -> tuple[list[RetrievalResult], MPFPTimings | None]:
tag_groups: list[TagGroup] | None = None,
) -> tuple[list[RetrievalResult], GraphRetrievalTimings | None]:
"""
Retrieve facts by expanding links from seeds.
@@ -130,7 +141,7 @@ class LinkExpansionRetriever(GraphRetriever):
Tuple of (results, timings)
"""
start_time = time.time()
timings = MPFPTimings(fact_type=fact_type)
timings = GraphRetrievalTimings(fact_type=fact_type)
async with acquire_with_retry(pool) as conn:
# Find seeds if not provided
@@ -147,6 +158,7 @@ class LinkExpansionRetriever(GraphRetriever):
threshold=0.3,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
)
timings.seeds_time = time.time() - seeds_start
logger.debug(
@@ -221,6 +233,9 @@ class LinkExpansionRetriever(GraphRetriever):
if tags:
results = filter_results_by_tags(results, tags, match=tags_match)
if tag_groups:
results = filter_results_by_tag_groups(results, tag_groups)
timings.result_count = len(results)
timings.traverse = time.time() - start_time
@@ -252,31 +267,48 @@ class LinkExpansionRetriever(GraphRetriever):
idx_memory_links_to_type_weight (to_unit_id, link_type, weight DESC)
replaces costly BitmapAnd of two separate scans
"""
config = get_config()
ml = fq_table("memory_links")
mu = fq_table("memory_units")
all_rows = await conn.fetch(
f"""
WITH entity_expanded AS (
-- Entity co-occurrence: seeds their precomputed entity-link neighbors.
-- Score = distinct shared entities (bounded at retain time to
-- MAX_LINKS_PER_ENTITY=50). GROUP BY mu.id is sufficient because mu.id
-- is the primary key and functionally determines all other mu columns.
SELECT
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
COUNT(DISTINCT ml.entity_id)::float AS score,
'entity'::text AS source
FROM {ml} ml
JOIN {mu} mu ON mu.id = ml.to_unit_id
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type = 'entity'
AND mu.fact_type = $2
AND mu.id != ALL($1::uuid[])
ue = fq_table("unit_entities")
per_entity_limit = config.link_expansion_per_entity_limit
# Entity CTE with LATERAL fanout cap.
# Every seed entity (including high-frequency ones) is kept, but each
# entity's expansion is capped to per_entity_limit target units. The
# LATERAL subquery orders by unit_id DESC so the most recently inserted
# units are preferred (a recency proxy that is free — it rides the PK
# index with no extra sort).
entity_cte = f"""
seed_entities AS (
SELECT DISTINCT ue.entity_id
FROM {ue} ue
WHERE ue.unit_id = ANY($1::uuid[])
),
entity_expanded AS (
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count,
COUNT(DISTINCT se.entity_id)::float AS score,
'entity'::text AS source
FROM seed_entities se
CROSS JOIN LATERAL (
SELECT ue_target.unit_id
FROM {ue} ue_target
WHERE ue_target.entity_id = se.entity_id
AND ue_target.unit_id != ALL($1::uuid[])
ORDER BY ue_target.unit_id DESC
LIMIT {per_entity_limit}
) t
JOIN {mu} mu ON mu.id = t.unit_id
WHERE mu.fact_type = $2
GROUP BY mu.id
ORDER BY score DESC
LIMIT $3
),
)"""
semantic_causal_cte = f"""
semantic_expanded AS (
-- Semantic kNN: both outgoing (seeds their kNN at insert time) and
-- incoming (facts inserted after seeds that found seeds as kNN).
@@ -284,14 +316,14 @@ class LinkExpansionRetriever(GraphRetriever):
SELECT
id, text, context, event_date, occurred_start,
occurred_end, mentioned_at,
fact_type, document_id, chunk_id, tags,
fact_type, document_id, chunk_id, tags, proof_count,
MAX(weight) AS score,
'semantic'::text AS source
FROM (
SELECT
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count,
ml.weight
FROM {ml} ml
JOIN {mu} mu ON mu.id = ml.to_unit_id
@@ -303,7 +335,7 @@ class LinkExpansionRetriever(GraphRetriever):
SELECT
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count,
ml.weight
FROM {ml} ml
JOIN {mu} mu ON mu.id = ml.from_unit_id
@@ -314,7 +346,7 @@ class LinkExpansionRetriever(GraphRetriever):
) sem_raw
GROUP BY id, text, context, event_date, occurred_start,
occurred_end, mentioned_at,
fact_type, document_id, chunk_id, tags
fact_type, document_id, chunk_id, tags, proof_count
ORDER BY score DESC
LIMIT $3
),
@@ -325,7 +357,7 @@ class LinkExpansionRetriever(GraphRetriever):
SELECT DISTINCT ON (mu.id)
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count,
ml.weight AS score,
'causal'::text AS source
FROM {ml} ml
@@ -336,18 +368,37 @@ class LinkExpansionRetriever(GraphRetriever):
AND mu.fact_type = $2
ORDER BY mu.id, ml.weight DESC
LIMIT $3
)
)"""
full_query = f"""
WITH {entity_cte},
{semantic_causal_cte}
SELECT * FROM entity_expanded
UNION ALL
SELECT * FROM semantic_expanded
UNION ALL
SELECT * FROM causal_expanded
""",
seed_ids,
fact_type,
budget,
self.causal_weight_threshold,
)
"""
params = [seed_ids, fact_type, budget, self.causal_weight_threshold]
try:
all_rows = await asyncio.wait_for(
conn.fetch(full_query, *params),
timeout=config.link_expansion_timeout,
)
except asyncio.TimeoutError:
logger.warning(
f"[LinkExpansion] Entity expansion timed out after {config.link_expansion_timeout}s "
f"for fact_type={fact_type}, falling back to semantic+causal only"
)
fallback_query = f"""
WITH {semantic_causal_cte}
SELECT * FROM semantic_expanded
UNION ALL
SELECT * FROM causal_expanded
"""
all_rows = await conn.fetch(fallback_query, *params)
entity_rows = [r for r in all_rows if r["source"] == "entity"]
semantic_rows = [r for r in all_rows if r["source"] == "semantic"]
@@ -387,6 +438,33 @@ class LinkExpansionRetriever(GraphRetriever):
f"{len(source_ids_found)} source_memory_ids found"
)
config = get_config()
ue = fq_table("unit_entities")
per_entity_limit = config.link_expansion_per_entity_limit
connected_sources_cte = f"""
source_entities AS (
SELECT DISTINCT ue_seed.entity_id
FROM seed_sources ss
JOIN {ue} ue_seed ON ue_seed.unit_id = ss.source_id
),
connected_sources AS (
-- Find sources sharing entities with seed observation sources
-- via LATERAL-capped self-join (prevents hub entity fanout).
SELECT DISTINCT t.unit_id AS source_id
FROM source_entities se
CROSS JOIN LATERAL (
SELECT ue_target.unit_id
FROM {ue} ue_target
WHERE ue_target.entity_id = se.entity_id
ORDER BY ue_target.unit_id DESC
LIMIT {per_entity_limit}
) t
WHERE NOT EXISTS (
SELECT 1 FROM seed_sources ss WHERE ss.source_id = t.unit_id
)
)"""
entity_rows = await conn.fetch(
f"""
WITH seed_sources AS (
@@ -395,22 +473,14 @@ class LinkExpansionRetriever(GraphRetriever):
WHERE id = ANY($1::uuid[])
AND source_memory_ids IS NOT NULL
),
connected_sources AS (
-- Mirror the non-observation entity expansion: follow pre-bounded entity
-- links in memory_links (capped to MAX_LINKS_PER_ENTITY=50 at retain time).
-- Score = number of distinct shared entities, same as the non-obs path.
SELECT DISTINCT ml.to_unit_id AS source_id
FROM seed_sources ss
JOIN {fq_table("memory_links")} ml ON ml.from_unit_id = ss.source_id
WHERE ml.link_type = 'entity'
),
{connected_sources_cte},
connected_array AS (
SELECT array_agg(source_id) AS source_ids FROM connected_sources
)
SELECT
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count,
(SELECT COUNT(DISTINCT s) FROM unnest(mu.source_memory_ids) s WHERE s = ANY(ca.source_ids))::float AS score
FROM {fq_table("memory_units")} mu, connected_array ca
WHERE mu.fact_type = 'observation'
@@ -434,13 +504,13 @@ class LinkExpansionRetriever(GraphRetriever):
SELECT
id, text, context, event_date, occurred_start,
occurred_end, mentioned_at,
fact_type, document_id, chunk_id, tags,
fact_type, document_id, chunk_id, tags, proof_count,
MAX(weight) AS score,
'semantic'::text AS source
FROM (
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id,
mu.chunk_id, mu.tags, ml.weight
mu.chunk_id, mu.tags, mu.proof_count, ml.weight
FROM {ml} ml JOIN {mu} mu ON mu.id = ml.to_unit_id
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type = 'semantic' AND mu.fact_type = 'observation'
@@ -448,21 +518,21 @@ class LinkExpansionRetriever(GraphRetriever):
UNION ALL
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id,
mu.chunk_id, mu.tags, ml.weight
mu.chunk_id, mu.tags, mu.proof_count, ml.weight
FROM {ml} ml JOIN {mu} mu ON mu.id = ml.from_unit_id
WHERE ml.to_unit_id = ANY($1::uuid[])
AND ml.link_type = 'semantic' AND mu.fact_type = 'observation'
AND mu.id != ALL($1::uuid[])
) sem_raw
GROUP BY id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, fact_type, document_id, chunk_id, tags
mentioned_at, fact_type, document_id, chunk_id, tags, proof_count
ORDER BY score DESC LIMIT $2
),
causal_expanded AS (
SELECT DISTINCT ON (mu.id)
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id,
mu.chunk_id, mu.tags, ml.weight AS score, 'causal'::text AS source
mu.chunk_id, mu.tags, mu.proof_count, ml.weight AS score, 'causal'::text AS source
FROM {ml} ml JOIN {mu} mu ON ml.to_unit_id = mu.id
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type IN ('causes', 'caused_by', 'enables', 'prevents')
@@ -1,684 +0,0 @@
"""
Meta-Path Forward Push (MPFP) graph retrieval.
A sublinear graph traversal algorithm for memory retrieval over heterogeneous
graphs with multiple edge types (semantic, temporal, causal, entity).
Combines meta-path patterns from HIN literature with Forward Push local
propagation from Approximate PPR.
Key properties:
- Sublinear in graph size (threshold pruning bounds active nodes)
- Lazy edge loading: only loads edges for frontier nodes, not entire graph
- Predefined patterns capture different retrieval intents
- All patterns run in parallel, results fused via RRF
- No LLM in the loop during traversal
"""
import asyncio
import logging
from collections import defaultdict
from dataclasses import dataclass, field
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from .graph_retrieval import GraphRetriever
from .tags import TagsMatch
from .types import MPFPTimings, RetrievalResult
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Data Classes
# -----------------------------------------------------------------------------
@dataclass
class EdgeTarget:
"""A neighbor node with its edge weight."""
node_id: str
weight: float
@dataclass
class EdgeCache:
"""
Cache for lazily-loaded edges.
Grows per-hop as edges are loaded for frontier nodes.
Shared across patterns to avoid redundant loads.
Loads ALL edge types at once to minimize DB queries.
Thread-safe via asyncio lock to prevent redundant concurrent loads.
"""
# edge_type -> from_node_id -> list of EdgeTarget
graphs: dict[str, dict[str, list[EdgeTarget]]] = field(default_factory=dict)
# Track which nodes have been fully loaded (all edge types)
_fully_loaded: set[str] = field(default_factory=set)
# Timing stats
db_queries: int = 0
edge_load_time: float = 0.0
# Detailed hop timing for debugging
hop_details: list[dict] = field(default_factory=list)
# Lock to prevent redundant concurrent loads
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
def get_neighbors(self, edge_type: str, node_id: str) -> list[EdgeTarget]:
"""Get neighbors for a node via a specific edge type."""
return self.graphs.get(edge_type, {}).get(node_id, [])
def get_normalized_neighbors(self, edge_type: str, node_id: str, top_k: int) -> list[EdgeTarget]:
"""Get top-k neighbors with weights normalized to sum to 1."""
neighbors = self.get_neighbors(edge_type, node_id)[:top_k]
if not neighbors:
return []
total = sum(n.weight for n in neighbors)
if total == 0:
return []
return [EdgeTarget(node_id=n.node_id, weight=n.weight / total) for n in neighbors]
def is_fully_loaded(self, node_id: str) -> bool:
"""Check if all edges for this node have been loaded."""
return node_id in self._fully_loaded
def get_uncached(self, node_ids: list[str]) -> list[str]:
"""Get node IDs that haven't been fully loaded yet."""
return [n for n in node_ids if not self.is_fully_loaded(n)]
def add_all_edges(self, edges_by_type: dict[str, dict[str, list[EdgeTarget]]], all_queried: list[str]):
"""
Add loaded edges to the cache (all edge types at once).
Args:
edges_by_type: Dict mapping edge_type -> from_node_id -> list of EdgeTarget
all_queried: All node IDs that were queried (marks them as fully loaded)
"""
for edge_type, edges in edges_by_type.items():
if edge_type not in self.graphs:
self.graphs[edge_type] = {}
for node_id, neighbors in edges.items():
self.graphs[edge_type][node_id] = neighbors
# Mark all queried nodes as fully loaded (even if they have no edges)
self._fully_loaded.update(all_queried)
@dataclass
class PatternResult:
"""Result from a single pattern traversal."""
pattern: list[str]
scores: dict[str, float] # node_id -> accumulated mass
@dataclass
class MPFPConfig:
"""Configuration for MPFP algorithm."""
alpha: float = 0.15 # teleport/keep probability
threshold: float = 1e-6 # mass pruning threshold (lower = explore more)
top_k_neighbors: int = 20 # fan-out limit per node
# Patterns from semantic seeds
patterns_semantic: list[list[str]] = field(
default_factory=lambda: [
["semantic", "semantic"], # topic expansion
["entity", "temporal"], # entity timeline
["semantic", "causes"], # reasoning chains (forward)
["semantic", "caused_by"], # reasoning chains (backward)
["entity", "semantic"], # entity context
]
)
# Patterns from temporal seeds
patterns_temporal: list[list[str]] = field(
default_factory=lambda: [
["temporal", "semantic"], # what was happening then
["temporal", "entity"], # who was involved then
]
)
@dataclass
class SeedNode:
"""An entry point node with its initial score."""
node_id: str
score: float # initial mass (e.g., similarity score)
# -----------------------------------------------------------------------------
# Lazy Edge Loading
# -----------------------------------------------------------------------------
async def load_all_edges_for_frontier(
pool,
node_ids: list[str],
top_k_per_type: int = 20,
) -> dict[str, dict[str, list[EdgeTarget]]]:
"""
Load top-k edges per (node, edge_type) for frontier nodes.
Uses a LATERAL join to efficiently fetch only the top-k edges per type,
avoiding loading hundreds of entity edges when only 20 are needed.
Requires composite index: (from_unit_id, link_type, weight DESC)
Args:
pool: Database connection pool
node_ids: Frontier node IDs to load edges for
top_k_per_type: Max edges to load per (node, link_type) pair
Returns:
Dict mapping edge_type -> from_node_id -> list of EdgeTarget
"""
if not node_ids:
return {}
async with acquire_with_retry(pool) as conn:
# Use LATERAL join to get top-k per (from_node, link_type)
# This leverages the composite index for efficient early termination
rows = await conn.fetch(
f"""
WITH frontier(node_id) AS (SELECT unnest($1::uuid[]))
SELECT f.node_id as from_unit_id, lt.link_type, edges.to_unit_id, edges.weight
FROM frontier f
CROSS JOIN (VALUES ('semantic'), ('temporal'), ('entity'), ('causes'), ('caused_by')) AS lt(link_type)
CROSS JOIN LATERAL (
SELECT ml.to_unit_id, ml.weight
FROM {fq_table("memory_links")} ml
WHERE ml.from_unit_id = f.node_id
AND ml.link_type = lt.link_type
AND ml.weight >= 0.1
ORDER BY ml.weight DESC
LIMIT $2
) edges
""",
node_ids,
top_k_per_type,
)
# Group by edge_type -> from_node -> neighbors
result: dict[str, dict[str, list[EdgeTarget]]] = defaultdict(lambda: defaultdict(list))
for row in rows:
edge_type = row["link_type"]
from_id = str(row["from_unit_id"])
to_id = str(row["to_unit_id"])
weight = row["weight"]
result[edge_type][from_id].append(EdgeTarget(node_id=to_id, weight=weight))
# Convert nested defaultdicts to regular dicts
return {edge_type: dict(edges) for edge_type, edges in result.items()}
# -----------------------------------------------------------------------------
# Core Algorithm (Async with Lazy Loading)
# -----------------------------------------------------------------------------
@dataclass
class PatternState:
"""State for a pattern traversal between hops."""
pattern: list[str]
hop_index: int
scores: dict[str, float]
frontier: dict[str, float]
def _init_pattern_state(seeds: list[SeedNode], pattern: list[str]) -> PatternState:
"""Initialize pattern state from seeds."""
if not seeds:
return PatternState(pattern=pattern, hop_index=0, scores={}, frontier={})
total_seed_score = sum(s.score for s in seeds)
if total_seed_score == 0:
total_seed_score = len(seeds)
frontier = {s.node_id: s.score / total_seed_score for s in seeds}
return PatternState(pattern=pattern, hop_index=0, scores={}, frontier=frontier)
def _execute_hop(state: PatternState, cache: EdgeCache, config: MPFPConfig) -> set[str]:
"""
Execute ONE hop of traversal, return frontier nodes for next hop.
This is a pure function that uses cached edges (no DB access).
Returns set of uncached nodes needed for next hop.
"""
if state.hop_index >= len(state.pattern):
return set()
edge_type = state.pattern[state.hop_index]
# Collect active nodes above threshold
active_nodes = [node_id for node_id, mass in state.frontier.items() if mass >= config.threshold]
if not active_nodes:
state.frontier = {}
return set()
# Propagate mass using cached edges
next_frontier: dict[str, float] = {}
uncached_for_next: set[str] = set()
for node_id, mass in state.frontier.items():
if mass < config.threshold:
continue
# Keep α portion for this node
state.scores[node_id] = state.scores.get(node_id, 0) + config.alpha * mass
# Push (1-α) to neighbors
push_mass = (1 - config.alpha) * mass
neighbors = cache.get_normalized_neighbors(edge_type, node_id, config.top_k_neighbors)
for neighbor in neighbors:
next_frontier[neighbor.node_id] = next_frontier.get(neighbor.node_id, 0) + push_mass * neighbor.weight
# Track if we'll need edges for this node in the next hop
if not cache.is_fully_loaded(neighbor.node_id):
uncached_for_next.add(neighbor.node_id)
state.frontier = next_frontier
state.hop_index += 1
return uncached_for_next
def _finalize_pattern(state: PatternState, config: MPFPConfig) -> PatternResult:
"""Finalize pattern by adding remaining frontier mass to scores."""
for node_id, mass in state.frontier.items():
if mass >= config.threshold:
state.scores[node_id] = state.scores.get(node_id, 0) + mass
return PatternResult(pattern=state.pattern, scores=state.scores)
async def mpfp_traverse_hop_synchronized(
pool,
pattern_jobs: list[tuple[list[SeedNode], list[str]]],
config: MPFPConfig,
cache: EdgeCache,
) -> list[PatternResult]:
"""
Execute ALL patterns with hop-synchronized edge loading.
Instead of running each pattern independently (causing multiple DB queries),
this function:
1. Runs hop 1 for ALL patterns (using pre-warmed seed edges)
2. Collects ALL unique hop-2 frontier nodes across patterns
3. Pre-warms hop-2 edges in ONE query
4. Runs hop 2 for ALL patterns
This reduces DB queries from O(patterns * hops) to O(hops).
Args:
pool: Database connection pool
pattern_jobs: List of (seeds, pattern) tuples
config: Algorithm parameters
cache: Shared edge cache (should be pre-warmed with seed edges)
Returns:
List of PatternResult for each pattern
"""
import time
# Initialize all pattern states
states = [_init_pattern_state(seeds, pattern) for seeds, pattern in pattern_jobs]
# Determine max hops (all patterns should be same length, but be safe)
max_hops = max((len(p) for _, p in pattern_jobs), default=0)
# Detailed timing for debugging
hop_times: list[dict] = []
# Execute hop-by-hop across ALL patterns
for hop in range(max_hops):
hop_start = time.time()
hop_timing = {"hop": hop, "patterns_executed": 0, "uncached_count": 0, "load_time": 0.0}
# Execute this hop for all patterns, collect uncached nodes for next hop
all_uncached: set[str] = set()
exec_start = time.time()
for state in states:
if state.hop_index < len(state.pattern):
uncached = _execute_hop(state, cache, config)
all_uncached.update(uncached)
hop_timing["patterns_executed"] += 1
hop_timing["exec_time"] = time.time() - exec_start
# Pre-warm edges for ALL uncached nodes before next hop
hop_timing["uncached_count"] = len(all_uncached)
if all_uncached:
uncached_list = list(all_uncached - cache._fully_loaded)
hop_timing["uncached_after_filter"] = len(uncached_list)
if uncached_list:
load_start = time.time()
edges_by_type = await load_all_edges_for_frontier(pool, uncached_list, config.top_k_neighbors)
hop_timing["load_time"] = time.time() - load_start
cache.edge_load_time += hop_timing["load_time"]
cache.db_queries += 1
cache.add_all_edges(edges_by_type, uncached_list)
hop_timing["edges_loaded"] = sum(
len(neighbors) for edges in edges_by_type.values() for neighbors in edges.values()
)
hop_timing["total_time"] = time.time() - hop_start
hop_times.append(hop_timing)
# Store hop timing details in cache for logging
cache.hop_details = hop_times
# Finalize all patterns
return [_finalize_pattern(state, config) for state in states]
async def mpfp_traverse_async(
pool,
seeds: list[SeedNode],
pattern: list[str],
config: MPFPConfig,
cache: EdgeCache,
) -> PatternResult:
"""
Async Forward Push traversal with lazy edge loading.
NOTE: For better performance with multiple patterns, use mpfp_traverse_hop_synchronized().
This function is kept for single-pattern use cases.
"""
if not seeds:
return PatternResult(pattern=pattern, scores={})
results = await mpfp_traverse_hop_synchronized(pool, [(seeds, pattern)], config, cache)
return results[0] if results else PatternResult(pattern=pattern, scores={})
def rrf_fusion(
results: list[PatternResult],
k: int = 60,
top_k: int = 50,
) -> list[tuple[str, float]]:
"""
Reciprocal Rank Fusion to combine pattern results.
Args:
results: List of pattern results
k: RRF constant (higher = more uniform weighting)
top_k: Number of results to return
Returns:
List of (node_id, fused_score) tuples, sorted by score descending
"""
fused: dict[str, float] = {}
for result in results:
if not result.scores:
continue
# Rank nodes by their score in this pattern
ranked = sorted(result.scores.keys(), key=lambda n: result.scores[n], reverse=True)
for rank, node_id in enumerate(ranked):
fused[node_id] = fused.get(node_id, 0) + 1.0 / (k + rank + 1)
# Sort by fused score and return top-k
sorted_results = sorted(fused.items(), key=lambda x: x[1], reverse=True)
return sorted_results[:top_k]
# -----------------------------------------------------------------------------
# Database Loading
# -----------------------------------------------------------------------------
async def fetch_memory_units_by_ids(
pool,
node_ids: list[str],
fact_type: str,
) -> list[RetrievalResult]:
"""Fetch full memory unit details for a list of node IDs."""
if not node_ids:
return []
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, fact_type, document_id, chunk_id, tags
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
AND fact_type = $2
""",
node_ids,
fact_type,
)
return [RetrievalResult.from_db_row(dict(r)) for r in rows]
# -----------------------------------------------------------------------------
# Graph Retriever Implementation
# -----------------------------------------------------------------------------
class MPFPGraphRetriever(GraphRetriever):
"""
Graph retrieval using Meta-Path Forward Push with lazy edge loading.
Runs predefined patterns in parallel from semantic and temporal seeds,
loading edges on-demand per hop instead of loading entire graph upfront.
"""
def __init__(self, config: MPFPConfig | None = None):
"""
Initialize MPFP retriever.
Args:
config: Algorithm configuration (uses defaults if None)
"""
if config is None:
# Read top_k_neighbors from global config
from ...config import get_config
global_config = get_config()
config = MPFPConfig(top_k_neighbors=global_config.mpfp_top_k_neighbors)
self.config = config
@property
def name(self) -> str:
return "mpfp"
async def retrieve(
self,
pool,
query_embedding_str: str,
bank_id: str,
fact_type: str,
budget: int,
query_text: str | None = None,
semantic_seeds: list[RetrievalResult] | None = None,
temporal_seeds: list[RetrievalResult] | None = None,
adjacency=None, # Ignored - kept for interface compatibility
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
) -> tuple[list[RetrievalResult], MPFPTimings | None]:
"""
Retrieve facts using MPFP algorithm with lazy edge loading.
Args:
pool: Database connection pool
query_embedding_str: Query embedding (used for fallback seed finding)
bank_id: Memory bank ID
fact_type: Fact type to filter
budget: Maximum results to return
query_text: Original query text (optional)
semantic_seeds: Pre-computed semantic entry points
temporal_seeds: Pre-computed temporal entry points
adjacency: Ignored (kept for interface compatibility)
tags: Optional list of tags for visibility filtering (OR matching)
Returns:
Tuple of (List of RetrievalResult with activation scores, MPFPTimings)
"""
import time
timings = MPFPTimings(fact_type=fact_type)
# Convert seeds to SeedNode format
semantic_seed_nodes = self._convert_seeds(semantic_seeds, "similarity")
temporal_seed_nodes = self._convert_seeds(temporal_seeds, "temporal_score")
# If no semantic seeds provided, fall back to finding our own
if not semantic_seed_nodes:
seeds_start = time.time()
semantic_seed_nodes = await self._find_semantic_seeds(
pool, query_embedding_str, bank_id, fact_type, tags=tags, tags_match=tags_match
)
timings.seeds_time = time.time() - seeds_start
logger.debug(
f"[MPFP] Found {len(semantic_seed_nodes)} semantic seeds for fact_type={fact_type} (tags={tags}, tags_match={tags_match})"
)
# Collect all pattern jobs
pattern_jobs = []
# Patterns from semantic seeds
for pattern in self.config.patterns_semantic:
if semantic_seed_nodes:
pattern_jobs.append((semantic_seed_nodes, pattern))
# Patterns from temporal seeds
for pattern in self.config.patterns_temporal:
if temporal_seed_nodes:
pattern_jobs.append((temporal_seed_nodes, pattern))
if not pattern_jobs:
logger.debug(
f"[MPFP] No pattern jobs (semantic_seeds={len(semantic_seed_nodes)}, temporal_seeds={len(temporal_seed_nodes)})"
)
return [], timings
timings.pattern_count = len(pattern_jobs)
# Shared edge cache across all patterns
cache = EdgeCache()
# Pre-warm cache with ALL seed node edges BEFORE running patterns
# This prevents redundant DB queries at hop 1
all_seed_ids = list({s.node_id for seeds, _ in pattern_jobs for s in seeds})
if all_seed_ids:
import time as time_module
prewarm_start = time_module.time()
edges_by_type = await load_all_edges_for_frontier(pool, all_seed_ids, self.config.top_k_neighbors)
cache.edge_load_time += time_module.time() - prewarm_start
cache.db_queries += 1
cache.add_all_edges(edges_by_type, all_seed_ids)
# Run all patterns with HOP-SYNCHRONIZED edge loading
# This batches hop-2 edge loads across ALL patterns into ONE query
# Reduces DB queries from O(patterns * hops) to O(hops)
step_start = time.time()
pattern_results = await mpfp_traverse_hop_synchronized(pool, pattern_jobs, self.config, cache)
timings.traverse = time.time() - step_start
# Record edge loading stats from cache
timings.edge_count = sum(len(neighbors) for g in cache.graphs.values() for neighbors in g.values())
timings.db_queries = cache.db_queries
timings.edge_load_time = cache.edge_load_time
timings.hop_details = cache.hop_details
# Fuse results
step_start = time.time()
fused = rrf_fusion(pattern_results, top_k=budget)
timings.fusion = time.time() - step_start
if not fused:
logger.debug(f"[MPFP] No fused results after RRF fusion (pattern_count={len(pattern_results)})")
return [], timings
# Get top result IDs
result_ids = [node_id for node_id, score in fused][:budget]
# Fetch full details
step_start = time.time()
results = await fetch_memory_units_by_ids(pool, result_ids, fact_type)
timings.fetch = time.time() - step_start
# Filter results by tags (graph traversal may have picked up unfiltered memories)
if tags:
from .tags import filter_results_by_tags
results = filter_results_by_tags(results, tags, match=tags_match)
timings.result_count = len(results)
# Add activation scores from fusion
score_map = {node_id: score for node_id, score in fused}
for result in results:
result.activation = score_map.get(result.id, 0.0)
# Sort by activation
results.sort(key=lambda r: r.activation or 0, reverse=True)
return results, timings
def _convert_seeds(
self,
seeds: list[RetrievalResult] | None,
score_attr: str,
) -> list[SeedNode]:
"""Convert RetrievalResult seeds to SeedNode format."""
if not seeds:
return []
result = []
for seed in seeds:
score = getattr(seed, score_attr, None)
if score is None:
score = seed.activation or seed.similarity or 1.0
result.append(SeedNode(node_id=seed.id, score=score))
return result
async def _find_semantic_seeds(
self,
pool,
query_embedding_str: str,
bank_id: str,
fact_type: str,
limit: int = 20,
threshold: float = 0.3,
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
) -> list[SeedNode]:
"""Fallback: find semantic seeds via embedding search."""
from .tags import build_tags_where_clause_simple
tags_clause = build_tags_where_clause_simple(tags, 6, match=tags_match)
params = [query_embedding_str, bank_id, fact_type, threshold, limit]
if tags:
params.append(tags)
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
f"""
SELECT id, 1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND embedding IS NOT NULL
AND fact_type = $3
AND (1 - (embedding <=> $1::vector)) >= $4
{tags_clause}
ORDER BY embedding <=> $1::vector
LIMIT $5
""",
*params,
)
return [SeedNode(node_id=str(r["id"]), score=r["similarity"]) for r in rows]
@@ -2,6 +2,7 @@
Cross-encoder neural reranking for search results.
"""
import math
from datetime import datetime, timezone
from .types import MergedCandidate, ScoredResult
@@ -13,6 +14,7 @@ UTC = timezone.utc
# so the max combined boost is (1 + alpha/2)^2 ≈ +21% and min is (1 - alpha/2)^2 ≈ -19%.
_RECENCY_ALPHA: float = 0.2
_TEMPORAL_ALPHA: float = 0.2
_PROOF_COUNT_ALPHA: float = 0.1 # Conservative: max ±5% for evidence strength
def apply_combined_scoring(
@@ -20,32 +22,81 @@ def apply_combined_scoring(
now: datetime,
recency_alpha: float = _RECENCY_ALPHA,
temporal_alpha: float = _TEMPORAL_ALPHA,
proof_count_alpha: float = _PROOF_COUNT_ALPHA,
is_passthrough_reranker: bool = False,
) -> None:
"""Apply combined scoring to a list of ScoredResults in-place.
Uses the cross-encoder score as the primary relevance signal, with recency
and temporal proximity applied as multiplicative boosts. This ensures the
influence of these secondary signals is always proportional to the base
relevance score, regardless of the cross-encoder model's score calibration.
Uses the cross-encoder score as the primary relevance signal, with recency,
temporal proximity, and proof count applied as multiplicative boosts. This
ensures the influence of these secondary signals is always proportional to
the base relevance score, regardless of the cross-encoder model's score
calibration.
Formula::
recency_boost = 1 + recency_alpha * (recency - 0.5) # in [1-α/2, 1+α/2]
temporal_boost = 1 + temporal_alpha * (temporal - 0.5) # in [1-α/2, 1+α/2]
combined_score = cross_encoder_score_normalized * recency_boost * temporal_boost
recency_boost = 1 + recency_alpha * (recency - 0.5) # in [1-α/2, 1+α/2]
temporal_boost = 1 + temporal_alpha * (temporal - 0.5) # in [1-α/2, 1+α/2]
proof_count_boost = 1 + proof_count_alpha * (proof_norm - 0.5) # in [1-α/2, 1+α/2]
combined_score = CE_normalized * recency_boost * temporal_boost * proof_count_boost
proof_norm maps proof_count using a smooth logarithmic curve centered at 0.5,
clamped to [0, 1]:
proof_count=1 0.5 + 0 = 0.5 (neutral multiplier)
proof_count=150 clamped to 1.0 (max +5% boost)
Temporal proximity is treated as neutral (0.5) when not set by temporal retrieval,
so temporal_boost collapses to 1.0 for non-temporal queries.
Proof count is treated as neutral (0.5) when not available (non-observation facts),
so proof_count_boost collapses to 1.0 for world/experience/opinion facts.
Args:
scored_results: Results from the cross-encoder reranker. Mutated in place.
now: Current UTC datetime for recency calculation.
recency_alpha: Max relative recency adjustment (default 0.2 ±10%).
temporal_alpha: Max relative temporal adjustment (default 0.2 ±10%).
proof_count_alpha: Max relative proof count adjustment (default 0.1 ±5%).
"""
if now.tzinfo is None:
now = now.replace(tzinfo=UTC)
# When the configured cross-encoder is a passthrough (e.g.
# RRFPassthroughCrossEncoder used by slim deployments), every
# cross_encoder_score_normalized is identical and provides no relevance
# signal. In that case the multiplicative recency / temporal / proof_count
# boosts below become the *only* ranking signal — making the final order a
# pure recency sort regardless of how relevant a candidate actually is.
#
# Detect that case and seed cross_encoder_score_normalized from the RRF
# rank instead, so the boosts modulate a meaningful base score rather than
# replacing it. This is a no-op for real cross-encoders, which produce
# diverse scores.
# When the reranker is a passthrough (e.g. RRFPassthroughCrossEncoder used
# by slim deployments), every cross_encoder_score_normalized is identical
# and provides no relevance signal. The multiplicative recency / temporal /
# proof_count boosts below would then become the *only* ranking signal,
# making the final order a pure recency sort regardless of how relevant a
# candidate actually is.
#
# Seed cross_encoder_score_normalized from the RRF rank instead, so the
# boosts modulate a meaningful base score. Caller passes is_passthrough
# explicitly because "all scores identical" is too fragile a heuristic —
# a real reranker can also tie scores (especially in tests with synthetic
# data) and we'd corrupt legitimate single-result reranks.
if is_passthrough_reranker and scored_results:
n = len(scored_results)
sorted_by_rrf = sorted(
scored_results,
key=lambda s: getattr(getattr(s, "candidate", None), "rrf_score", 0.0),
reverse=True,
)
denom = max(1, n - 1)
for new_rank, sr in enumerate(sorted_by_rrf):
# Map rank → [0.1, 1.0] so the recency boost can still nudge
# ordering between adjacent candidates without overpowering RRF.
sr.cross_encoder_score_normalized = 1.0 - (0.9 * new_rank / denom)
for sr in scored_results:
# Recency: linear decay over 365 days → [0.1, 1.0]; neutral 0.5 if no date.
sr.recency = 0.5
@@ -59,13 +110,23 @@ def apply_combined_scoring(
# Temporal proximity: meaningful only for temporal queries; neutral otherwise.
sr.temporal = sr.retrieval.temporal_proximity if sr.retrieval.temporal_proximity is not None else 0.5
# Proof count: log-normalized evidence strength; neutral for non-observations.
proof_count = sr.retrieval.proof_count
if proof_count is not None and proof_count >= 1:
# Clamp to [0, 1] so extreme counts stay within documented ±5% range
proof_norm = min(1.0, max(0.0, 0.5 + (math.log(proof_count) / 10.0)))
else:
# Neutral baseline is precisely 0.5, ensuring neutral multiplier (1.0)
proof_norm = 0.5
# RRF: kept at 0.0 for trace continuity but excluded from scoring.
# RRF is batch-relative (min-max normalised) and redundant after reranking.
sr.rrf_normalized = 0.0
recency_boost = 1.0 + recency_alpha * (sr.recency - 0.5)
temporal_boost = 1.0 + temporal_alpha * (sr.temporal - 0.5)
sr.combined_score = sr.cross_encoder_score_normalized * recency_boost * temporal_boost
proof_count_boost = 1.0 + proof_count_alpha * (proof_norm - 0.5)
sr.combined_score = sr.cross_encoder_score_normalized * recency_boost * temporal_boost * proof_count_boost
sr.weight = sr.combined_score
@@ -153,6 +214,8 @@ class CrossEncoderReranker:
# Normalize scores using sigmoid to [0, 1] range
# Cross-encoder returns logits which can be negative
import math
import numpy as np
def sigmoid(x):
@@ -163,11 +226,20 @@ class CrossEncoderReranker:
# Create ScoredResult objects with cross-encoder scores
scored_results = []
for candidate, raw_score, norm_score in zip(candidates, scores, normalized_scores):
# Sanitize NaN scores (cross-encoder can return NaN for certain inputs).
# NaN propagates through all downstream scoring and Pydantic serializes
# NaN as JSON null, which breaks clients expecting numeric values.
raw = float(raw_score)
norm = float(norm_score)
if math.isnan(raw):
raw = 0.0
if math.isnan(norm):
norm = 0.0
scored_result = ScoredResult(
candidate=candidate,
cross_encoder_score=float(raw_score),
cross_encoder_score_normalized=float(norm_score),
weight=float(norm_score), # Initial weight is just cross-encoder score
cross_encoder_score=raw,
cross_encoder_score_normalized=norm,
weight=norm, # Initial weight is just cross-encoder score
)
scored_results.append(scored_result)
@@ -10,6 +10,7 @@ Implements:
import asyncio
import logging
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Optional
@@ -17,15 +18,23 @@ from typing import Optional
from ...config import get_config
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from .graph_retrieval import BFSGraphRetriever, GraphRetriever
from .graph_retrieval import GraphRetriever
from .link_expansion_retrieval import LinkExpansionRetriever
from .mpfp_retrieval import MPFPGraphRetriever
from .tags import TagsMatch, build_tags_where_clause_simple
from .types import MPFPTimings, RetrievalResult
from .tags import TagGroup, TagsMatch, build_tag_groups_where_clause, build_tags_where_clause_simple
from .types import GraphRetrievalTimings, RetrievalResult
logger = logging.getLogger(__name__)
def tokenize_query(query_text: str) -> list[str]:
"""Normalize query text and split into BM25 tokens.
Strips punctuation, lowercases, and splits on whitespace.
Returns an empty list when the query contains no word characters.
"""
return re.sub(r"[^\w\s]", " ", query_text.lower()).split()
@dataclass
class ParallelRetrievalResult:
"""Result from parallel retrieval across all methods."""
@@ -36,7 +45,9 @@ class ParallelRetrievalResult:
temporal: list[RetrievalResult] | None
timings: dict[str, float] = field(default_factory=dict)
temporal_constraint: tuple | None = None # (start_date, end_date)
mpfp_timings: list[MPFPTimings] = field(default_factory=list) # MPFP sub-step timings per fact type
graph_timings: list[GraphRetrievalTimings] = field(
default_factory=list
) # Graph retrieval sub-step timings per fact type
max_conn_wait: float = 0.0 # Maximum connection acquisition wait time across all methods
@@ -62,15 +73,7 @@ def get_default_graph_retriever() -> GraphRetriever:
if _default_graph_retriever is None:
config = get_config()
retriever_type = config.graph_retriever.lower()
if retriever_type == "mpfp":
_default_graph_retriever = MPFPGraphRetriever()
logger.info(
f"Using MPFP graph retriever (top_k_neighbors={_default_graph_retriever.config.top_k_neighbors})"
)
elif retriever_type == "bfs":
_default_graph_retriever = BFSGraphRetriever()
logger.info("Using BFS graph retriever")
elif retriever_type == "link_expansion":
if retriever_type == "link_expansion":
_default_graph_retriever = LinkExpansionRetriever()
logger.info("Using LinkExpansion graph retriever")
else:
@@ -94,6 +97,7 @@ async def retrieve_semantic_bm25_combined(
limit: int,
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
tag_groups: list[TagGroup] | None = None,
) -> dict[str, tuple[list[RetrievalResult], list[RetrievalResult]]]:
"""
Combined semantic + BM25 retrieval for multiple fact types in a single query.
@@ -128,31 +132,37 @@ async def retrieve_semantic_bm25_combined(
Returns:
Dict mapping fact_type -> (semantic_results, bm25_results)
"""
import re
result_dict: dict[str, tuple[list[RetrievalResult], list[RetrievalResult]]] = {ft: ([], []) for ft in fact_types}
sanitized_text = re.sub(r"[^\w\s]", " ", query_text.lower())
tokens = [token for token in sanitized_text.split() if token]
tokens = tokenize_query(query_text)
# Over-fetch for HNSW approximation; semantic results trimmed to limit in Python.
hnsw_fetch = max(limit * 5, 100)
cols = (
"id, text, context, event_date, occurred_start, occurred_end, mentioned_at, "
"fact_type, document_id, chunk_id, tags"
"fact_type, document_id, chunk_id, tags, metadata, proof_count"
)
table = fq_table("memory_units")
# --- Parameter layout ---
# $1 = query_emb_str (semantic arms)
# $2 = bank_id
# $3 = limit (BM25 LIMIT; semantic uses inlined hnsw_fetch literal)
# $4 = bm25_text (only when tokens present)
# $N = tags (N=4 when no tokens, N=5 when tokens present)
tags_param_idx = 5 if tokens else 4
# When tokens present:
# $3 = limit (BM25 LIMIT; semantic uses inlined hnsw_fetch literal)
# $4 = bm25_text
# $5 = tags (if present)
# $6+ = tag_groups params (one per leaf)
# When no tokens ($3 is skipped — not included in params to avoid type inference gap):
# $3 = tags (if present)
# $4+ = tag_groups params (one per leaf)
tags_param_idx = 5 if tokens else 3
tags_clause = build_tags_where_clause_simple(tags, tags_param_idx, match=tags_match)
# tag_groups params start immediately after the tags param slot
tag_groups_param_start = tags_param_idx + (1 if tags else 0)
groups_clause, groups_params, _ = build_tag_groups_where_clause(tag_groups, tag_groups_param_start)
# --- Semantic UNION ALL arms (one per fact_type) ---
# Each arm has its own ORDER BY embedding <=> $1 LIMIT {hnsw_fetch}, which
# lets the planner use the partial HNSW index for that fact_type.
@@ -169,6 +179,7 @@ async def retrieve_semantic_bm25_combined(
f" AND embedding IS NOT NULL"
f" AND (1 - (embedding <=> $1::vector)) >= 0.3"
f" {tags_clause}"
f" {groups_clause}"
f" ORDER BY embedding <=> $1::vector"
f" LIMIT {hnsw_fetch})"
)
@@ -208,17 +219,20 @@ async def retrieve_semantic_bm25_combined(
f" AND fact_type = '{ft}'"
f" {bm25_where_filter}"
f" {tags_clause}"
f" {groups_clause}"
f" ORDER BY {bm25_order_by}"
f" LIMIT $3)"
)
query = "\nUNION ALL\n".join(arms)
params: list = [query_emb_str, bank_id, limit]
params: list = [query_emb_str, bank_id]
if tokens:
params.append(bm25_text_param)
params.append(limit) # $3: BM25 LIMIT (only referenced when tokens are present)
params.append(bm25_text_param) # $4
if tags:
params.append(tags)
params.extend(groups_params)
rows = await conn.fetch(query, *params)
@@ -251,6 +265,7 @@ async def retrieve_temporal_combined(
semantic_threshold: float = 0.1,
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
tag_groups: list[TagGroup] | None = None,
) -> dict[str, list[RetrievalResult]]:
"""
Temporal retrieval for multiple fact types in a single query.
@@ -280,10 +295,14 @@ async def retrieve_temporal_combined(
end_date = end_date.replace(tzinfo=UTC)
# Build tags clause
# Entry point query: fixed params are $1-$6, tags at $7
tags_clause = build_tags_where_clause_simple(tags, 7, match=tags_match)
params = [query_emb_str, bank_id, fact_types, start_date, end_date, semantic_threshold]
tag_groups_param_start = 7 + (1 if tags else 0)
groups_clause, groups_params, _ = build_tag_groups_where_clause(tag_groups, tag_groups_param_start)
params: list = [query_emb_str, bank_id, fact_types, start_date, end_date, semantic_threshold]
if tags:
params.append(tags)
params.extend(groups_params)
# Two-phase entry point query:
# Phase 1 (date_ranked): rank by date only — no embedding computation — for all units in
@@ -314,9 +333,10 @@ async def retrieve_temporal_combined(
(occurred_end IS NOT NULL AND occurred_end BETWEEN $4 AND $5)
)
{tags_clause}
{groups_clause}
),
sim_ranked AS (
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.proof_count, mu.document_id, mu.chunk_id, mu.tags, mu.metadata,
1 - (mu.embedding <=> $1::vector) AS similarity,
ROW_NUMBER() OVER (PARTITION BY mu.fact_type ORDER BY mu.embedding <=> $1::vector) AS sim_rn
FROM date_ranked dr
@@ -324,7 +344,7 @@ async def retrieve_temporal_combined(
WHERE dr.rn <= 50
AND (1 - (mu.embedding <=> $1::vector)) >= $6
)
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, document_id, chunk_id, tags, similarity
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, proof_count, document_id, chunk_id, tags, metadata, similarity
FROM sim_ranked
WHERE sim_rn <= 10
""",
@@ -400,16 +420,21 @@ async def retrieve_temporal_combined(
# Build tags clause for spreading (use param 7 since 1-6 are used)
spreading_tags_clause = build_tags_where_clause_simple(tags, 7, table_alias="mu.", match=tags_match)
spreading_groups_param_start = 7 + (1 if tags else 0)
spreading_groups_clause, spreading_groups_params, _ = build_tag_groups_where_clause(
tag_groups, spreading_groups_param_start, table_alias="mu."
)
while frontier and budget_remaining > 0 and iteration < max_iterations:
iteration += 1
batch_ids = frontier[:batch_size]
frontier = frontier[batch_size:]
# $1=query_emb, $2=batch_ids, $3=fact_type, $4=threshold, $5=per_source_limit, $6=bank_id, $7=tags
# $1=query_emb, $2=batch_ids, $3=fact_type, $4=threshold, $5=per_source_limit, $6=bank_id, $7=tags, $M+=tag_groups
spreading_params = [query_emb_str, batch_ids, ft, semantic_threshold, per_source_limit, bank_id]
if tags:
spreading_params.append(tags)
spreading_params.extend(spreading_groups_params)
# LATERAL join: for each source node, fetch top-K neighbors by weight using
# the existing idx_memory_links_from_type_weight index with early-exit semantics.
@@ -417,7 +442,7 @@ async def retrieve_temporal_combined(
# bank_id on memory_units lets the planner use idx_memory_units_bank_fact_type.
neighbors = await conn.fetch(
f"""
SELECT src.from_unit_id, mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
SELECT src.from_unit_id, mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.metadata,
l.weight, l.link_type,
1 - (mu.embedding <=> $1::vector) AS similarity
FROM unnest($2::uuid[]) AS src(from_unit_id)
@@ -436,6 +461,7 @@ async def retrieve_temporal_combined(
AND mu.embedding IS NOT NULL
AND (1 - (mu.embedding <=> $1::vector)) >= $4
{spreading_tags_clause}
{spreading_groups_clause}
""",
*spreading_params,
)
@@ -509,6 +535,7 @@ async def retrieve_all_fact_types_parallel(
graph_retriever: GraphRetriever | None = None,
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
tag_groups: list[TagGroup] | None = None,
) -> MultiFactTypeRetrievalResult:
"""
Optimized retrieval for multiple fact types using batched queries.
@@ -566,6 +593,7 @@ async def retrieve_all_fact_types_parallel(
thinking_budget,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
)
semantic_bm25_time = time.time() - semantic_bm25_start
@@ -584,6 +612,7 @@ async def retrieve_all_fact_types_parallel(
semantic_threshold=0.1,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
)
temporal_time = time.time() - temporal_start
@@ -591,9 +620,11 @@ async def retrieve_all_fact_types_parallel(
timings["temporal_combined"] = temporal_time
# Step 3: Run graph retrieval for each fact type in parallel
async def run_graph_for_fact_type(ft: str) -> tuple[str, list[RetrievalResult], float, MPFPTimings | None]:
async def run_graph_for_fact_type(
ft: str,
) -> tuple[str, list[RetrievalResult], float, GraphRetrievalTimings | None]:
graph_start = time.time()
results, mpfp_timing = await retriever.retrieve(
results, graph_timing = await retriever.retrieve(
pool=pool,
query_embedding_str=query_embedding_str,
bank_id=bank_id,
@@ -604,8 +635,9 @@ async def retrieve_all_fact_types_parallel(
temporal_seeds=None,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
)
return ft, results, time.time() - graph_start, mpfp_timing
return ft, results, time.time() - graph_start, graph_timing
# Run graph for all fact types in parallel
graph_tasks = [run_graph_for_fact_type(ft) for ft in fact_types]
@@ -614,7 +646,7 @@ async def retrieve_all_fact_types_parallel(
# Organize results by fact type
results_by_fact_type: dict[str, ParallelRetrievalResult] = {}
max_conn_wait = conn_wait # Single connection for semantic+bm25+temporal
all_mpfp_timings: list[MPFPTimings] = []
all_graph_timings: list[GraphRetrievalTimings] = []
for ft in fact_types:
# Get semantic + bm25 results for this fact type
@@ -623,14 +655,14 @@ async def retrieve_all_fact_types_parallel(
# Find graph results for this fact type
graph_results = []
graph_time = 0.0
mpfp_timing = None
graph_timing = None
for gr in graph_results_list:
if gr[0] == ft:
graph_results = gr[1]
graph_time = gr[2]
mpfp_timing = gr[3]
if mpfp_timing:
all_mpfp_timings.append(mpfp_timing)
graph_timing = gr[3]
if graph_timing:
all_graph_timings.append(graph_timing)
break
# Get temporal results for this fact type from combined result
@@ -651,7 +683,7 @@ async def retrieve_all_fact_types_parallel(
"temporal_extraction": temporal_extraction_time,
},
temporal_constraint=temporal_constraint,
mpfp_timings=[mpfp_timing] if mpfp_timing else [],
graph_timings=[graph_timing] if graph_timing else [],
max_conn_wait=max_conn_wait,
)
@@ -12,7 +12,11 @@ OR matching (any/any_strict): Memory matches if ANY of its tags overlap with req
AND matching (all/all_strict): Memory matches if ALL request tags are present in its tags
"""
from typing import Literal
from __future__ import annotations
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field
TagsMatch = Literal["any", "all", "any_strict", "all_strict"]
@@ -170,3 +174,217 @@ def filter_results_by_tags(
filtered.append(result)
return filtered
# =============================================================================
# Compound tag group models (recursive boolean expressions)
# =============================================================================
class TagGroupLeaf(BaseModel):
"""A leaf tag filter: matches memories by tag list and match mode."""
tags: list[str]
match: TagsMatch = "any_strict"
class TagGroupAnd(BaseModel):
"""Compound AND group: all child filters must match."""
model_config = ConfigDict(populate_by_name=True)
filters: list[TagGroup] = Field(alias="and")
class TagGroupOr(BaseModel):
"""Compound OR group: at least one child filter must match."""
model_config = ConfigDict(populate_by_name=True)
filters: list[TagGroup] = Field(alias="or")
class TagGroupNot(BaseModel):
"""Compound NOT group: child filter must NOT match."""
model_config = ConfigDict(populate_by_name=True)
filter: TagGroup = Field(alias="not")
# TagGroup is a discriminated union; Pydantic will try left-to-right.
# TagGroupLeaf is identified by the presence of 'tags'.
# TagGroupAnd / TagGroupOr / TagGroupNot are compound (no 'tags' key).
TagGroup = Annotated[
TagGroupLeaf | TagGroupAnd | TagGroupOr | TagGroupNot,
Field(union_mode="left_to_right"),
]
# Rebuild forward-reference models so recursive TagGroup is resolved.
TagGroupAnd.model_rebuild()
TagGroupOr.model_rebuild()
TagGroupNot.model_rebuild()
# =============================================================================
# SQL builder for compound tag groups
# =============================================================================
def _build_group_clause(
group: TagGroup,
param_offset: int,
table_alias: str,
) -> tuple[str, list, int]:
"""
Recursively build an inner SQL clause (no leading AND/OR) for a single TagGroup.
Returns:
(inner_clause, params, next_param_offset)
"""
if isinstance(group, TagGroupLeaf):
column = f"{table_alias}tags" if table_alias else "tags"
operator, include_untagged = _parse_tags_match(group.match)
if include_untagged:
clause = f"({column} IS NULL OR {column} = '{{}}' OR {column} {operator} ${param_offset})"
else:
clause = f"({column} IS NOT NULL AND {column} != '{{}}' AND {column} {operator} ${param_offset})"
return clause, [group.tags], param_offset + 1
elif isinstance(group, TagGroupAnd):
parts = []
params: list = []
offset = param_offset
for child in group.filters:
child_clause, child_params, offset = _build_group_clause(child, offset, table_alias)
parts.append(child_clause)
params.extend(child_params)
inner = " AND ".join(parts)
return f"({inner})", params, offset
elif isinstance(group, TagGroupOr):
parts = []
params = []
offset = param_offset
for child in group.filters:
child_clause, child_params, offset = _build_group_clause(child, offset, table_alias)
parts.append(child_clause)
params.extend(child_params)
inner = " OR ".join(parts)
return f"({inner})", params, offset
elif isinstance(group, TagGroupNot):
child_clause, child_params, next_offset = _build_group_clause(group.filter, param_offset, table_alias)
return f"NOT {child_clause}", child_params, next_offset
else:
# Should never happen with proper Pydantic validation
return "", [], param_offset
def build_tag_groups_where_clause(
tag_groups: list[TagGroup] | None,
param_offset: int,
table_alias: str = "",
) -> tuple[str, list, int]:
"""
Build a SQL WHERE clause for compound tag group filtering.
Top-level groups are AND-ed together. Each group is a recursive boolean
expression (leaf, and, or, not).
Args:
tag_groups: List of TagGroup objects. If None or empty, returns empty clause.
param_offset: Starting parameter number for SQL placeholders.
table_alias: Optional table alias prefix (e.g., "mu." for "memory_units mu").
Returns:
Tuple of (sql_clause, params, next_param_offset):
- sql_clause: SQL WHERE clause string starting with "AND" (or empty string)
- params: List of parameter values to bind (one per leaf node)
- next_param_offset: Next available parameter number
Example:
>>> groups = [TagGroupLeaf(tags=["user:alice"], match="all_strict")]
>>> clause, params, next_offset = build_tag_groups_where_clause(groups, 3)
>>> print(clause) # "AND (tags IS NOT NULL AND tags != '{}' AND tags @> $3)"
"""
if not tag_groups:
return "", [], param_offset
all_params: list = []
all_clauses: list[str] = []
offset = param_offset
for group in tag_groups:
inner_clause, group_params, offset = _build_group_clause(group, offset, table_alias)
all_clauses.append(inner_clause)
all_params.extend(group_params)
combined = " AND ".join(all_clauses)
return f"AND {combined}", all_params, offset
# =============================================================================
# Python-side filter for compound tag groups (post-retrieval filtering)
# =============================================================================
def _match_group(result: object, group: TagGroup) -> bool:
"""
Recursively evaluate a TagGroup against a retrieval result.
Args:
result: Any object with a 'tags' attribute (list[str] or None).
group: The TagGroup to evaluate.
Returns:
True if the result matches the group, False otherwise.
"""
if isinstance(group, TagGroupLeaf):
result_tags = getattr(result, "tags", None)
is_untagged = result_tags is None or len(result_tags) == 0
_, include_untagged = _parse_tags_match(group.match)
is_any_match = group.match in ("any", "any_strict")
tags_set = set(group.tags)
if is_untagged:
return include_untagged
else:
result_tags_set = set(result_tags)
if is_any_match:
return bool(result_tags_set & tags_set)
else:
return tags_set <= result_tags_set
elif isinstance(group, TagGroupAnd):
return all(_match_group(result, child) for child in group.filters)
elif isinstance(group, TagGroupOr):
return any(_match_group(result, child) for child in group.filters)
elif isinstance(group, TagGroupNot):
return not _match_group(result, group.filter)
else:
return True
def filter_results_by_tag_groups(
results: list,
tag_groups: list[TagGroup] | None,
) -> list:
"""
Filter retrieval results by compound tag groups in Python (for post-processing).
Used when SQL filtering isn't possible (e.g., graph traversal results).
Top-level groups are AND-ed together.
Args:
results: List of RetrievalResult objects with a 'tags' attribute.
tag_groups: List of TagGroup objects. If None or empty, returns all results.
Returns:
Filtered list of results where ALL top-level groups match.
"""
if not tag_groups:
return results
return [r for r in results if all(_match_group(r, group) for group in tag_groups)]
@@ -62,13 +62,14 @@ def format_facts_for_prompt(facts: list[MemoryFact]) -> str:
if fact.context:
fact_obj["context"] = fact.context
# Add occurred_start if available (when the fact occurred)
if fact.occurred_start:
occurred_start = fact.occurred_start
if isinstance(occurred_start, str):
fact_obj["occurred_start"] = occurred_start
elif isinstance(occurred_start, datetime):
fact_obj["occurred_start"] = occurred_start.strftime("%Y-%m-%d %H:%M:%S")
# Add temporal fields if available
for field_name in ("occurred_start", "occurred_end", "mentioned_at"):
value = getattr(fact, field_name, None)
if value:
if isinstance(value, str):
fact_obj[field_name] = value
elif isinstance(value, datetime):
fact_obj[field_name] = value.strftime("%Y-%m-%d %H:%M:%S")
formatted.append(fact_obj)
@@ -110,11 +111,7 @@ def build_think_prompt(
context: str | None = None,
entity_summaries_text: str | None = None,
) -> str:
"""Build the think prompt for the LLM.
Note: opinion_facts_text parameter removed - opinions are now stored as mental models
and included via entity_summaries_text.
"""
"""Build the think prompt for the LLM."""
disposition_desc = build_disposition_description(disposition)
name_section = f"""
@@ -131,7 +131,7 @@ class RetrievalResult(BaseModel):
text: str = Field(description="Memory unit text content")
context: str = Field(default="", description="Memory unit context")
event_date: datetime | None = Field(default=None, description="When the memory occurred")
fact_type: str | None = Field(default=None, description="Fact type (world, experience, opinion)")
fact_type: str | None = Field(default=None, description="Fact type (world, experience)")
score: float = Field(description="Score from this retrieval method")
score_name: str = Field(description="Name of the score (e.g., 'similarity', 'bm25_score', 'activation')")
@@ -140,9 +140,7 @@ class RetrievalMethodResults(BaseModel):
"""Results from a single retrieval method."""
method_name: Literal["semantic", "bm25", "graph", "temporal"] = Field(description="Name of retrieval method")
fact_type: str | None = Field(
default=None, description="Fact type this retrieval was for (world, experience, opinion)"
)
fact_type: str | None = Field(default=None, description="Fact type this retrieval was for (world, experience)")
results: list[RetrievalResult] = Field(description="Retrieved results with ranks")
duration_seconds: float = Field(description="Time taken for this retrieval")
metadata: dict[str, Any] = Field(default_factory=dict, description="Method-specific metadata")
@@ -319,7 +319,7 @@ class SearchTracer:
duration_seconds: Time taken for this retrieval
score_field: Field name containing the score in data dict
metadata: Optional metadata about this retrieval method
fact_type: Fact type this retrieval was for (world, experience, opinion)
fact_type: Fact type this retrieval was for (world, experience)
"""
retrieval_results = []
for rank, (doc_id, data) in enumerate(results, start=1):
@@ -11,8 +11,8 @@ from typing import Any
@dataclass
class MPFPTimings:
"""Timing breakdown for a single MPFP retrieval call."""
class GraphRetrievalTimings:
"""Timing breakdown for a single graph retrieval call."""
fact_type: str
edge_count: int = 0 # Total edges loaded
@@ -47,6 +47,8 @@ class RetrievalResult:
document_id: str | None = None
chunk_id: str | None = None
tags: list[str] | None = None # Visibility scope tags
metadata: dict[str, str] | None = None # User-provided metadata
proof_count: int | None = None # Number of supporting memories (observations only)
# Retrieval-specific scores (only one will be set depending on retrieval method)
similarity: float | None = None # Semantic retrieval
@@ -70,6 +72,8 @@ class RetrievalResult:
document_id=row.get("document_id"),
chunk_id=row.get("chunk_id"),
tags=row.get("tags"),
metadata=row.get("metadata"),
proof_count=row.get("proof_count"),
similarity=row.get("similarity"),
bm25_score=row.get("bm25_score"),
activation=row.get("activation"),
@@ -153,6 +157,7 @@ class ScoredResult:
"document_id": self.retrieval.document_id,
"chunk_id": self.retrieval.chunk_id,
"tags": self.retrieval.tags,
"metadata": self.retrieval.metadata,
"semantic_similarity": self.retrieval.similarity,
"bm25_score": self.retrieval.bm25_score,
}
@@ -82,20 +82,16 @@ class TaskBackend(ABC):
Args:
task_dict: Task dictionary to execute
Raises:
Exception: Re-raised from executor on failure.
"""
if self._executor is None:
task_type = task_dict.get("type", "unknown")
logger.warning(f"No executor registered, skipping task {task_type}")
return
try:
await self._executor(task_dict)
except Exception as e:
task_type = task_dict.get("type", "unknown")
logger.error(f"Error executing task {task_type}: {e}")
import traceback
traceback.print_exc()
await self._executor(task_dict)
class SyncTaskBackend(TaskBackend):

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