Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 0376c0251d feat(admin): add /admin config surface (API + control plane)
Server-level admin surface, gated by HINDSIGHT_API_ENABLE_ADMIN_API with an
optional, independent HINDSIGHT_API_ADMIN_TOKEN:

- API: GET /admin/config returns the resolved HindsightConfig with credentials
  redacted (set -> "***", unset -> null) via the credential denylist plus a
  name-suffix heuristic; features.admin_api exposed on /version.
- Control plane: top-level /admin page reusing the shared (now data-driven)
  Sidebar with a single "Configuration" item + a dedicated AdminHeader; server
  proxy forwards an independent HINDSIGHT_CP_ADMIN_TOKEN to /admin/*.
- Regenerated OpenAPI spec + Python/TS/Go clients; docs + .env.example; admin
  i18n across all locales.

Groundwork for the visibility leg of #2034. The connectivity test + health
surfacing land separately as a per-bank health endpoint.
2026-06-09 15:38:13 +02:00
Nicolò Boschi 109e1bd955 fix(api): stop forcing vchordrq.probes session GUC on listless vchord indexes (#2076)
Hindsight set a session-level vchordrq.probes override (10/30) for the
vchord backend, but VectorChord requires the probes value to match each
index's build.internal.lists hierarchy. Hindsight's built-in vchordrq
index clause does not set lists, so it is listless and expects 0 probes;
the session GUC supplies 1, and every query on that pooled connection
fails with "need 0 probes, but 1 probes provided".

On vchord deployments this rejects retain completions after extraction
succeeds, so the worker retries forever and the queue fills with stuck
retain ops that block consolidation.

Drop the vchord entries from the ANN tuning dispatcher so no session
probe override is applied; deployments that partition vchordrq indexes
should attach probes via index storage fallback parameters (VectorChord
1.1) instead. pgvector hnsw.ef_search tuning is unchanged.

Refs #1667.
2026-06-09 13:54:36 +02:00
Nicolò Boschi c0f0c3a769 fix(control-plane): drop locale slug from URLs (localePrefix never) (#2075)
Bank selection was lost on refresh for non-default locales because the
locale prefix (e.g. /es/banks/x) defeated path parsing in bank-context.
Switch next-intl to localePrefix "never" so the locale is resolved from
the NEXT_LOCALE cookie and never appears in the URL. Paths stay clean
(/banks/x) for every language, so the existing ^/banks/ parsing works.

Also removes the now-dead stripLocalePrefix() helper in middleware.

Supersedes #2070.
2026-06-09 13:29:20 +02:00
Nicolò Boschi 6ba4aeaf03 chore: format test files with ruff (enable formatter on tests/) (#2074)
Tests were excluded from both ruff lint and format via the top-level
[tool.ruff].exclude in hindsight-api-slim, hindsight-embed and the shared
ruff.toml. As a result test files drifted from the formatter's style and
every PR that touched a test (or ran format-on-save) carried large
formatting-only churn.

Move the tests exclude into [tool.ruff.lint].exclude (and [lint].exclude in
ruff.toml) so the formatter now covers tests while lint rules — too noisy for
test code (unused imports/vars, import ordering) — stay excluded. Then run
ruff format across all test directories.

Note: lint.exclude is a post-traversal path filter, so it needs the glob form
'tests/**' rather than the directory form 'tests/' used by top-level exclude.
2026-06-09 13:23:11 +02:00
Ben 9ea1ef164a release(obsidian): v0.1.0 2026-06-08 16:45:22 -04:00
Ben b0f86f9c0d feat(obsidian): Hindsight plugin for Obsidian (#1941)
* feat(obsidian): add Obsidian plugin integration

Sync an Obsidian vault into a shared Hindsight bank and chat with an agent
grounded on your notes (citations link back to the source note). Obsidian
stays the source of truth: one-way sync, conversation memory off by default.

- TS plugin (esbuild → main.js): requestUrl HTTP client, incremental sync
  engine (hash/mtime gate, upsert/delete/rename, reconcile + orphan prune),
  reflect-backed chat view with citations + reasoning, settings + commands.
- One shared bank ("obsidian") across vaults; implicit scoping via auto tags
  (vault:, folder: ancestors, created:/updated: date buckets) so recall can
  scope by any combo from the UI or an automation. document_id is
  vault-prefixed to avoid cross-vault collisions.
- Tests (vitest, mocked obsidian module): sync upsert/delete/rename/hash-gate,
  auto-scope tags, client request shapes, and the §0.5 guard (no conversation
  retain when the toggle is off).
- Wiring: test-obsidian-integration CI job + aggregate gate, VALID_INTEGRATIONS,
  changelog generator, integrations.json + docs page + changelog page + icon.

Out of scope for v1: rename-proof frontmatter identity; BRAT/community-store
release-asset attachment (release-integration.yml only npm-publishes today).

* feat(obsidian): scoped chat filters, retrieved-notes, debug logging, branding

- Chat scope filters (vault + folder dropdowns above the ask bar) build
  tag_groups (all_strict) passed to reflect; folder tags are hierarchical.
- "Notes retrieved" list + per-step reasoning: reflect's based_on omits
  document_ids, so harvest them from the recall/expand tool outputs (incl.
  nested observation source_facts). New reflect-util with a unit test.
- Debug logging toggle: logs the reflect request (with scope) and the
  retrieved note ids to the console for verifying filters.
- "New chat" view action + command to reset the conversation.
- Branding: real Hindsight logo (favicon) embedded as a data URI for the
  ribbon, chat header, empty state, and tab icon (via an SVG <image>).

* feat(obsidian): chat output extras — copy, snippet previews, wikilink resolution

- "Copy" action under each answer.
- "Notes retrieved" now shows the matched text snippet per note (from the
  recall/expand tool outputs + observation source_facts), so you can see why a
  note was pulled without opening it. New retrievedNotesDetailed() + test.
- Answers render with the active note as sourcePath, so [[wikilinks]] resolve.

* feat(obsidian): auto-grow chat composer + frontmatter/client edge tests

The composer textarea now grows with multi-line input up to a 240px cap,
then scrolls. Adds unit coverage for the two previously untested pure
layers: frontmatter.normalizeNote (no/blocklist/inline-flow frontmatter,
created/date precedence, scalar metadata, unterminated block) and client
edge paths (transport rejection, reflect tag_groups-vs-tags branch, retain
tag omission).

* ci(obsidian): attach BRAT install assets to the GitHub release

Obsidian plugins install from GitHub release assets (main.js, manifest.json,
styles.css), not npm. The release-integration workflow only npm-published
the package, leaving the plugin uninstallable. Add an obsidian-only step
that creates/updates the release for the tag and uploads the three files
(idempotent on re-run), and grant the job contents:write.

* chore(obsidian): fix generated-files drift (prettier + docs-skill mirror)

Run prettier over the integration (README.md table/emphasis formatting and
the new frontmatter.spec.ts array wrapping) and regenerate the agent-skill
changelog mirror that generate-docs-skill.sh produces. Resolves the
verify-generated-files CI check.

* feat(obsidian): persistent sync-status indicator in the status bar

Background, edit-triggered sync previously ran silently — only the manual
'Sync vault now' surfaced a Notice. Add an always-visible status-bar item
that shows synced/syncing/error state plus a live 'last synced x ago' time,
notes the pending-edit count, and triggers a sync on click. All sync paths
(reconcile, debounced flush, single-note ingest, delete, rename) route
through it. Pure label/tooltip logic is unit-tested (9 cases).

* feat(obsidian): mirror sync status in the chat header

Surface the same sync state in the chat panel's header (right-aligned),
reusing renderSyncStatus with no brand prefix since the Hindsight wordmark
is already shown. The plugin pushes updates to any open chat view whenever
sync state changes, and clicking the pill triggers a sync.

* feat(obsidian): show note count + pending in the sync indicator

Replace the bare check mark with the tracked-note count and either the
pending-edit count or the last-sync time (e.g. '✓ 412 notes · 2m ago',
'✓ 412 notes · 3 pending'). Tooltip carries the full breakdown. Count comes
from the local sync index; singular/plural handled.

* feat(obsidian): explicit refresh button for sync (spins while syncing)

The sync status was clickable text with no obvious affordance. Split it into
an informational status label plus a dedicated refresh icon button (in both
the chat header and the status bar) that triggers a sync on click and spins
while a sync is in flight.

* docs(obsidian): document the sync-status indicator in the README
2026-06-08 16:43:06 -04:00
Ben 9ca6617813 release(omo): v0.1.0 2026-06-08 16:22:55 -04:00
Derek Bouius 6dc56498ce feat(integrations): add oh-my-openagent (OMO) integration (#2018)
* feat(integrations): add oh-my-openagent (OMO) integration

Cloud-first Hindsight memory integration for the OMO agent harness.
Provides automatic recall/retain via lifecycle hooks with support
for both Hindsight Cloud (api.hindsight.vectorize.io) and self-hosted.

- 5 lifecycle hooks: SessionStart, UserPromptSubmit, Stop, SubagentStop, SessionEnd
- Always-apply rule for memory guidance
- Config hierarchy: settings.json → ~/.hindsight/omo.json → HINDSIGHT_* env vars
- Bearer token auth for cloud mode (hsk_* keys)
- Interactive demo script for local dev testing
- Full test suite (29 tests)

* chore(ci): add OMO integration test job

- Add test-omo-integration job to test.yml (pip + pytest pattern)
- Add detect-changes output and path filter for omo

* fix: apply lint formatting to OMO integration files

* fix: fix demo importlib.util import and add mkdir to setup instructions

- Import importlib.util explicitly (importlib alone doesn't expose .util)
- Add mkdir -p for ~/.omo/hooks and .omo/rules in README copy instructions
- Default demo API URL to localhost:8888 to match Docker compose port

* docs: rewrite OMO README with cloud-first setup as default

Simplify setup to 4 numbered steps with cloud as the primary path.
Move self-hosted to an optional section. Add testing section.
Clarify that rules are per-project while hooks/scripts are global.

* chore: add omo to VALID_INTEGRATIONS in release script

* fix: address release-blocking issues for OMO integration

- Remove pyproject.toml (causes release workflow to mis-classify omo as
  a Python package and fail uv build). Move pytest config to pytest.ini.
- Add IntegrationMeta entry in generate_changelog.py
- Add integrations.json entry with internal doc link
- Add docs page at docs-integrations/omo.md
- Add omo.svg icon
- Add "version": "0.1.0" to settings.json
2026-06-08 16:21:49 -04:00
Ben 9a9aef6225 release(cline): v0.1.0 2026-06-08 16:11:00 -04:00
Ben 66e58a23af feat(cline): Hindsight memory integration via lifecycle hooks (#1956)
* feat(cline): add Hindsight memory integration via lifecycle hooks (no MCP)

Gives Cline persistent long-term memory without MCP, using its lifecycle
hooks. TaskStart/UserPromptSubmit recall relevant memories and inject them
via contextModification; TaskComplete/TaskCancel retain the task transcript.
Cline hands hooks no transcript, so prompts are accumulated per-task in
local state and retained at task end. Reuses the agent-agnostic core from the
Codex integration (HTTP client, config, bank derivation, state, content
helpers). Includes an install.py, 34 tests, CI job, and release/docs wiring.

* refactor(cline): typed HindsightClineConfig instead of raw dict (review)

Address the code-review should-fix: replace the raw `config` dict (known,
enumerated keys) with a HindsightClineConfig dataclass per SKILL §5. load_config
maps the camelCase settings.json/env keys onto snake_case fields; consumers
read typed attributes. Also tighten type hints flagged in the review:
ensure_bank_mission (client: HindsightClient, debug_fn: Callable[..., None] |
None), _cast_env(typ: type) -> Any, debug_log(... ) -> None, parse_hook_input
(raw: dict[str, Any]), and client _headers/_request dict parameterization.
retain_metadata stays a dict (genuinely user-defined dynamic keys).

* refactor(cline): parameterize retain() metadata dict type
2026-06-08 16:09:46 -04:00
Ben e76021add3 blog: How oh-my-pi Built Persistent Codebase Memory on Hindsight (#2017)
* blog: How oh-my-pi Built Persistent Codebase Memory on Hindsight

Adoption case-study post on oh-my-pi (10k-star terminal coding agent
by @can1357) using Hindsight as its memory backend. All technical
details and code snippets pulled verbatim from the public repo at
github.com/can1357/oh-my-pi.

Covers: their three-mode bank-scoping policy (global / per-project /
per-project-tagged with the default being tag-based with `any` match);
the mental-model seed file (user-preferences, project-conventions,
project-decisions, each with delta-mode refresh_after_consolidation);
the debounced retain queue (16-item batch / 5s interval) and the
full-session auto-retain path; the auto-recall pipeline with the
exact preamble they use; and the reason they replaced
@vectorize-io/hindsight-client with a minimal fetch client.

Closes by tying the pattern back to other Hindsight-backed coding
agents (Hermes, Claude Code, OpenClaw) — same shape, different
implementations.

Cover image is a placeholder reusing the Hermes coding-assistant
card; final Hindsight x oh-my-pi art is a follow-up.

* blog(oh-my-pi): drop irrelevant Python-client aside

* blog(oh-my-pi): swap placeholder for omp + Hindsight branded cover

* blog(oh-my-pi): add Can Bölük (can1357) as co-author

* blog(oh-my-pi): apply final-revised draft

* blog(oh-my-pi): swap cover for retain/recall/reflect cycle diagram


* blog(oh-my-pi): bump date to 2026-06-08
2026-06-08 15:24:09 -04:00
Ben ccf0dc8268 release(haystack): v0.1.0 2026-06-08 14:54:36 -04:00
394d66e607 feat(integrations): add Haystack integration (#1256)
* feat(integrations): add Haystack integration for persistent agent memory

Add hindsight-haystack package providing Haystack Tool instances backed
by Hindsight's retain/recall/reflect APIs. Uses async client methods with
event-loop-safe sync wrapper to work correctly inside Haystack's agent
runtime.

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

* fix(haystack): use persistent event loop for async client calls

aiohttp binds its session to the creating event loop, so asyncio.run()
(which creates/destroys a loop per call) breaks on sequential calls.
Switch to a persistent daemon-thread event loop with
run_coroutine_threadsafe. Also removes unused per-operation timeout
constants and adds _run_sync tests.

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

* feat(haystack): add HindsightToolset with auto-recall/retain, fix review issues

- Add HindsightToolset(Toolset) with auto_recall and auto_retain flags
  that automatically inject recalled memories into the system prompt
  before each turn and retain user/assistant messages after each turn
- Fix _ensure_bank to retry on transient errors instead of permanently
  disabling bank creation
- Fix reflect_on_memory to return structured_output JSON when
  response_schema is set
- Truncate error messages to avoid dumping raw HTTP responses to agents
- Extract _build_backend_kwargs() and _build_tools() as shared helpers
- Add 20 new tests (60 -> 80 total) covering toolset, auto-recall,
  auto-retain, structured output, and bank creation retry

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

* fix(haystack): address review round 2 — max_recall_results, role metadata, _run_sync cleanup

- Add max_recall_results param to HindsightToolset (default 10) to cap
  auto-recall prompt injection size, matching Pydantic AI pattern
- Auto-retain now includes role + source metadata on messages, matching
  LlamaIndex's metadata pattern for distinguishable conversation turns
- _recall_for_prompt now calls the API directly with result cap instead
  of going through the formatted string from recall_memory
- Serialize/deserialize max_recall_results in to_dict/from_dict
- Add tests for max_recall_results and role metadata (82 total)

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

* fix(haystack): default to Cloud without configure(); add gated E2E + bucketing

- resolve_client() now falls back to DEFAULT_HINDSIGHT_API_URL and the
  HINDSIGHT_API_KEY env var when configure() was never called (it previously
  raised "No Hindsight API URL configured"). Updated the unit test to assert
  the cloud-default + env-key behavior. Satisfies the "default to Cloud" goal.
- Add a gated tests/test_e2e.py (retain/recall/reflect tools against a live
  Hindsight server), marked requires_real_llm; register the marker in
  pyproject; the test-haystack-integration CI job now runs the deterministic
  bucket (-m "not requires_real_llm").

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

* fix(haystack): close owned clients at exit; run E2E client I/O on the bridge loop

The tools run async client calls on a persistent background event loop. aiohttp
sessions bound to that loop were never closed, surfacing as "Unclosed client
session/connector" warnings. Track module-owned Hindsight clients (those created
when the caller didn't pass client=) and close them on the loop via an atexit
hook, then stop the loop. The live E2E now performs all client I/O through that
same loop (acreate_bank/adelete_bank/aclose via _run_sync) and logs cleanup
failures instead of swallowing them — zero unclosed-connector warnings.

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

* chore(haystack): sync openapi.json with main

Branch carried an older snapshot of hindsight-docs/static/openapi.json
that pre-dated main's addition of OperationProgress. Re-checkout main's
openapi.json so check-openapi-compatibility passes.

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

* fix(haystack): strip api_key from to_dict() so it doesn't leak to YAML

_build_backend_kwargs was emitting the api_key in the serializable dict
that to_dict() returns. Haystack pipelines get dumped to YAML for
inspection, checkpointing, and sharing — a serialized key leaks into
every dump. Reviewer (benfrank241) flagged this on #1256.

Drop the api_key from the serialized backend_kwargs. resolve_client()
already reads HINDSIGHT_API_KEY from the env var as a final fallback,
so a redeployed pipeline picks the key back up from the host's
environment rather than from the YAML.

The test_tools_round_trip_serialization_with_client test previously
asserted the leak — flipped it to assert the key is NOT present
and added a json.dumps probe asserting the literal key value also
doesn't appear under any other field name. Pre-fix, the test fails:
  AssertionError: api_key must not appear in serialized backend_kwargs
   — would leak to YAML pipeline dumps
  assert 'api_key' not in {'api_key': 'client-key', ...}

86/86 tests pass post-fix.

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

* ci: re-trigger CI

A previous push to this branch silently did not trigger a pull_request
event in GitHub Actions, leaving the PR without a CI run for the latest
HEAD. Push an empty commit to force a new event.

* ci: empty commit to attach pull_request CI check to the PR head

(Previous pushes did not auto-trigger pull_request workflow events for
reasons internal to GitHub Actions; manual workflow_dispatch runs passed
green but their checks don't roll up onto the PR. Re-poking the head
to surface the green state on the PR.)

* ci: trailing newline to force CI retrigger

* fix(haystack): register in changelog/gallery + docs page + tidy tools

Review follow-ups for the Haystack integration:

1. Add haystack to the INTEGRATIONS map in generate_changelog.py so the
   release script's changelog step resolves the slug (was missing, which
   would fail the release).
2. Add the integrations.json gallery entry, a doc page at
   docs-integrations/haystack.md, and an icon — required by
   check-integrations.mjs (forward: entry needs a doc page; reverse: a
   released integration must appear in the gallery).
3. Drop the inaccurate 'Raises: HindsightError' clause from
   create_hindsight_tools — resolution always succeeds (URL defaults to
   Cloud) so it never raises; the error type stays exported as the
   conventional public catch type.
4. Replace the _TOOL_DEFS dict-of-3-tuples with a frozen _ToolDef dataclass
   and drop the redundant method-name field (it equalled the dict key).

* fix(haystack): use official Haystack logo for gallery icon

Replace the placeholder glyph with the real deepset Haystack mark (teal
#0EAF9C rounded square + white symbol), extracted as vector from deepset's
own website source (deepset-ai/haystack-home site-logo partial).

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: DK09876 <[email protected]>
2026-06-08 14:52:38 -04:00
Ben 9891f53177 fix(docs): correct Grok Build icon path in integrations banner (#2063)
The rotating integrations banner referenced /img/icons/grok-build.png,
but the asset is grok-build.svg (the gallery already uses the .svg). The
missing .png rendered as a broken-image placeholder in the marquee. Point
the banner at the existing .svg.
2026-06-08 14:46:49 -04:00
Nicolò Boschi 8170fe880e docs: remove versioned docs for 0.5 and lower (#2059)
Drop Docusaurus versioned snapshots for 0.3, 0.4, and 0.5
(versioned_docs + versioned_sidebars) and remove their entries from
versions.json. Keeps 0.6, 0.7, and 0.8.

docusaurus.config.ts reads versions.json dynamically, so no config
changes are required.
2026-06-08 18:11:50 +02:00
Nicolò Boschi 95d77233bf fix(migrations): install maintenance routines on target_schema=public (#2056) (#2058)
The maintenance-routines migration (e5f6a7b8c9d0) only created the shared
public.banks_needing_consolidation() / public.schemas_with_expired_rows()
routines when the run had no target_schema at all. But the single-tenant
runtime always migrates an explicit schema, defaulting to public, so on
every default PostgreSQL deployment the migration was stamped applied while
the functions were never created. Background maintenance then logs
"function public.schemas_with_expired_rows(...) does not exist" and
"function public.banks_needing_consolidation() does not exist".

Since e5f6a7b8c9d0 is already stamped on affected 0.8.0 databases, editing
it would not re-run there. This adds a forward repair migration that
idempotently (CREATE OR REPLACE) reinstalls the routines on the run that
targets the shared public schema (base run, or explicit target_schema=public),
self-healing already-upgraded deployments and covering fresh upgrades.
Non-public tenant runs still skip it to avoid concurrent CREATE on the same
pg_proc row.

Fixes #2056
2026-06-08 17:52:03 +02:00
Ben 4a0a599473 fix(docs): add cursor-cli to integrations gallery (#2060)
The cursor-cli release (integrations/cursor-cli/v0.1.0, #1975) created a
release tag but never added the integration to the docs single source of
truth. check-integrations.mjs enforces that every released integration tag
has an entry in src/data/integrations.json with a matching doc page, so the
build-docs job has been failing on every PR (e.g. #866) — not from those PRs'
changes, but from the missing cursor-cli entry on main.

Add the gallery entry, the docs-integrations/cursor-cli.md page, and an icon.
Both invariants now pass locally.
2026-06-08 11:51:23 -04:00
Nicolò Boschi bfdc1c5e65 fix(deps): cap tokenizers<=0.23.0 for local-ML extras (#2055) (#2057)
* fix(deps): cap tokenizers<=0.23.0 for local-ML extras (#2055)

transformers (incl. 5.x) hard-requires tokenizers<=0.23.0 via a runtime
check, but tokenizers 0.23.1 is the latest on PyPI. Without a lockfile, an
in-place upgrade to 0.8.0 can resolve tokenizers 0.23.1 and break local
embeddings/reranker startup with an ImportError. Pin the compatible range
in the local-ml and local-onnx extras.

* chore(deps): update uv.lock for tokenizers cap (#2055)
2026-06-08 17:35:16 +02:00
Ben 37e28fac09 release(cursor-cli): v0.1.0 2026-06-08 11:30:11 -04:00
dbfe83a2ae feat(integrations): add Cursor CLI integration (#1975)
* Add .worktrees to .gitignore

* feat(integrations): add Cursor CLI integration

Four Cursor CLI hooks keep memory in sync automatically:

  - sessionStart       — health check + daemon pre-start
  - beforeSubmitPrompt — recall relevant memories and inject as
                         `additional_context`
  - stop               — read the on-disk transcript, retain the
                         conversation (fire-and-forget, async retain)
  - preCompact         — surface which memories will survive the next
                         context-window compaction

The integration follows the same shape as the existing codex
integration (Python hook scripts reading JSON from stdin, writing
JSON to stdout) and the same config schema, so users with a
codex setup can drop in cursor-cli with no new concepts.

Project resolution prefers Cursor's `CURSOR_PROJECT_DIR` env var
(common field in the hook runtime), then `workspace_roots[0]`,
then `cwd` — avoiding the codex `session` default granularity
since Cursor's `stop` hook is fire-and-forget.

CI:
  - new `test-cursor-cli-integration` job in .github/workflows/test.yml
  - `cursor-cli` added to VALID_INTEGRATIONS in scripts/release-integration.sh

Docs:
  - new top-level hindsight-integrations/README.md indexing every
    integration, with cursor-cli highlighted under "Coding agents & CLIs"

72 tests cover the four hook scripts, the bank-id derivation, the
HTTP client, the cursor transcript reader, and the chunked-retain
logic. All pass under `python -m pytest tests/ -v`. Ruff and
shellcheck are clean.

Co-Authored-By: opencode minimax-m3 high <[email protected]>

* fix(cursor-cli): derive bank id in session_start banner

The session banner used a static `config.get("bankId") or "cursor-cli"`
fallback, while recall.py / retain.py / pre_compact.py all called
`derive_bank_id(hook_input, config)`. With `dynamicBankId: true` and
`dynamicBankGranularity: ["project"]`, the banner reported the static
default ("cursor-cli") while the other hooks targeted the derived
bank (e.g. "korayem-cli-agents-hindsight"). Users and agents that
trusted the banner then called `hindsight memory reflect cursor-cli`
against an empty bank, while the hooks themselves were writing to
the correct one.

Mirror recall.py's pattern: import derive_bank_id, call it with the
parsed hook_input, surface the resolved bank in debug logs so users
can confirm parity with the other hooks.

Tests cover all four acceptance criteria:
  - dynamicBankId true → derived bank in banner
  - dynamicBankId false + explicit bankId → static bank in banner
  - HINDSIGHT_BANK_ID env override → resolved through config loader
  - regression: previous tests still pass

Co-Authored-By: opencode minimax-m3 high <[email protected]>

* refactor(cursor-cli): align implementation with codex/claude-code

The cursor-cli implementation shipped several invented surfaces and
patterns that drifted from the codex/claude-code reference. This
commit removes the inventions and brings the script bodies back
to near-parity with the references so future divergence stands
out in a diff.

Removed — invented user-facing surfaces:
  - session_start.py: the "Hindsight memory integration is active
    for this session. Bank: <id>" additional_context banner.
    The references' sessionStart is fire-and-forget with no
    additional_context. Banner output is where the bank-id
    display-mismatch bug lived, and the only consumer that "saw"
    the banner was the agent, which never asked for it.
  - pre_compact.py and its TestPreCompactHook class entirely.
    preCompact is observational in Cursor's spec — it cannot
    influence the compaction itself. The actual mechanism that
    preserves memory through compaction is the beforeSubmitPrompt
    recall that fires after compaction finishes. The "Hindsight
    preserved N memories" user_message was invented value with
    no reference equivalent.

Restored — patterns from codex that were dropped:
  - session_start.py: debug_log for "Hindsight not running" path
    (was changed to a noisier print).
  - recall.py: import time, import write_state, LAST_RECALL_STATE
    const, and the write_state(...) block that drops the most
    recent recall payload to ~/.hindsight/cursor-cli/state/.
    Dead code in codex, but matching the reference for now keeps
    the diff focused on actual cursor-specific differences.
  - recall.py: `prompt = (hook_input.get("prompt") or
    hook_input.get("user_prompt") or "")` — kept the user_prompt
    fallback for defense in depth.
  - retain.py: "Exit codes" section in the docstring and the
    inline comments / blank lines that codex uses for
    readability.
  - lib/__init__.py: removed the cursor-cli-specific docstring
    to match codex's empty file.

Kept — true Cursor-specific differences (justify in PR review):
  - session_start.py / retain.py / recall.py: docstrings mention
    Cursor, not Codex.
  - debug log key: conversation_id (Cursor's term) instead of
    session_id (codex's term). Cursor's `stop` hook carries
    conversation_id; codex's carries session_id.
  - session_id fallback chain: hook_input.get("conversation_id")
    or hook_input.get("session_id") or "unknown" — accepts both
    payload shapes.
  - template_vars includes conversation_id alongside session_id
    so retainTags / retainMetadata templates work either way.
  - retainTags default: ["{conversation_id}"] (codex is empty list)
    — convention is to tag the document with the source-of-truth id.
  - retainContext default: "cursor-cli" (was "codex").
  - agentName default: "cursor-cli" (was "codex").
  - bankMission / retainMission defaults: full text matching the
    Cursor CLI audience (codex leaves them empty).
  - USER_AGENT: "hindsight-cursor-cli/<version>" (was
    "hindsight-codex/<version>").
  - PROFILE_NAME: "cursor-cli" (was "codex") in daemon.py —
    controls the hindsight-embed profile name.
  - bank resolution: CURSOR_PROJECT_DIR env var → workspace_roots[0]
    → cwd (codex only uses cwd). Cursor sets CURSOR_PROJECT_DIR
    on every hook.
  - VALID_FIELDS in bank.py adds "gitProject" as an alias for the
    project resolution.
  - recall output schema: Cursor's beforeSubmitPrompt wants
    {continue, additional_context}, not codex's
    {hookSpecificOutput: {hookEventName, additionalContext}}.

Tests:
  - Removed TestSessionStartHook tests that asserted on the
    deleted banner.
  - Removed TestPreCompactHook class entirely.
  - test_session_start.test_no_output_when_server_reachable is
    the new mirror of codex's expectations: sessionStart emits
    nothing on stdout.

Net: -296 lines, 68 tests passing, ruff + shellcheck clean.

Co-Authored-By: opencode minimax-m3 high <[email protected]>

* fix(cursor-cli): flush memory at session end

Add a Cursor sessionEnd hook that forces a final retain so short sessions are stored even when retainEveryNTurns skips per-turn retention. Also remove stale preCompact/banner docs and align the daemon idle-timeout fallback with the shipped config.

Co-Authored-By: OpenAI GPT-5 Codex High <[email protected]>

* fix(cursor-cli): register integration in changelog generator

cursor-cli was added to VALID_INTEGRATIONS and CI but missing from the
INTEGRATIONS map in generate_changelog.py, which the release script reads
when generating the changelog entry. Without it, the release would fail at
the changelog step.

---------

Co-authored-by: opencode minimax-m3 high <[email protected]>
Co-authored-by: OpenAI GPT-5 Codex High <[email protected]>
2026-06-08 11:27:40 -04:00
Ben 7c2d1848ec release(roo-code): v0.1.0 2026-06-08 11:14:39 -04:00
Ben 644e37ac19 feat(roo-code): package as installable PyPI CLI (hindsight-roo-code) (#2054)
Turn the Roo Code integration into a pip-installable package so users can:

    pip install hindsight-roo-code
    hindsight-roo-code install [--api-url ...] [--project-dir ...] [--global]

- Move install logic into a hindsight_roo_code package with an
  argparse-based CLI exposed via a console_scripts entry point
- Ship the rules file as package data, read via importlib.resources so it
  resolves from the installed wheel
- Add pyproject.toml (hatchling), LICENSE, py.typed
- Add CLI tests; update install/rules tests to import from the package
- Switch the CI job to uv build + uv sync + uv run pytest
- Map roo-code -> hindsight-roo-code in the changelog generator
- Update README and docs to the pip install + CLI flow
2026-06-08 11:11:00 -04:00
Nicolò Boschi 8ccddd2406 docs: changelog and blog post for v0.8.0 (#2053)
* docs: changelog and blog post for v0.8.0

* docs(blog): tighten 0.8.0 post — demote ops/history, clarify retention scope

* docs(blog): add Operations & Observability section with LLM tracing screenshot; note reranker-free consolidation

* docs(blog): reframe consolidation as reliability/infra hardening against LLM drift

* docs(blog): add Background Operations screenshot to operations section

* docs(blog): quantify obs-dedup win (30%->1%) and link perf dashboard

* docs(skill): regenerate hindsight-docs skill for 0.8.0 changelog + openapi version
2026-06-08 16:36:37 +02:00
Nicolò Boschi a2de0b0dd6 release(opencode): v0.2.5 2026-06-08 15:54:16 +02:00
Nicolò Boschiandsdrobov 421cde6de1 fix(opencode): fold recall into the first system section, not a new one (#2052)
* fix(opencode): fold recall into the first system section, not a new one

OpenCode emits each system[] entry as a separate system message, and some
providers/LLMs only honor the first — so pushing recall as a new section can be
silently dropped. Append it to system[0] instead so recall is always seen.

Ports the approach from #1988 (@sdrobov) onto current main: applies it to the
order-independent system.transform recall path and the OpenCode-routed logger,
with a test that an existing system[0] is appended to (not pushed alongside).
Verified live: real recall folds into a single system entry containing both the
agent prompt and the memories block.

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

* chore(opencode): sync package-lock

---------

Co-authored-by: sdrobov <[email protected]>
2026-06-08 15:53:42 +02:00
Nicolò Boschi 8cadecb3a1 Release v0.8.0
- Update version to 0.8.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
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Create documentation version-0.8
2026-06-08 15:38:08 +02:00
Minghao XiaoandNicolò Boschi c2524473e7 fix(consolidation): set output token budget (#1967)
* fix(consolidation): set output token budget

* fix(consolidation): default max_completion_tokens to unset for full backwards compat

A 64k default still passes a raw value through to models LiteLLM does not
have a registry cap for (e.g. non-registered models on OpenAI/Gemini),
which is not a guaranteed no-op. Leaving it unset omits the key entirely
so every provider keeps its current implicit output budget — byte
identical to prior behaviour. Operators on providers with a low hidden
cap (notably Bedrock imported models) set the env var to fix #1939.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-08 15:27:04 +02:00
Nicolò Boschi 6166972023 test(entity-labels): reproduce paired id/name extraction from [[...]] tags (#2051)
Forum report (related to GH-1558): a user configures an 'application' entity
label (map type, tag=True) with multi-value 'id' and 'name' fields, marks up
source text with [[Matched Text (name, id)]] notation, and expects a consistent
{application:name:X, application:id:Y} pair per tagged element. They observe
inconsistent results: often only one half of the pair, sometimes neither, worse
when several tags share a chunk.

Adds a focused reproduction harness in test_entity_labels.py:
- two deterministic tests pinning the map post-processing mechanics (emits the
  full pair when the LLM returns both fields; faithfully drops half when it
  doesn't -- there is no backfill, so pairing must come from the model)
- one map-config end-to-end test (hs_llm_core): three tags in one chunk with
  non-canonical surface forms, asserting every element yields a complete pair

Finding: on gemini-2.5-flash the map config is robust -- complete pairs across
all runs (including denser/larger documents tried during investigation). The
reported inconsistency did not reproduce on this model, pointing to model
capability / much larger real documents as the likely driver. The harness is
parameterized so a weaker model can be plugged in to reproduce.
2026-06-08 15:24:47 +02:00
Nicolò Boschi 24abf373da docs(integrations): single source of truth (integrations.json) for gallery + sidebars (#2048)
* docs(integrations): single source of truth for sidebar + guardrails

Make src/data/integrations.json the single source for the Integrations
sidebar across every docs version, and add build-time guardrails so it
can't drift.

- Inject the Integrations sidebar category at render time from
  integrations.json via a DocRoot/Layout/Sidebar swizzle. Every docs
  version (current + frozen 0.3-0.7) now shows the same list, and adding
  one JSON entry is all it takes - no per-version sidebar edits. The
  sidebar files keep only a positional placeholder category (a link to
  the gallery), which the swizzle replaces.
- check-integrations.mjs, wired into `npm run build`:
  - forward: fail if a JSON entry has no docs-integrations/<slug> page
    (the injected sidebar isn't covered by Docusaurus link-checking).
  - reverse: fail if a released integration tag is missing from the JSON
    (skips gracefully without tags; excludes private cloudflare-oauth-proxy).
- Add the released-but-undocumented integrations to the JSON so the
  gallery + sidebar show them: claude-agent-sdk and superagent (with new
  doc pages) and paperclip.
- CI: fetch tags (fetch-depth: 0) in the docs build jobs so the reverse
  check can see them.

One name + one icon per integration come straight from the JSON; display
order is the JSON array order (manual, most-interesting-first).

* docs(code-review): require integrations.json entry + doc page for integrations

Add a review rule: every added/released integration must have an entry in
hindsight-docs/src/data/integrations.json (single source of truth for the
gallery + sidebar) and a docs-integrations/<slug> page, enforced by
check-integrations.mjs. Also note the changelog generator keeps its own
INTEGRATIONS list that must be updated for releases.

* docs(integrations): sidebar on (unversioned) integration pages + alphabetical order

- Give the integration doc pages their own sidebar without versioning them:
  point the unversioned `integrations` plugin at sidebars-integrations.ts,
  generated from integrations.json (doc items so each page associates with the
  sidebar and renders it). Previously these pages had sidebarPath: false (no
  sidebar at all).
- Sort integrations alphabetically by name in all three surfaces — the
  Integrations Hub gallery, the main docs sidebar, and the new integration-page
  sidebar — via a shared src/lib/integrations.ts helper (gallery + swizzle) and
  an inline sort in the config-loaded integration sidebar. JSON array order is
  no longer significant for display.
- The swizzle now only fills the main-docs placeholder category, leaving the
  generated integration-page sidebar untouched.

* docs(integrations): replace placeholder/wrong icons with official brand icons

Fetch real brand icons from each integration's official site (apple-touch-icon
/ high-res favicon) and point integrations.json at them, replacing
self-generated, generic, or reused placeholders:

- New brand icons for claude-agent-sdk, superagent, paperclip, codex, grok-build,
  ai-sdk, chat, local-mcp, openclaw, langgraph, autogen, opencode, n8n, pipecat,
  smolagents, dify, strands, outsystems, pydantic-ai, and refreshed many others
  (litellm, crewai, perplexity, llamaindex, vapi, flowise, hindclaw, agno,
  hermes, agentcore, google-adk, openai-agents, roo-code, skills, claude-code).
- claude-agent-sdk now uses the Claude/Anthropic brand (was reused claude-code
  icon); context-forge uses the MCP logo (it's an MCP gateway); superagent uses
  its pyramid logo (was generic package icon); paperclip its paperclip mark.
- Kept the existing real marks for nemoclaw (NVIDIA NeMo) and right-agent — no
  official brand favicon exists for those, and the auto-fetched candidates were
  wrong (a letter favicon / the repo author's avatar).
- Removed 7 now-orphaned icon files.

* ci(docs): add explicit integrations check step to build-docs

Run scripts/check-integrations.mjs as a named, fail-fast step before the docs
build (the build runs it too, but this surfaces it clearly and fails before the
slow build). Pure Node, no npm install; uses the tags already fetched via
fetch-depth: 0.

* ci(docs): trigger build-docs (integrations check) on integration changes

Add hindsight-integrations/** to the docs path filter so the integrations
single-source check runs on integration-only PRs (which can add/rename an
integration without touching hindsight-docs/**).
2026-06-08 15:23:17 +02:00
Nicolò Boschi 858095f3ba test(ci): harden LLM-as-judge against single-call verdict flips (#2050)
Nearly all hs_llm_core flakiness comes from the judge: a single temperature-0
call to the judge model occasionally flips its verdict on borderline phrasing,
failing a test whose system output was actually fine.

Harden the shared judge (used by ~49 assertions across 24 files) so every
judge-based test benefits at once:

- When the primary (temp-0) verdict is 'not met', collect N independent
  higher-temperature second opinions and uphold the failure only if the majority
  still agrees. Verdicts that pass on the first call return immediately, so
  passing tests are unchanged in cost and behaviour, and genuine failures (all
  judges agree) still fail. Tunable via HINDSIGHT_TEST_JUDGE_CONFIRMATIONS /
  _CONFIRM_TEMPERATURE.
- Retry transient judge-call errors (rate limits, 5xx) so judge-infra hiccups
  don't fail the test under evaluation (HINDSIGHT_TEST_JUDGE_CALL_ATTEMPTS).

Also add the standard @pytest.mark.flaky backstop to the mental-model
tag-security test, which lacked one.
2026-06-08 15:13:45 +02:00
Nicolò Boschi 27d5ac2832 release(opencode): v0.2.4 2026-06-08 13:04:36 +02:00
Nicolò Boschi 102416c428 fix(opencode): call OpenCode app.log as a method so logging actually works (#2049)
* fix(opencode): call OpenCode app.log as a method so logging actually works

0.2.3 routed logs through client.app.log but extracted it to a detached
reference (const log = client.app.log; log(...)). OpenCode's app.log is a class
method that uses `this` internally, so the detached call threw
'this._client is undefined' — swallowed by the try/catch, and the console
fallback was skipped because the reference was truthy. Net effect: 0.2.3 logged
nothing in real OpenCode (no resolved-endpoint line, no surfaced errors).

- Call app.log as a method on app so `this` is preserved.
- On synchronous failure, fall through to the console.error fallback instead of
  swallowing.
- Regression test with a this-dependent app.log (mirrors OpenCode's client).

Verified live against OpenCode 1.16.2: 'service=hindsight ... Hindsight plugin
initialized' and 'Injected recall context' now appear in the log stream.

* chore(opencode): sync package-lock version to 0.2.3

* fix(opencode): make autoRecall independent of session.created ordering (#1758)

autoRecall keyed off session.created marking recalledSessions and
system.transform consuming it — which silently disabled recall if
system.transform fired first (the relative order is an undocumented OpenCode
detail that has differed across versions; #1758 item 2).

Recall now runs on the first system.transform per session, using
recalledSessions purely as a dedup marker for sessions already recalled into.
session.created no longer participates. Behaviour is identical on 1.16.2 (where
created fires first) but no longer breaks if the order flips.

Verified order-independence with unit tests (recall before/after/without
session.created) and a built-plugin harness.
2026-06-08 13:04:05 +02:00
Nicolò Boschi 6de5024aaa test(ci): de-flake TEI parallelism timing + disposition judge reruns (#2045)
* test(ci): de-flake TEI parallelism timing + disposition judge reruns

Two pre-existing flaky tests that failed unrelated to their subject:

- test_tei_cross_encoder::test_parallel_requests asserted absolute elapsed
  < 0.08s to prove parallelism; CI scheduling jitter pushed it to 0.10s.
  Widen the simulated latency and assert comfortably below the serial time
  (max_concurrent_observed > 1 remains the deterministic parallelism proof).

- test_quality_integration::test_high_skepticism_response_is_more_hedged_than_low
  is a judge-evaluated disposition comparison that exhausted its 2 reruns in CI;
  bump to 3 (matching the heaviest LLM tests).

* fix(ci): prettier-format opencode plugin.test.ts (verify-generated-files)

CI runs prettier --write across all integrations and found opencode/src/
plugin.test.ts drifted from the shared .prettierrc.json (it was last hand-edited
in #2038), failing verify-generated-files on every PR. Apply the formatting the
generator expects (collapses a wrapped .toBe(...) to one line).
2026-06-08 12:35:49 +02:00
Nicolò Boschi 8bd44716a1 perf(recall): add recall-temporal suite that forces the temporal arm (#2046)
The existing recall suites only exercise the temporal retrieval arm
incidentally. This adds a dedicated 'recall-temporal' suite that stamps
all memories with one event_date and augments every query with a 1-day
window on it, so the temporal entry-point scan matches (near-)all rows —
the dense-temporal-zone regime from #1958 that #1983 bounded.

- _populate_bank gains an optional event_date for the clustered regime
- registered in SUITES; runs by default in the daily all-suites job
- added to the workflow_dispatch suite choices for manual single runs

Results flow to the perf dashboard automatically (publish script keeps
the full suites[] array); a matching 'Recall + temporal' page has been
added there.
2026-06-08 12:32:31 +02:00
Nicolò Boschi dbb0ada924 release(opencode): v0.2.3 2026-06-08 12:28:18 +02:00
Nicolò Boschi 796a9eff91 fix(opencode): observable logging — config-only debug, resolved-endpoint log, surfaced errors (#2047)
* fix(opencode): observable logging — config-only debug, resolved-endpoint log, surfaced errors

OpenCode users (notably on Windows) could see tool calls register but no
memories land, with zero signal as to why: every retain/recall failure was
swallowed via debugLog, the resolved API URL/bank was only logged when debug
was on, and HINDSIGHT_DEBUG is unreliable to set for OpenCode's plugin runtime.

- Add a Logger that routes through OpenCode's server log stream
  (client.app.log, service=hindsight) — TUI-safe, visible via --print-logs and
  the OpenCode log files. Falls back to console.error when no client.
- error/warn/info are always emitted; debug is gated on config.debug.
- Always log the resolved endpoint + bank at init (a common 'memories aren't
  saving' cause is silently defaulting to Hindsight Cloud).
- Surface retain/recall/hook failures as errors instead of swallowing them;
  hooks still never throw, so OpenCode is not affected.
- Drop the HINDSIGHT_DEBUG env override; 'debug' is now a config-only option
  (opencode.json plugin options or ~/.hindsight/opencode.json).
- Tests for the logger; update config tests; document the change.

Refs #1758

* style(opencode): prettier-format plugin.test.ts (pre-existing drift)

* docs(opencode): document config-only debug + default error/endpoint logging
2026-06-08 12:27:32 +02:00
Nicolò Boschi e774617625 feat(consolidation): periodic reconcile + cross-tenant retention via maintenance loop (#1969) (#2019)
* feat(consolidation): periodic reconcile + cross-tenant retention via maintenance loop (#1969)

Add a single background MaintenanceLoop (engine/maintenance.py) started in
MemoryEngine.initialize(), replacing the two per-recorder retention sweep tasks.
One ~60s tick runs each job on its own interval:

- Consolidation reconcile (HINDSIGHT_API_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS,
  default 300, 0=off): re-schedules consolidation for banks with eligible-but-
  unscheduled facts and no in-flight consolidation, recovering facts stranded
  when a consolidation operation failed terminally (#1969).
- Retention sweeps (hourly) for audit_log and llm_requests, now across ALL tenant
  schemas (the old sweeps only swept the base schema).

Cross-tenant discovery uses server-side PL/pgSQL routines (migration
e5f6a7b8c9d0): public.banks_needing_consolidation() and
public.schemas_with_expired_rows(table, ts_col, days) — one round-trip each
instead of a per-schema query storm at scale. Config gating resolves the full
hierarchy per returned bank (global/tenant/bank); Tenant gains an optional
tenant_id so tenant-layer overrides are honored.

* fix(consolidation): gate maintenance loop to PostgreSQL

The retention sweeps target PG-only tables and the reconcile relies on PG-only
PL/pgSQL routines, so on Oracle every tick would call non-existent functions and
spam warnings. Skip starting the loop when the backend is Oracle (mirrors the
PG-only migration).

* test(consolidation): 100-tenant maintenance loop targeting test

Provisions 100 tenant schemas (cloning the five tables the loop touches) and
verifies each job affects only the tenants it should: audit-log and llm-request
retention purge expired rows only in schemas that have them (recent rows kept
everywhere), and the consolidation reconcile enqueues only the eligible banks
into their own schema — skipping auto-consolidation-disabled, in-flight, and
already-consolidated banks.

* fix(migration): chain maintenance routines after the split-history head

After rebasing onto main, the maintenance-routines migration and #2007's
split-history migration (a7b8c9d0e1f2) both pointed at d3e4f5a6b7c8, creating two
alembic heads (test_single_head failed). Re-point down_revision to a7b8c9d0e1f2
so the tree is a single linear head again.

* fix(maintenance): create public routines once + stop loop racing tests

Two CI failures from the maintenance work:

1. Migration ran CREATE OR REPLACE FUNCTION public.* on every per-schema
   migration; concurrent tenant provisioning collided on the pg_proc catalog
   ('tuple concurrently updated'). Create the shared public routines only on the
   base-schema run (target_schema unset); tenant runs skip them.

2. The maintenance loop auto-starts in every test engine (llm-trace retention is
   on by default), and its background sweep deleted llm_requests rows that
   test_maintenance_multitenant had just inserted. Disable llm-trace retention in
   the test env too, so with reconcile already off and audit retention off by
   default no job is enabled and the loop never starts; tests drive it directly.
2026-06-08 11:59:07 +02:00
zwcf5200andNicolò Boschi aa024a5cde feat(recall): add configurable HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY (#2039)
* feat(recall): make semantic threshold configurable

* refactor(recall): rename semantic_threshold to semantic_min_similarity

Align the new semantic gate with its sibling BM25_MIN_SCORE: per-strategy
prefix, and 'min_similarity' since the value is a cosine similarity. Renames
the env var (HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY), config field, and the
build_semantic_arm parameter (min_similarity).

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-08 11:28:15 +02:00
Evo 227441a302 docs(configuration): document HINDSIGHT_API_DATABASE_BACKEND (postgresql|oracle) (#2024)
* docs(configuration): document HINDSIGHT_API_DATABASE_BACKEND (postgresql|oracle)

* docs(configuration): document HINDSIGHT_API_DATABASE_BACKEND (postgresql|oracle)
2026-06-08 11:16:13 +02:00
Minghao XiaoandNicolò Boschi 831f0efa10 fix(retain): expose retain outcome metadata (#2041)
* fix(retain): expose retain outcome metadata

* fix(retain): avoid double-counting batch extraction errors; drop dup json parse

- _write_batch_extraction_errors overwrites extraction_errors_* instead of
  folding in stored counters, which double-counted on batch crash recovery
  (resumed batch reprocesses all results and recomputes errors from scratch).
- Remove now-unused _parse_result_metadata helper and merge_errors method.
- Log retain-outcome-metadata write failures at warning (not debug): a missing
  write silently regresses clients to the ambiguous pre-fix behaviour.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-08 11:14:32 +02:00
Evo bd60c7575c docs(models): document the onnx embeddings provider (#2020) 2026-06-08 11:14:16 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> cc6fc94468 chore(deps): bump the uv group across 4 directories with 6 updates (#2027)
---
updated-dependencies:
- dependency-name: pyarrow
  dependency-version: 23.0.1
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: idna
  dependency-version: '3.15'
  dependency-type: indirect
  dependency-group: uv
- dependency-name: starlette
  dependency-version: 1.0.1
  dependency-type: indirect
  dependency-group: uv
- dependency-name: starlette
  dependency-version: 1.0.1
  dependency-type: indirect
  dependency-group: uv
- dependency-name: urllib3
  dependency-version: 2.7.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: python-multipart
  dependency-version: 0.0.27
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: idna
  dependency-version: '3.15'
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 11:13:12 +02:00
Willow LopezandClaude Opus 4.8 50b7eda2ab fix: remove Markdown bold formatting from fact extraction prompt (#2029)
The prompt template used **what**, **when** etc. as field labels.
This Markdown bold syntax leaked into LLM outputs causing non-JSON
responses across all tested models (GPT-4, Ollama models: gemma4,
kimi-k2, llama3.2, qwen3.5, glm-5.1).

Replaced **field** with "field" — same visual emphasis for the model
but no Markdown syntax to confuse JSON output parsing.

Fixes #1138

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-08 11:13:00 +02:00
Evo 78c27bfa74 docs(models): sync gemini + vertexai default models to 3.x matching config.py (#2030)
* docs(models): sync gemini + vertexai default models to 3.x matching config.py

* docs(models): regenerate skills mirror default-model table (gemini+vertexai 3.x)

* docs(models): sync Vertex AI walkthrough + gemini examples to 3.x (complete #2030 scope)

The defaults table fix (#2030) left the env-var examples and Vertex AI
setup walkthrough still handing users the retired gemini-2.0-flash-001
(404 on Vertex) and stale gemini-2.0-flash. Sync the prose surface:
- Vertex AI examples + google/ prefix note -> gemini-3.1-flash-lite (vertexai default)
- Gemini AI Studio example -> gemini-3.5-flash (gemini default)
Regenerated the CI-enforced skills mirror.
2026-06-08 11:12:37 +02:00
Evo b07392c97c docs(configuration): document shared cohere/litellm fallback API-key aliases (#2031)
* docs(configuration): document shared HINDSIGHT_API_COHERE_API_KEY / LITELLM_API_BASE / LITELLM_API_KEY fallback aliases

* docs(configuration): document shared HINDSIGHT_API_COHERE_API_KEY / LITELLM_API_BASE / LITELLM_API_KEY fallback aliases
2026-06-08 11:12:16 +02:00
Evo 727d3214cd docs(models): flag Fireworks AI Batch API support in the capabilities table (#2036)
FireworksLLM overrides supports_batch_api()->True (fireworks_llm.py:106),
and provider=="fireworks" dispatches to FireworksLLM (llm_wrapper.py:424),
but the base OpenAICompatibleLLM grants batch only to openai/groq
(openai_compatible_llm.py:1236) so the override is load-bearing. The
capabilities matrix in llmProviders.json was missing the fireworks
batchApi flag, rendering it as '-' (not supported) and understating the
provider. Regenerated the CI-enforced skills mirror (models.md).
2026-06-08 11:11:58 +02:00
Evo 3346363d2f fix(transfer): include mental_model_history count in import-bank CLI summary (#2032) 2026-06-08 11:11:51 +02:00
zwcf5200 f62500193f fix(trace): preserve RRF source ranks (#2040) 2026-06-08 11:07:32 +02:00
Nicolò Boschi e23e7ca909 release(opencode): v0.2.2 2026-06-08 11:02:38 +02:00
Evo e68d325830 fix(opencode): drop non-function export from plugin entry (#2028) (#2038)
OpenCode >=1.16 iterates every plugin-entry export and throws on any
non-function value; the re-exported DEFAULT_HINDSIGHT_API_URL string
bricked plugin load. Drop it from the entry (still exported from
./config) and add a regression test that the entry is function-only.
2026-06-08 10:52:14 +02:00
Evo 3c8ca47dda fix(reranker): make litellm-sdk reranker api_key optional for Bedrock IAM auth (#2043) 2026-06-08 10:45:33 +02:00
Evo 454069af4d docs(claude-code): correct enableKnowledgeTools default (false→true) and disabled-behavior after #1999 (#2044) 2026-06-08 10:45:11 +02:00
Nicolò Boschi 9622747759 release(superagent): v0.1.0 2026-06-08 10:44:56 +02:00
Nicolò Boschi 854d0a6283 fix(release): register superagent in changelog generator 2026-06-08 10:44:37 +02:00
Nicolò Boschi 36fd445003 release(claude-agent-sdk): v0.1.0 2026-06-08 10:38:53 +02:00
Nicolò Boschi 568fcea422 fix(release): register claude-agent-sdk in changelog generator 2026-06-08 10:38:53 +02:00
Evo c1089698b5 docs(api): document the progress snapshot + include_payload on the operation status endpoint (#2037)
PR #2013 added a durable progress snapshot (OperationProgress: stage/at/
processed/total/detail) plus an updated_at heartbeat and an include_payload
query param yielding task_payload to GET .../operations/{operation_id}, but
the 'Get operation status' docs had no response-field prose for any of them
(the example even passes include_payload without explaining it). Added a
response-fields subsection sourced from http.py. Regenerated the skills mirror.
2026-06-08 10:35:52 +02:00
b708302187 feat(integrations): add Superagent safety middleware (#1128)
* feat(integrations): add Superagent safety middleware for Hindsight memory

Adds hindsight-superagent integration that wraps Hindsight retain/recall/reflect
with Superagent Guard (prompt injection detection) and Redact (PII removal).

- SafeHindsight middleware class with configurable guard + redact pipeline
- Global configure() / per-instance config with env var fallbacks
- CI job and release script entry
- 54 unit tests + 10 e2e tests (all passing)

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

* fix(superagent): default to Hindsight Cloud URL when no URL is configured

Matches the pattern used by all other integrations — falls back to
https://api.hindsight.vectorize.io instead of erroring.

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

* fix(superagent): require superagent_api_key, update README defaults

- resolve_safety_client now raises HindsightError if no API key is
  provided, matching actual safety-agent behavior (create_client()
  requires a key)
- README: document superagent_api_key as required, hindsight_api_url
  defaults to Hindsight Cloud URL

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

* fix(superagent): disable broken fallback by default, add env var key resolution

The safety-agent SDK's default fallback endpoint (superagent.sh/api/fallback)
returns a 307 redirect that httpx doesn't follow for POST requests, causing
all guard() calls to fail on cold starts. This change:

- Defaults enable_fallback=False so the primary Cloud Run endpoint is used
  directly (60s timeout is sufficient)
- Exposes enable_fallback and fallback_timeout in config/SafeHindsight for
  users who want to opt back in
- Adds os.environ fallback for SUPERAGENT_API_KEY in resolve_safety_client
  so it works without calling configure() first
- Fixes e2e redact test that was blocked by guard on recall query

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

* fix(superagent): require explicit guard_model, increase client timeout

Superagent's hosted guard endpoints (Cloud Run Ollama) currently serve
empty model lists, making the default superagent/guard-1.7b unusable.
Update all examples to use guard_model="openai/gpt-4o-mini" and document
the self-hosting alternative. Increase Hindsight client timeout from 30s
to 120s to accommodate reflect's server-side LLM call.

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

* fix(superagent): disable guard on retain, fix e2e tests for OpenAI guard

General-purpose LLMs (gpt-4o-mini) over-classify PII content as security
violations, blocking retain before redact runs. Disable guard on retain
in all examples and default test helper. Fix e2e tests to use explicit
guard_model and OpenAI provider instead of broken hosted endpoints.

All 10 e2e tests now pass against live APIs.

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

* feat(superagent): switch guard/redact model to gpt-4.1-nano

gpt-4.1-nano correctly distinguishes prompt injection from legitimate
content (including PII), eliminating the need to disable guard on retain.
Re-enables full Guard → Redact → Retain pipeline.

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

* fix(superagent): add typed return values and py.typed marker

Replace Any return types on recall() and reflect() with
RecallResponse and ReflectResponse from hindsight-client.
Add py.typed marker for PEP 561 type checker support.

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

* style(superagent): fix ruff line-length formatting in _client.py

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

* feat(superagent): add enable_redact_on_recall + lazy SafetyClient

Two gaps surfaced by code review:

1. `enable_redact_on_recall` was missing.  Guard was configurable on every
   op (retain/recall/reflect) but redact was wired only into retain.  A
   memory like "John's SSN is 123-45-6789" stored from a non-safe path
   would come back verbatim through `recall()`.  Added the option to
   redact each result's text on the read path.

   Default is False rather than True because every result triggers its own
   redact call (N results → N round-trips), unlike retain which is always 1
   call.  Callers who care about read-path PII opt in.

2. SafetyClient was resolved eagerly in `SafeHindsight.__init__`, raising
   if SUPERAGENT_API_KEY was missing even when every safety hook was
   disabled.  Moved resolution behind a `_get_safety()` getter that
   constructs on first guard/redact call.  Explicit `safety_client=` still
   wins and is stored directly, so the "supply your own client" path is
   unchanged.

Tests: 62 pass (56 original + 3 redact-on-recall + 3 lazy-resolution).

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

* fix(superagent): address review-agent findings — env fallback, race, concurrency, scope

Addresses the 1 blocker + 8 should-fixes from the review-agent pass.

Blocker:
- resolve_hindsight_client() now reads HINDSIGHT_API_KEY env directly.  The
  base hindsight_client.Hindsight doesn't fall back to the env var on its
  own, so the constructor-only path (no prior configure() call) was silently
  dropping the key.  Fix: read os.environ.get(HINDSIGHT_API_KEY_ENV) as the
  third precedence step after explicit api_key and config.api_key.

Should-fix:
- Safety client config is now snapshotted at __init__ via snapshot_safety_config()
  and built lazily via build_safety_client() on first guard/redact call.
  A later configure() call cannot silently change what an already-constructed
  SafeHindsight will see.
- Redact-on-recall (and the new retain_batch / redact-on-reflect paths) run
  under an asyncio.Semaphore bounded by `redact_concurrency` (default 5).
  Wide recalls no longer stampede the Superagent rate limit.
- Added `enable_redact_on_reflect` — reflect's synthesised text is also LLM
  output derived from possibly-PII memories, so the same opt-in shape as
  redact-on-recall applies.  Off by default.
- Added `SafeHindsight.retain_batch(items)` wrapping aretain_batch with
  per-item guard + redact under the concurrency cap.  Any item's GuardBlocked
  aborts the whole batch before any store.
- Added `aclose()` + async context manager.  Closes owned underlying clients
  (Hindsight, SafetyClient) but leaves caller-passed clients alone.
- Pinned safety-agent to >=0.1.5,<0.2.0 and hindsight-client to >=0.4.0,<1.0
  so a pre-1.0 minor upstream bump can't silently change the API.
- Switched config-resolution precedence from `or`-chains to `_kw()` helper
  using `is not None`.  Explicit empty list / 0 / False kwargs now override
  global config instead of being treated as "unset".
- Tag merge in retain() now uses `dict.fromkeys(...)` instead of `set(...)`
  so order is preserved (call-tags first, then default tags, deduped).

E2E tests:
- TestE2EGuard block tests now actually assert that Guard blocks (with 3
  retries to absorb model variance).  Previously they silently passed if
  Guard returned "allow" — defeating the purpose.
- Same fix for the bare-Superagent `test_guard_blocks_injection`.
- Added E2E coverage for redact-on-recall, redact-on-reflect, retain_batch,
  and global-config-vs-per-instance-override precedence.

Unit tests:
- 15 new unit tests across 5 new test classes: TestSafetyConfigSnapshot,
  TestRedactConcurrencyCap, TestRedactOnReflect, TestRetainBatch,
  TestLifecycle, TestTagMergeOrder, TestEnvFallback.  All passing; total
  77 unit tests up from 62.

README updated with new options, lazy-resolution clarification, batch and
lifecycle sections.

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

* fix(superagent): round-3 review-agent findings — E2E rigor, validation, observability

Addresses 5 should-fixes, 2 nits, and 1 question from the round-3 review pass.

E2E rigor (should-fix):
- test_redact_strips_pii_from_stored_memory: previously passed silently if
  recall returned no results.  Now polls via _recall_until_nonempty() so
  empty results fail the test.  Same polling helper applied to every E2E
  that retains-then-recalls (redact-on-recall, redact-on-reflect,
  retain_batch, config precedence) so a non-indexed retain no longer
  silently turns an assertion into a non-assertion.
- test_recall_clean_query / test_reflect_clean_query: now assert the
  stored memory's content actually surfaces in recall/reflect output,
  not just that the response shape is valid.
- cleanup_banks fixture: extended suffix list to include every test class's
  bank (-redact-recall, -redact-reflect, -batch, -precedence) so the new
  E2Es don't leak banks.

Code correctness (should-fix):
- Validate safety_concurrency >= 1 in both SafeHindsight.__init__ and
  configure() — asyncio.Semaphore(0) would deadlock _redact_many() and
  the guard-batching path in retain_batch.  Raises ValueError early.
- Expand retain_batch to pass through every per-item field
  Hindsight.aretain_batch supports (metadata, document_id, entities,
  observation_scopes, strategy) and accept top-level document_id /
  document_tags kwargs.  Previous narrow surface forced callers to fall
  back to the raw client for any of those fields.

Naming + docs (nit):
- Rename `redact_concurrency` → `safety_concurrency`.  The same cap
  bounds both redact-many and the guard-batching loop in retain_batch,
  so the name "redact-only" was misleading.  Public kwarg, config field,
  and internal attr all renamed; tests + README updated.
- Align README requirements list with pyproject bounds: safety-agent
  >=0.1.5,<0.2.0 and hindsight-client >=0.4.0,<1.0.

Observability (question → resolved):
- Add `on_guard(scope, result)` callback invoked for every guard verdict
  (pass and block) so callers can log/observe non-block decisions without
  changing core flow.  Scope is one of "retain"/"recall"/"reflect"/
  "retain_batch".  Sync or async callable accepted; async is awaited.
  Callback fires before GuardBlockedError raises on block, preserving
  observability for the block path too.

Tests added: 12 new across TestSafetyConcurrencyValidation,
TestOnGuardCallback, TestRetainBatchFieldPassthrough.  Total: 87 unit
tests (was 77 → +10 net after the renames).  All passing.

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

* fix(superagent): round-4 polish — update_mode, retain_async, on_guard error containment

Addresses 2 should-fixes and 1 nit from the round-4 review.

retain_batch surface (should-fix):
- Added "update_mode" to _BATCH_PASSTHROUGH_KEYS.  Hindsight.aretain_batch
  reads item.get("update_mode") per item, so dropping it forced callers
  who wanted controlled upserts to fall back to the raw client.
- Added top-level `retain_async: bool = False` kwarg.  Hindsight supports
  background-processing the batch after the safety pipeline is done; the
  wrapper now exposes that knob.  Guard + Redact still run synchronously
  before the call returns — only the underlying store is deferred.  When
  the default False is used, the kwarg isn't forwarded so the client's own
  default wins.

on_guard error containment (nit):
- The callback is documented as observability "without changing the core
  flow," but a raised exception inside the callback previously took down
  the memory op.  Wrapped the call in try/except with a WARNING log so
  observability failures stay observable instead of fatal.  The log
  includes the scope and the exception type/message so an operator can
  spot a misbehaving callback.  Block-path behaviour is unaffected — if
  Guard says block, GuardBlockedError still raises after the callback
  attempt.

Tests: 93 unit tests pass (was 87; +6 net).  New cases cover update_mode
per-item passthrough, retain_async forwarding (and the don't-forward-on-
default case), sync and async on_guard exception containment, and that
a callback exception doesn't suppress a real block verdict.

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

* test(superagent): make E2E suite merge-clean — natural-language anchors, lifecycle

Live E2E run with the Superagent key surfaced two reproducible failures
plus aiohttp connector leaks.  Fixes:

1. test_redact_strips_pii_from_stored_memory — previously queried for
   "What is Bob's contact info?", which deterministically misses after
   redact strips Bob's name and email from the stored content.  A first
   attempt added a synthetic canary ("redact-pii-canary alpha bravo")
   alongside the PII, but Hindsight's fact extraction treats opaque
   identifier phrases as noise and drops them, so the canary itself
   didn't surface in recall either.  Fix is to use natural-language
   project context ("Project Phoenix client onboarding") as the anchor
   — fact extraction materialises it as a real fact, vector search
   handles it cleanly, and the assertion verifies (a) the anchor is
   retrievable and (b) the PII is absent from the result.

2. test_redact_on_reflect_scrubs_synthesis — same root cause, same fix.
   Anchor on "Project Tango payment notes" instead of a synthetic
   canary or PII-laden query.  The credit card sits secondary in the
   memory but isn't relied on for retrieval.

3. Unclosed aiohttp ClientSession / TCPConnector warnings — every test
   instantiated a SafeHindsight via _make_client() but never called
   aclose().  Added an autouse fixture that tracks every safe created
   via _make_client() and aclose()s them on test teardown.  Idempotent;
   exceptions during cleanup are swallowed so they don't mask the
   test's own result.

Result: 14/14 E2E pass in 74s (down from 127s due to fewer rerun
attempts on the previously-failing paths) with no unclosed-session
warnings.  93/93 unit tests still pass.

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

* style(superagent): apply ruff format (fixes verify-generated-files CI)

Same formatter drift as the other integrations: ruff check passed but ruff
format (run by the verify-generated-files job via scripts/hooks/lint.sh)
reflows manually-wrapped lines that fit within 120 cols. Formatting only —
no behavior change.

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

* test(superagent): bucket E2E as requires_real_llm; PR CI runs deterministic only

Mark the live E2E suite (real Superagent Guard/Redact + OpenAI + Hindsight)
with a module-level requires_real_llm marker, registered in pyproject,
mirroring the core test split from #1469. The test-superagent-integration job
now runs -m "not requires_real_llm" (deterministic bucket: 93 tests); the
real-LLM bucket (14 tests) is selectable via -m requires_real_llm.

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

* test(superagent): add deterministic retain->recall->reflect round-trip (mock bucket)

Drives SafeHindsight end to end with mocked Hindsight + Superagent clients,
asserting guard/redact-then-forward across all three ops — the in-CI / no-keys
analog of the live round-trip.

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

* chore(superagent): remove dead resolve_safety_client

resolve_safety_client at _client.py:87 was a convenience wrapper around
snapshot_safety_config + build_safety_client, with a docstring saying
"kept for backwards compatibility — combines snapshot + build into one
call". As reviewer (benfrank241) flagged on PR #1128: there's nothing
to be backwards compatible with — this is a new package. The middleware
(SafeHindsight) uses snapshot_safety_config + build_safety_client
directly. The function had no real callers.

Drop:
- The function itself from _client.py.
- TestResolveSafetyClient class from tests/test_client.py (its 6 tests
  only exercised the dead wrapper).
- The corresponding import.

test_middleware.py::test_unsafe_path_does_not_resolve_safety_client
stays — the "resolve" there is a generic verb describing whether the
middleware needs to construct a safety client at all, not a reference
to the deleted function. That test still verifies the lazy-construction
semantics it always did.

Test suite: 88 passed, 14 skipped (down from 88+6 = 94 passed; the 6
removed were the wrapper-only tests). Middleware coverage unchanged.

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

* chore(superagent): ruff format/check fixes for verify-generated-files CI

verify-generated-files flagged _client.py drift (2 trailing blank
lines after the resolve_safety_client removal) plus 3 additional
small lint findings ruff check could autofix. Running the full
ruff format + ruff check --fix pipeline brings the diff to zero
against what CI expects.

No behaviour changes; format-only.

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: DK09876 <[email protected]>
2026-06-05 16:45:34 -04:00
Ben 18c45c9d01 release(opencode): v0.2.1 2026-06-05 16:44:30 -04:00
DK09876andClaude Opus 4.7 06f36b8b25 fix(opencode): default to Hindsight Cloud + gated live E2E (#1915)
* fix(opencode): default to Hindsight Cloud + gated live E2E

Aligns OpenCode with the cloud-default convention adopted across the
Python integrations (LangGraph, Haystack, OpenAI Agents, LlamaIndex,
AutoGen).

Changes:

- config.ts: introduce DEFAULT_HINDSIGHT_API_URL =
  "https://api.hindsight.vectorize.io". Set DEFAULTS.hindsightApiUrl to
  it so the plugin works out-of-the-box against Hindsight Cloud (API key
  via HINDSIGHT_API_TOKEN). Self-hosters override hindsightApiUrl. Also
  re-export the constant from index.ts.

- index.ts: drop the "No API URL configured" branch that returned empty
  hooks. The URL always resolves now (default = Cloud), so the plugin
  always returns its full tool + hook surface. Requests fail at call
  time with a clear server error if no key is configured against Cloud,
  matching the framework's goal-5 contract ("API key not required at
  construction; fails at call time if missing").

- tools.ts: add an index signature to HindsightTools so the object is
  assignable to OpenCode's Hooks.tool (Record<string, ToolDefinition>)
  without losing the three concrete keys. Fixes a pre-existing dts
  build error that was previously masked by the now-removed empty-hooks
  return branch.

- README.md: restructure Quick Start so Cloud is the primary path
  ("enable plugin + set HINDSIGHT_API_TOKEN"); move self-hosted under a
  secondary heading; update the env-var table to show the new default.

- e2e.test.ts (new): gated live test (skipped unless
  HINDSIGHT_LIVE_E2E=1) covering the three contract surfaces — agent
  tool path (retain → server-side extraction → recall), session.idle
  auto-retain, session.created + system.transform inject. TS equivalent
  of the `requires_real_llm` pytest marker used by the Python
  integrations. Exposed as `npm run test:e2e`.

- plugin.test.ts: replace the "returns empty hooks when no URL" test
  with "defaults to Hindsight Cloud" — asserts the client is constructed
  with DEFAULT_HINDSIGHT_API_URL and the full hook surface is returned.

- config.test.ts + test-helpers.ts: update default-value expectations to
  the new cloud-default constant.

- package.json: version 0.2.0 → 0.2.1; add `test:e2e` script.

Verification:
- Deterministic vitest: 6 files / 101 tests pass, 1 file / 3 tests
  skipped (the gated E2E).
- Live vitest (HINDSIGHT_LIVE_E2E=1, against a local Hindsight server):
  7 files / 104 tests pass.
- `npx tsc --noEmit`: clean.
- `npm run build` (tsup): ESM + DTS both succeed.

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

* fix(opencode): reword 'Hindsight Cloud' in test files for OSS-clean (V2 audit)

V2 audit (2026-06-02) flagged two 'Hindsight Cloud' strings in TS test
files under a strict reading of Goal-4 (which says shipped source — .py
and .ts — should not name the cloud product):

- src/e2e.test.ts:14 (file-header comment): 'For Hindsight Cloud:
  HINDSIGHT_API_TOKEN' → 'When pointing at the hosted backend:
  HINDSIGHT_API_TOKEN'
- src/plugin.test.ts:44 (test description): 'defaults to Hindsight Cloud
  when no API URL' → 'defaults to the hosted backend URL when no API URL'

Test behaviour unchanged. The README and PR descriptions can still
name the product.

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

* test(opencode): pass HINDSIGHT_API_TOKEN to live e2e direct client

The live e2e suite's direct (non-plugin) HindsightClient was constructed
with only { baseUrl: URL }, no apiKey. Against `127.0.0.1:8888` that's
fine — local has no auth. Against `api.hindsight.vectorize.io` the test's
own retain/recall/deleteBank calls 401, masking the fact that the plugin
path itself works against Cloud.

The plugin already reads HINDSIGHT_API_TOKEN from env via its config
resolution. Have the test mirror it: when TOKEN is present, construct
with apiKey. When absent (local-only run), keep the previous shape.

Verified:
- HINDSIGHT_LIVE_E2E=1 against LOCAL (no token):     104/104 pass
- HINDSIGHT_LIVE_E2E=1 against CLOUD (with token):   104/104 pass
- npm test deterministic (no env):                    101/101 + 3 skipped

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

* chore(opencode): prettier format README + e2e.test.ts

verify-generated-files CI flagged drift in:
- hindsight-integrations/opencode/README.md
- hindsight-integrations/opencode/src/e2e.test.ts

Both are pure prettier formatting (line wrapping in README, single
quoted -> double quoted spacing in e2e.test.ts). Running
`npx prettier --write` brings the diff to zero.

No behaviour changes.

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

* chore(opencode): sync openapi.json with main

Branch carried an older snapshot of hindsight-docs/static/openapi.json
that pre-dated main's addition of OperationProgress. Re-checkout main's
openapi.json so check-openapi-compatibility passes.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-05 16:41:29 -04:00
Ben a74e5e6b5a release(openai-agents): v0.1.2 2026-06-05 16:41:11 -04:00
c01fc12f7e fix(openai-agents): default to Cloud + gated E2E + requires_real_llm bucketing (#1866)
* fix(openai-agents): default to Cloud without configure(); add gated E2E + bucketing

- resolve_client() now falls back to DEFAULT_HINDSIGHT_API_URL and the
  HINDSIGHT_API_KEY env var when configure() was never called (it previously
  raised). Updates the tools + memory_instructions raise-tests to assert the
  cloud-default + env-key behavior. Satisfies the "default to Cloud" goal.
- Add a gated tests/test_e2e.py covering retain/recall/reflect via
  await tool.on_invoke_tool(...) and memory_instructions(), all against a live
  Hindsight server. Marked requires_real_llm; register the marker in pyproject;
  the test-openai-agents-integration CI job now runs the deterministic bucket
  (-m "not requires_real_llm").
- Fix version drift: _version.py was "0.1.0" while pyproject said "0.1.1".
  Sync to 0.1.1 + update the User-Agent assertions in test_tools.py.

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

* ci(openai-agents): wire test-openai-agents-integration into aggregate-gate

Audit finding (2026-06-02): the test-openai-agents-integration job is
defined (test.yml L3019) and runs successfully, but is missing from the
report-pr-status job's `needs:` list. That means a failure of this
specific integration job does not block the aggregate pass on
pull_request_review. Pre-existing oversight — the omission predates this
PR — but it's worth closing now so the OpenAI Agents integration's CI
matters for merge gating.

One-line addition: add `- test-openai-agents-integration` to the needs
list, grouped with the other Python integrations.

Verification: YAML parses; no other change needed — the job definition
itself was already correct.

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

* chore(openai-agents): sync openapi.json with main

Branch carried an older snapshot of hindsight-docs/static/openapi.json
that pre-dated main's addition of OperationProgress. Re-checkout main's
openapi.json so check-openapi-compatibility passes.

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

---------

Co-authored-by: DK09876 <[email protected]>
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
Co-authored-by: Ben <[email protected]>
2026-06-05 16:39:44 -04:00
Ben df7f45e698 release(litellm): v0.5.4 2026-06-05 16:28:45 -04:00
dfe74b1de9 fix(litellm): injection_mode, context manager restore, validation, error consistency (#1711)
* feat(litellm): expand recall/reflect/hindsight_memory APIs and fix default URL

- recall(): add include_entities, trace, recall_tags, recall_tags_match params
  (previously only supported via the callback/enable() path, not the manual API)
- reflect(): add recall_tags, recall_tags_match params (same gap)
- hindsight_memory(): default URL now matches configure()/wrap_openai()/wrap_anthropic()
  instead of hardcoding localhost; add session_id, use_reflect, reflect_context,
  tags, recall_tags, recall_tags_match params
- Document that enable() and HindsightCallback are mutually exclusive injection
  paths to prevent accidental double injection
- Add 17 tests covering all new behaviour

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

* fix(litellm): strip hindsight_bank_id from kwargs before LiteLLM call and add sync param to aretain

- hindsight_bank_id kwarg was leaking into LiteLLM as extra_body, causing
  OpenAI 400 errors; now popped in completion(), _wrapped_completion(),
  _wrapped_acompletion() and propagated as bank_id_override throughout
  injection and storage paths
- _inject_memories() accepts bank_id_override to honour per-call bank
  without mutating globals
- _store_conversation() and _store_conversation_from_text() accept
  bank_id_override for consistent per-call storage routing
- _LiteLLMStreamWrapper and _LiteLLMAsyncStreamWrapper carry
  bank_id_override so streamed responses store to the right bank
- aretain() now accepts sync=True, forwarding it to retain()

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

* fix(litellm): design review fixes — injection_mode, context manager restore, validation, error consistency

- config.py: remove DEFAULT_BANK_ID footgun (configure() without bank_id now
  leaves bank_id=None; is_configured() and enable() correctly require explicit
  bank_id). Add _restore_config() for atomic state restoration. Add
  budget/recall_tags_match validation in configure() and set_defaults().
  Emit DeprecationWarning for document_id usage.

- __init__.py: _inject_memories() now respects injection_mode
  (PREPEND_USER prepends to last user message; SYSTEM_MESSAGE keeps existing
  behaviour). Wire up defaults.query as fallback recall query. Fix
  ValueError → HindsightError for missing bank_id. hindsight_memory()
  finally block now calls _restore_config() to atomically restore all settings
  (previously lost: sync_storage, tags, recall_tags, recall_tags_match,
  reflect_context, reflect_response_schema). Add _enabled_lock and _debug_lock
  for thread safety on shared mutable state.

- callbacks.py: ValueError → HindsightError in log_pre_api_call and
  async_log_pre_api_call for missing bank_id, consistent with __init__.py.

- tests: update tests that relied on DEFAULT_BANK_ID behaviour; add
  TestValidation, TestInjectionMode, TestQueryField, TestHindsightErrorConsistency,
  TestContextManagerFullRestore (83 tests, all passing).

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

* fix(litellm): run ruff format and update test_config.py for no-default-bank-id behaviour

- Run ruff format on __init__.py and wrappers.py to match CI lint expectations
- test_config.py: update test_configure_with_no_arguments to assert bank_id is None
  (not DEFAULT_BANK_ID) and rename test_is_configured_true_with_defaults to
  test_is_configured_false_without_explicit_bank_id with corrected assertion,
  matching the removed DEFAULT_BANK_ID footgun

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

* fix(litellm): declare hindsight-client dep, add E2E suite, implement set_bank_mission

Addresses PR review blockers and one user-facing should-fix:

1. **hindsight-client missing from dependencies** — the package imports
   `hindsight_client` and `hindsight_client_api` in 11+ places but never
   declared the dep, so `pip install hindsight-litellm` from PyPI raised
   ModuleNotFoundError on any retain/recall/reflect path.  Add explicit
   `hindsight-client>=0.4.0` to project deps.

2. **E2E suite was out-of-tree** — moved the 23-test live-API suite into
   `tests/test_e2e.py` with env-var-based `HINDSIGHT_API_URL` and
   skip-on-missing-keys markers (`requires_hindsight`, `requires_openai`,
   `requires_all`) matching the sibling integrations' layout.  Tests
   collect cleanly; skip when no live server / OpenAI key is available.

3. **set_bank_mission() was documented but never implemented** —
   README.md showed `hindsight_litellm.set_bank_mission(mission=..., name=...)`
   as a public API, but no such function existed.  Implement it as a thin
   wrapper around `Hindsight.create_bank()` that resolves bank_id /
   url / api_key from the configured defaults, with HindsightError on
   missing bank_id or underlying client failure.  Add 4 unit tests.

4. Add `Python :: 3.13` to package classifiers.

Unit tests: 113 passed (was 109, +4 new set_bank_mission tests).

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

* fix(litellm): dual-injection guard, LRU dedup cache, excluded_models in enable() path

Three correctness should-fixes from the PR review:

1. **Dual-injection footgun guard** — when both enable() and a
   HindsightCallback registered on litellm.callbacks were active,
   memories would be injected twice (once by the monkeypatch, once by
   the callback running inside the original litellm.completion).
   - enable() now scans litellm.callbacks at install time and emits a
     RuntimeWarning if a HindsightCallback is already present.
   - HindsightCallback.log_pre_api_call / async_log_pre_api_call now
     short-circuit when is_enabled() returns True, so registering a
     HindsightCallback after enable() no longer double-injects.

2. **Dedup cache LRU + thread safety** — _recent_hashes was a Set[str]
   without a lock; set.pop() evicted an arbitrary entry rather than the
   oldest, and the cache was mutated from both the sync log_success_event
   and the async executor path with no synchronization. Replace with
   OrderedDict + threading.Lock, move_to_end on hits for true LRU, and
   popitem(last=False) on eviction.

3. **excluded_models honored in enable() monkeypatch path** — the
   excluded_models config was previously only checked by the
   HindsightCallback path; _wrapped_completion / _wrapped_acompletion
   would inject memories on every model regardless. Add an early-out
   that calls the original litellm function untouched when the model
   matches any excluded_models glob.

Unit tests: 119 passed (was 113, +6 new tests covering each fix).

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

* fix(litellm): close wrapper clients + own one event loop; test hygiene

wrap_openai()/wrap_anthropic() wrappers gain close()/context-manager support
so the cached Hindsight client (and its aiohttp session) is released; this
eliminates the unclosed client-session/connector ResourceWarnings.

Replace the per-call `new_event_loop()` bridges with a single owned per-thread
loop (hindsight_litellm/_async.py), set as the thread's current loop so the
client reuses it and the `asyncio.get_event_loop()` deprecation (which becomes
an error on 3.14) no longer fires from our sync paths. The loop is
deliberately NOT closed in cleanup(): a shared loop closed under a live client
raises "Event loop is closed", so close_loop() is a documented manual-only
shutdown helper.

Test hygiene: add pytest-asyncio to the dev dependency group (fixes the
"Unknown config option: asyncio_mode" warning), close clients in the E2E
fixtures, and add unit tests for wrapper close() and the _async bridge.

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

* style(litellm): sort _async import before config (ruff I001)

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

* fix(litellm): own loop in wrap bank-setup + correct loop-lifecycle docs

- ensure_loop() now runs before wrap_openai()/wrap_anthropic() create the
  bank/mission setup client, matching _get_client and the config bank paths
  (no orphaned loop / get_event_loop deprecation on that path).
- Correct stale comments + module docstring that claimed cleanup() closes the
  owned loop — it does not; close_loop() is a documented manual-only helper.
- Convert the flaky context-manager E2E test from a fixed sleep to polling.
- Add unit coverage for wrap bank-setup loop ownership.

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

* style(litellm): apply ruff format (fixes verify-generated-files CI)

ruff check passed but ruff format (run by the verify-generated-files job via
scripts/hooks/lint.sh) reflows manually-wrapped lines that fit within the
120-col limit. Formatting only — no behavior change.

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

* test(litellm): bucket E2E as requires_real_llm; PR CI runs deterministic only

Mark the live E2E suite (real Hindsight + provider calls) with a module-level
requires_real_llm marker, registered in pyproject, mirroring the core test
split from #1469. The test-litellm-integration job now runs
-m "not requires_real_llm" (deterministic bucket: 134 tests); the real-LLM
bucket (23 tests) is selectable via -m requires_real_llm for a dedicated or
nightly job.

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

* test(litellm): add deterministic full inject-flow test (mock bucket)

Mocks the Hindsight client's recall and spies litellm.completion to assert the
recalled memory is injected into the messages the LLM receives — the in-CI /
no-keys analog of the live enable()/completion tests. Runs in the deterministic
bucket.

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

* fix(litellm): thread api_key into _get_client on the inject path

Audit finding (2026-06-02): hindsight_litellm/__init__.py:311 constructs the
Hindsight client via _get_client(config.hindsight_api_url) without forwarding
config.api_key. The retain path threads the key correctly via wrappers.py
(L177/355/471), but the recall/reflect injection path doesn't — so Hindsight
Cloud writes succeed while reads return 401 "Authentication failed: API key
required". The earlier review pass missed this because it tested only against
a local self-hosted server; an out-of-session audit ran the user-perspective
driver against api.hindsight.vectorize.io with an hsk_ key and caught the
asymmetry.

Fix: forward config.api_key as the second positional argument. Single-line
behavioral change.

Regression pin: TestInjectionPathPassesApiKey — configures the integration
with a Cloud-shaped URL + key, patches _get_client to capture call args,
runs _inject_memories, asserts the configured key was forwarded. Tolerates
positional and keyword call forms.

Other audit-suggested callsites (587/643/1343/1425) were _inject_memories
invocations, not _get_client; they don't carry api_key directly. wrappers.py,
config.py, and the cached-client paths in HindsightOpenAI / HindsightAnthropic
already pass the key.

Verification:
- Deterministic bucket: 136 pass (135 prior + 1 regression).
- Live bucket: 12 pass / 11 skipped / 0 failed (skips are
  provider-key-conditional, not affected by this change).

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

* fix(litellm): reword 'Hindsight Cloud' references for OSS-clean (V2 audit)

V2 audit (2026-06-02) caught two 'Hindsight Cloud' literals introduced by
the cloud-injection 401 fix (commit 3083a4a2):

- __init__.py:309 (comment): 'Hindsight Cloud rejects un-keyed recall/reflect'
  → 'the hosted backend rejects un-keyed recall/reflect'
- tests/test_integration.py:1423 (assertion message): 'breaks Hindsight Cloud
  reads' → 'breaks reads against the hosted backend'

Goal-4 (OSS-clean) of the integration-review rubric: shipped .py source
should not name the cloud product. The README and PR descriptions still
can. This restores compliance — behaviour and the regression test pin
itself are unchanged.

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

* fix(litellm): forward sync=True to retain() in sync_storage path

When configure(sync_storage=True) was set, _store_conversation() and
_store_conversation_from_text() called the package-level retain() without
passing sync=True. retain()'s own default is sync=False (background daemon
thread), so the storage POST was dispatched off-thread and the function
returned immediately. The 'Stored conversation to bank' INFO log was
emitted before the HTTP request had actually been sent.

In long-lived processes (Jupyter notebooks, the cookbook flow) this was
invisible because the daemon thread had time to complete. In short-lived
processes — a writer CLI that exits after a single completion() call —
the daemon thread was killed at process exit and the POST never landed
on the server. A second process recalling against the same bank a few
seconds later observed zero memories, even with sync_storage=True.

Cross-process drop-in is the most basic real-app pattern users try after
the cookbook, so this silent data loss had to be fixed before merge.

Reproduction (pre-fix):
  Process A: configure(sync_storage=True) + litellm.completion(...)
             → logs "Stored conversation to bank: BANK"
             → process exits
  Wait 10s.
  Process B: Hindsight(...).list_memories(BANK)
             → 0 memories

Post-fix: Process B sees the extracted memories as expected.

Adds two regression tests that mock retain() and assert sync=True is
forwarded in both the non-streamed and streamed sync_storage branches.
Both fail on the prior code; both pass now.

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

* chore(litellm): remove dead _debug_lock

_debug_lock at __init__.py:165 was never used — there's no `with _debug_lock:`
anywhere in the codebase and every _last_injection_debug write is unguarded.
Reviewer (benfrank241) flagged this on PR #1711. Drop the unused variable.

threading is still imported (used by _enabled_lock at line 158,
_storage_error_lock at line 1035, and two threading.Thread spawns at 1190 +
1263), so the import stays.

105/105 tests in test_integration.py pass.

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

* chore(litellm): sync openapi.json with main

Branch carried an older snapshot of hindsight-docs/static/openapi.json
that pre-dated main's addition of OperationProgress. Re-checkout main's
openapi.json so check-openapi-compatibility passes.

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

---------

Co-authored-by: DK09876 <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-06-05 16:27:58 -04:00
a933a417cd feat(claude-agent-sdk): add Claude Agent SDK integration (#1582)
* feat(claude-agent-sdk): add Claude Agent SDK integration with memory tools and hooks

Adds hindsight-claude-agent-sdk package providing:
- In-process MCP server with retain, recall, and reflect tools
- Automatic memory hooks (auto-recall on prompt, auto-retain on stop)
- Tool output retention via PostToolUse hooks
- Global configuration and per-call overrides
- 74 unit tests, CI job, and release script entry
- Cookbook recipe for docs site

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

* fix(claude-agent-sdk): default to Cloud without configure(); add gated E2E + bucketing

- resolve_client() now falls back to DEFAULT_HINDSIGHT_API_URL and the
  HINDSIGHT_API_KEY env var when configure() was never called (it previously
  raised). Updated the tools + hooks unit tests to assert the cloud-default +
  env-key behavior. Satisfies the "default to Cloud" goal for both
  create_hindsight_tools and create_memory_hooks.
- Add a gated tests/test_e2e.py (retain/recall/reflect MCP tools against a live
  Hindsight server, stdlib urllib health check — no requests dep), marked
  requires_real_llm; register the marker; the test-claude-agent-sdk-integration
  CI job now runs the deterministic bucket (-m "not requires_real_llm").

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

* test(claude-agent-sdk): assert create_memory_hooks reads HINDSIGHT_API_KEY from env

Mirrors the tools env-key test so hook construction's cloud-default + env-key
path is covered, not just the no-key default.

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: DK09876 <[email protected]>
2026-06-05 16:23:15 -04:00
Ben 05602730e8 release(langgraph): v0.2.0 2026-06-05 16:17:56 -04:00
b67e813a83 LangGraph: add memory_instructions, fix nodes, remove BaseStore (#1673)
* docs: add langgraph.py example snippets for integration docs

Adds embeddable code snippets covering all three LangGraph integration
patterns: tools (ReAct agent), memory nodes, BaseStore, and constructor
options. Follows the same [docs:section] pattern as ai-sdk.ts.

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

* LangGraph integration: add memory_instructions, fix nodes, remove BaseStore

- Add memory_instructions() for standalone LangChain use without a graph
- Add recall_types, recall_include_entities to create_recall_node()
- Add metadata, document_id to create_retain_node()
- Nodes now raise HindsightError instead of silently swallowing errors
- Remove HindsightStore (BaseStore adapter) — leaky KV abstraction over
  semantic memory (get unreliable, delete no-op, list session-scoped)
- Update README: cloud-first examples, add memory_instructions section
- Update docs example: replace base-store with memory-instructions snippet
- Fix pre-existing test failures (user_agent mock mismatch)
- 52 unit tests pass, 13 E2E tests pass

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

* style(langgraph): run ruff format on tools.py

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

* docs(langgraph): keep cloud product unnamed in module docstring

The docstring example said "Uses Hindsight Cloud by default" — names the
cloud product in OSS source.  Per the integration review's OSS-clean rule,
the cloud should be reachable by overriding hindsight_api_url but not
explicitly named in core code.  Rephrased to "Uses the default API URL"
and "Or point at a different instance".

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

* chore(langgraph): address PR review polish items

- __version__ now derived from package metadata (was stale 0.1.0 vs pyproject 0.1.2)
- pyproject description no longer references the removed store adapter
- create_hindsight_tools return type tightened from `list` to `list[BaseTool]`
- memory_instructions docstring now documents the deliberate silent-fallback
  on Hindsight error (vs nodes which raise) — load-bearing API contract
- create_retain_node docstring now notes ToolMessage / FunctionMessage
  content is intentionally skipped

No behaviour change; 52/52 unit tests still pass.

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

* fix(langgraph): default to Cloud without configure() + add gated E2E suite

resolve_client() now falls back to DEFAULT_HINDSIGHT_API_URL and the
HINDSIGHT_API_KEY env var when configure() was never called, matching the
Superagent pattern and satisfying the "default to Cloud" goal. Previously
create_hindsight_tools(bank_id=...) raised without an explicit URL/config.

Also add an in-tree, pytest-gated tests/test_e2e.py covering the tools,
graph-node, and memory_instructions patterns (skips when no live Hindsight),
update unit tests to assert the Cloud-default behavior, and close the
Hindsight clients in the manual smoke scripts to avoid unclosed-session
warnings.

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

* fix(langgraph): drop "Hindsight Cloud" product name from tools docstring

Keeps the OSS source product-agnostic — cloud naming belongs in the
cookbook/blog, not the package. Behavior unchanged.

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

* test(langgraph): bucket E2E as requires_real_llm

Mark the live E2E suite (drives a live Hindsight server) with a module-level
requires_real_llm marker, registered in pyproject, mirroring the core test
split from #1469. Deterministic bucket (-m "not requires_real_llm") = 53 unit
tests; real-LLM bucket (-m requires_real_llm) = 6 E2E.

Note: there is no test-langgraph-integration CI job yet, so this marker is not
wired into CI; adding that job is tracked as a follow-up in the review log.

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

* test(langgraph): add deterministic compiled-graph flow test (mock bucket)

Wires a real compiled StateGraph (recall -> agent -> retain) backed by a mocked
Hindsight client, asserting the recall node injects memory and the retain node
stores the human turn — the in-CI / no-keys analog of the live graph test.

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

* ci(langgraph): add test-langgraph-integration job + 3 supporting wiring places

Audit finding (2026-06-02): hindsight-langgraph has zero CI presence in
.github/workflows/test.yml — no detect-changes output, no path filter, no
job definition, no aggregate-gate entry. The prior review-log called this
PR MERGE-READY based on "green at time of audit"; the audit caught that
green was consistent with "no job exists to fail" — changes to the package
silently bypassed CI.

This commit adds the missing wiring, mirroring the AutoGen #1868 pattern
that added the same scaffold for that package's integration job:

  1. L41   detect-changes output: integrations-langgraph
  2. L126  path filter:           hindsight-integrations/langgraph/**
  3. L2914 job def:               test-langgraph-integration
           - timeout-minutes: 30 (matches autogen/openai-agents)
           - runs uv build + uv sync --frozen + pytest with the
             `-m "not requires_real_llm"` exclusion so the deterministic
             bucket runs in PR CI while the live bucket is reserved for
             the dedicated/nightly job (the standing convention from
             PR #1469).
  4. L3911 aggregate gate entry:  test-langgraph-integration

Verification:
- YAML parses (python -c 'yaml.safe_load(...)').
- Deterministic bucket unchanged: 55 pass / 6 deselected.

The PR's existing integration code is unchanged — this is purely test-yml
scaffolding.

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: DK09876 <[email protected]>
2026-06-05 16:16:48 -04:00
Ben 2e011d279c blog: How Hindsight Learns — A Deep Dive Into Mental Models (#2021)
* blog: Mental Models in Hindsight — A Code-Level Deep Dive

Definitive technical reference for the mental-models feature. Every
claim is grounded in the docs or the implementation, with file paths
and line numbers cited inline.
2026-06-05 15:18:30 -04:00
Nicolò Boschi c94935bfa2 feat(operations): durable progress snapshot for consolidation and batch retain (#2013)
* feat(operations): durable progress snapshot for consolidation and batch retain

Long-running consolidation could look identical whether healthy or stuck:
updated_at was only touched on claim/complete, with no mid-run progress, so
operators couldn't tell a slow job from a frozen one without DB access (#1840).

Add a best-effort heartbeat that writes a coarse {stage, processed, total,
detail} snapshot into async_operations.result_metadata (top-level jsonb merge so
sibling keys survive) and bumps updated_at, at phase/batch boundaries:
- consolidation: scanning -> processing_batch (per round, with observation
  counters) -> refreshing_mental_models
- batch retain: processing_sub_batch per sub-batch (split loop + small-batch path)
Each call mirrors the same stage into the existing set_stage() so live worker
logs and the durable row tell one story.

Surface it as a typed `progress` field on the operation list/status API
(OperationProgress model); null when no snapshot was recorded. Regenerate
OpenAPI spec + Python/TS/Rust/Go clients.

Scope is visibility only: no staleness classification or auto-kill.

Tests: helper merge-without-clobber + updated_at bump, API surfacing on
get/list, null-when-absent, and real-run wiring for consolidation (processed
advances to total) and batch retain.

* feat(control-plane): show operation progress snapshot in operations view

Surface the new `progress` field (stage + processed/total + per-phase counters)
that the dataplane writes for running consolidation/batch-retain operations.

- Type `progress` through api.ts (listOperations + getOperationStatus) via a
  shared OperationProgress interface.
- bank-operations-view: render a compact stage + processed/total bar under the
  status badge on processing rows, and a full progress block (with detail
  counters) in the operation details dialog. Refreshes via the existing poll.
- Add the `field.progress` label to all locale message files.

UI half of #1840; pairs with the dataplane progress snapshot.

* fix(operations): make retain progress reach total on completion; hide on terminal ops

A finished single-sub-batch retain was frozen at a pre-run "processing_sub_batch
0/1" snapshot: it was written *before* the sub-batch ran and never updated, so a
completed operation looked stuck. The control-plane details dialog also rendered
that leftover heartbeat regardless of status, so a completed op showed an
in-progress bar.

- Write the retain progress snapshot *after* each sub-batch commits (processed=i
  for the split loop, 1/1 for the small-batch path), so the last snapshot reaches
  total/total and reflects completion instead of a stale pre-run count.
- Control plane: only render the progress section while status is "processing";
  for terminal operations the status badge + completed_at are the source of truth.

Update the retain progress test to assert the snapshot reaches total/total and
the durable row reflects completion.

* fix(operations): per-LLM-batch consolidation progress + live heartbeat in UI

Consolidation progress was written only at the outer DB-fetch round boundary, but
a whole batch of memories is processed inside a single round's LLM dispatch — so
the snapshot sat at "scanning 0/N" for the entire (often minutes-long) LLM phase
and only jumped at the very end, looking stuck even while healthy.

- Write the snapshot per LLM batch using the cumulative processed count that the
  per-batch log already tracks, with cumulative observation counters in detail.
  processed now climbs 8/42, 16/42, … as batches commit. Drop the now-redundant
  round-boundary write.
- Control plane: show a live "last heartbeat · Ns ago" line under the progress
  bar, ticking every second (only while an operation is processing) so a frozen
  heartbeat on an active job is visible at a glance. Add heartbeat/lastHeartbeat
  labels to all locales.

* fix(operations): clearer consolidation stage, compact progress row, faster poll

Address operator-feedback on the progress UI:
- Collapse consolidation's "scanning" + "processing_batch" into one self-explanatory
  "consolidating" stage that advances 0/N -> N/N, instead of an opaque scan->process
  hop nobody could interpret.
- Control plane: render the in-row progress as a single compact line (bar + count +
  heartbeat age) so the status column no longer stacks three rows; the full breakdown
  (stage, counters, labelled heartbeat) stays in the details dialog.
- Poll the operations list every 2s while something is processing (was a flat 5s) so
  the bar and heartbeat feel live, backing off to 5s when everything is terminal.

* feat(operations): chunk-level retain progress; cap consolidation total; drop detail badges

- Retain now reports "storing N/total chunks" from the streaming pipeline as each
  consumer batch commits (threaded via a progress_callback so the engine stays
  decoupled and operation_id/total_chunks are already in scope). Replaces the coarse
  per-sub-batch tick — a long document now shows chunks committing live.
- Consolidation: treat total as an estimate that grows with processed
  (max(total_count, processed)) so the bar never reads >100% (e.g. 58/51) when memories
  are retained mid-run.
- Control plane: drop the per-counter detail badges from the progress dialog (noisy);
  the bar + stage + heartbeat carry the signal.

* feat(control-plane): inline progress + heartbeat on the status badge row

Put the compact progress (bar + count + heartbeat age) on the same line as the
status badge instead of stacking a second row under it, so a processing row reads
"⟳ processing  ▓▓░ 8/42 · 5s" on one line.

* feat(operations): Updated column, fixed-width status, snappy completion flash

Operator-feedback polish on the operations table:
- Add an "Updated" column (relative time, absolute in tooltip). Required surfacing
  updated_at on the operations *list* endpoint (it was only on the detail endpoint);
  regenerated OpenAPI + clients.
- Give the status column a fixed width so the row no longer shifts left when the
  inline progress appears/disappears as an operation starts or finishes.
- Flash a row briefly (emerald on completed, red on failed/cancelled) when it
  transitions to a terminal state, with a 700ms color transition, so a completion
  landing on a poll reads as a deliberate change instead of a silent badge swap.
- Refresh the relative-time clock on every poll so the Updated column stays accurate
  while idle (not just while the per-second heartbeat ticker runs).

* feat(control-plane): label and fix the Actions column width

The Actions column had no header and no fixed width, so it grew when a pending/failed
row's Cancel/Retry button appeared — shifting the whole table. Give it an "Actions"
label (added to all locales) and a fixed 110px width on header and cell so the layout
stays put regardless of which rows show an action button.

* feat(operations): update consolidation total by re-counting instead of clamping

Replace the max(total, processed) clamp (which pinned the bar at 100% once processed
caught the start-of-job estimate) with a real re-count: once processed passes the
initial estimate, report total = processed + still-pending. Guarded so the extra
COUNT only runs after the estimate is exhausted (≈the final batch normally, or
repeatedly only if memories keep arriving mid-run) — no per-batch query in the common
case.

Also explain it in the UI: the consolidation progress section notes that the total is
an estimate from job start and can grow if new memories arrive while it runs.

* fix(control-plane): label file_convert_retain as "Convert File"
2026-06-05 18:11:07 +02:00
Nicolò Boschi e30f8af148 fix(llm): downgrade tool_choice="required" for servers that silently drop it (#2016)
vLLM (--enable-auto-tool-choice), LM Studio and Ollama advertise
tool_choice="required" but silently ignore it: instead of forcing a tool
call they return finish_reason "stop"/"tool_calls" with an EMPTY tool_calls
array and no HTTP error. Reflect's agent loop forces its retrieval tools via
named tool_choice dicts (normalized to "required" + a single filtered tool),
so on these endpoints the agent calls zero tools, synthesis runs with no
retrieval, and reflect answers "I don't have information" even when the bank
holds the answer.

Downgrade "required" to auto (None/omitted) for these self-hosted endpoints
so the model still gets to call a tool. Named dicts already narrow the tools
list to one entry, so forced calls stay practically forced under auto. The
real OpenAI API (no base_url override), llama-server (which honors
"required", per #1179) and cloud providers are left untouched.

Fixes #1877. Same bug class as #1563 (LM Studio) and #1179 (LM Studio +
Qwen), both of which this also resolves.
2026-06-05 17:32:04 +02:00
Nicolò Boschi 4f50034800 fix(init): fail fast when model init blocks instead of hanging forever (#2014)
Model/connection initialization had no wall-clock cap: if embeddings, the
cross-encoder, or LLM verification blocked (e.g. an offline HuggingFace
download or an unreachable provider), `asyncio.gather` in
`MemoryEngine.initialize()` never returned and the daemon hung in a third
state — neither started nor errored. The lazy reranker path
(`CrossEncoderReranker.ensure_initialized()`) had the same problem on the
first request.

Wrap both with `asyncio.wait_for` capped by a new static config
`HINDSIGHT_API_MODEL_INIT_TIMEOUT` (default 300s, generous enough for
first-time model downloads). On timeout, raise a clear RuntimeError that
names the likely cause and points at the env var — no silent fallback.

Fixes #1897
2026-06-05 16:18:02 +02:00
Nicolò Boschi 3b2830c7d8 docs(models): note Groq free tier (8k TPM) is unsuitable for Hindsight (#2015)
Retain reserves max_completion_tokens (~64k) up front, and Groq's free-tier
8k TPM limit counts that reservation at admission, so every retain call is
rejected with HTTP 413 'Request too large' even for a one-line message.
Document that the free tier is unsuitable and a paid tier / other provider
is required. Refs #1573.
2026-06-05 15:54:24 +02:00
Nicolò Boschi c255d35525 fix(reflect): let a fresh mental model short-circuit forced retrieval (no extra LLM call) (#2011)
* fix(reflect): let a fresh mental model short-circuit forced retrieval

Reflect forced the full hierarchical path
search_mental_models -> search_observations -> recall via a named
tool_choice on the first iterations. Because a named tool_choice forbids
the model from emitting `done`, the agent could never answer off a fresh,
directly-relevant mental model — it always paid for the lower layers too
(issue #1971).

Fix: after the forced search_mental_models result, decide deterministically
(no extra LLM call) whether to keep forcing. If the call is low/mid budget
and every retrieved mental model is explicitly fresh (is_stale is False)
with non-empty content, stop forcing from the next iteration on. That
iteration — which happens regardless — now runs under `auto`, so the agent
either answers directly or, having just read the mental model, issues its
own targeted search_observations/recall. Stale, empty, or missing mental
models keep the full forced path; high budget always keeps it.

This reuses the agentic step that already occurs instead of adding a
separate sufficiency-classifier LLM call, so the sufficient path saves two
forced rounds and no path ever adds a round.

* test(reflect): add real-LLM e2e coverage for mental-model short-circuit

Two hs_llm_core end-to-end tests drive the real agent loop (stubbed
search functions, real llm_config) to verify behaviour the deterministic
MockLLM tests cannot:

- fresh + sufficient mental model: the released agent answers off it and
  never calls search_observations/recall (judge-verified grounding);
- stale mental model: no short-circuit, lower layers stay forced, and the
  agent corrects the stale summary using the freshly retrieved raw fact.

The stale case (forcing is deterministic) is used rather than a
"fresh-but-incomplete model retrieves deeper on its own" case, because
whether a released model chooses to dig deeper is model-dependent and not
something the fix guarantees — only release-to-auto is guaranteed.
2026-06-05 15:20:39 +02:00
Nicolò Boschi 7e1145c08a feat(history): move mental-model & observation history into dedicated tables (#2007)
Both histories accumulated in a single JSONB/CLOB `history` column, appended
to on every update. Observations had NO cap at all, so a frequently-reinforced
observation grew until it crossed Postgres's 256MB jsonb limit (SQLSTATE 54000)
and the row got stuck. Mental models capped by entry COUNT (not size) and
rewrote the whole array + TOAST per refresh, defeating HOT updates.

Now one row per change in mental_model_history / observation_history, indexed
on (item, changed_at DESC, id DESC). Each row stores its snapshot as a single
JSONB `content` blob (per-row, so it stays small) plus changed_at; the cap is
enforced at write time as a bounded DELETE of the oldest over-cap rows, for
both histories (new per-observation cap:
HINDSIGHT_API_OBSERVATION_HISTORY_MAX_ENTRIES, default 50).

- migration a7b8c9d0e1f2: create tables, backfill from the JSONB/CLOB arrays
  (PG jsonb_array_elements / Oracle JSON_TABLE), drop the legacy columns
- write paths: insert-then-trim in consolidator (observations) and
  memory_engine (mental models); also stop writing the dropped column in the
  create-observation INSERT
- read paths: get_observation_history / get_mental_model_history read the new
  tables; observation list/get no longer select the column
- export/import: mental_model_history carried (parent keeps a stable id, the
  surrogate id is dropped so the target reassigns it); observation_history is
  derived (observations regenerate with fresh ids on import) and not carried
- tests: deterministic observation-history coverage + MM-history export/import
  round-trip
2026-06-05 15:07:30 +02:00
Nicolò Boschi 75a7c19d6a fix(docker): clear diagnostic for pg0 bind-mount permission failure (#1483) (#2010)
* fix(docker): clear diagnostic for pg0 bind-mount permission failure (#1483)

The standalone image runs rootless (UID 1000). A host bind mount whose
directory isn't owned by UID 1000 — the default on macOS Docker Desktop and
most non-1000 Linux hosts — makes embedded pg0 fail with the opaque
"Permission denied (os error 13)". Auto-chowning the volume would require
running as root, which we deliberately avoid.

Instead:
- Recommend a Docker named volume in the README/installation docs; named
  volumes are seeded with the image's UID-1000 ownership, so they work with
  zero setup and stay rootless.
- Add a pg0 writability pre-check in start-all.sh that prints an actionable
  message (named volume, or --user) and exits cleanly instead of letting pg0
  emit os-error-13. Skipped when an external database is configured.
- Add regression tests for the new check in test-start-all.sh.

* docs(readme): drop bind-mount explanation, keep named-volume fix
2026-06-05 14:39:09 +02:00
Nicolò Boschi 82800ba864 fix(ci): repair zeroentropy embedding tests and regenerate drifted clients (#2009)
* fix(openapi): keep binary upload fields as format:binary; regen spec+clients

The #1982 dep bump (FastAPI 0.136 / Pydantic 2.12) serializes binary upload
fields as OpenAPI-3.1 {"type":"string","contentMediaType":"application/
octet-stream"}. openapi-generator v7.10.0 (generate-clients.sh) does NOT
treat contentMediaType as a file upload, so it regenerated the Files `files`
and document-transfer `file` params as plain strings — silently breaking
multipart upload in the Go/Python/TypeScript clients ([]*os.File -> []string,
StrictBytes -> StrictStr, Blob|File -> string).

generate_openapi.py now post-processes the exported schema to restore the
prior `format: binary` representation (still valid under openapi 3.1.0, and
what the generator understands) for application/octet-stream string fields,
scoped to binary uploads only. Regenerated the spec and clients: the upload
signatures are back to the file-upload form (identical to main); the only
remaining delta vs main is ValidationError dropping its `url` field, a real
Pydantic 2.12 change (error metadata, harmless).

* test(embeddings): give zeroentropy routing mocks a dimension attribute

PR #1670 added post-encode dimension validation to generate_embeddings_batch
— it now reads embeddings_backend.dimension, which the EmbeddingsBackend
Protocol already requires. The pre-existing QueryAwareEmbeddings/
DocumentAwareEmbeddings routing mocks (#1770) omit it, so the two routing
tests started failing with AttributeError on main.

The mocks return single-element vectors, so declare dimension = 1 to satisfy
the Protocol and let validation pass. Pure test fix; no behavior change.

* test(openapi): lock _restore_binary_format binary-upload rewrite

Regression guard for the file-upload break: asserts octet-stream string
fields are rewritten to format:binary (incl. nested/array-item schemas) and
that other content media types are left untouched.
2026-06-05 14:38:44 +02:00
Nicolò Boschi 2860c9ae16 refactor(api): unify lazy bank-create into _ensure_bank_exists, couple to caller txn (#2004)
All bank-scoped write paths lazily create the bank (the FK target) before
their first insert. That logic was duplicated across create_mental_model,
create_webhook, submit_async_retain, and the import paths as a bare
get_or_create_bank_profile + best-effort default-template apply, and it ran
on its own connection — so a freshly-created bank could outlive a write that
ultimately failed.

Introduce a single MemoryEngine._ensure_bank_exists() entry point:
  * Pass conn (with an open transaction) to run the bank INSERT + per-bank
    vector index creation on the caller's connection, so the bank row commits
    or rolls back atomically with the caller's write. Used by
    create_mental_model, create_webhook, and submit_async_retain (whose
    parent+child inserts already share one transaction).
  * Omit conn for paths with no single write transaction to join (retain and
    import write later across many per-document transactions); the bank is
    created on a dedicated connection as before.

The HINDSIGHT_API_DEFAULT_BANK_TEMPLATE hook is best-effort, opens its own
connections, and can itself create pinned models, so it is never run inside
the caller's transaction — it stays a post-commit step, applied only when the
bank was freshly created. Add get_or_create_bank_profile_on_conn() in
bank_utils as the connection-bound variant.

Both get_or_create_bank_profile and its _on_conn variant now return a typed
BankProfileResult dataclass instead of a (profile, created) tuple.

Tests: add txn-rollback atomicity coverage for create_mental_model (a failing
insert rolls the new bank back) and submit_async_retain (new bank rolls back
with the operation rows), plus missing-bank coverage for webhooks and batch
retain. Update test_async_retain_tags to stub _ensure_bank_exists (the method
submit_async_retain now calls).
2026-06-05 12:47:23 +02:00
Nicolò Boschi 049901802f fix(api): add vchord catalogs to search_path for external Postgres (#1351) (#2008)
VectorChord BM25 registers its objects in dedicated schemas
(vchord_bm25 -> bm25_catalog, pg_tokenizer -> tokenizer_catalog). The
BM25 distance operator <&> resolves its operand types via the session
search_path, so a connection that lacks these schemas fails recall with
'type "bm25vector" does not exist' and retain with
'function tokenize(...) does not exist'.

The official vchord-suite Docker image masks this by shipping the
catalogs in search_path; an external Postgres does not. Set the same
search_path on each connection when the vchord text-search backend is
configured. Qualifying the SQL is insufficient: the <&> operator's type
resolution cannot be schema-qualified and still requires bm25_catalog on
the path. Tenant tables are always accessed via fq_table(), so this does
not affect schema isolation.
2026-06-05 12:46:54 +02:00
Nicolò Boschi a3d3d42b39 feat(llm): honor HINDSIGHT_API_LLM_STRICT_SCHEMA on all json_schema-capable providers (#2003)
Structured-output calls (retain fact extraction, consolidation observation
merge) use a soft "schema-in-prompt + json_object" path by default: the schema
is appended to the prompt and the model must voluntarily emit valid JSON. Strong
hosted models comply, but weaker self-hosted instruction-followers (small
Qwen/Llama/Mistral GGUF via llama.cpp/vLLM) return prose preambles, markdown
fenced blocks, or invalid JSON that fails to parse — retain/consolidation then
retry forever and wedge.

#1986 added a HINDSIGHT_API_LLM_STRICT_SCHEMA flag but wired it into only the
OpenAI-compatible provider, leaving LiteLLM and the batch retain path ignoring
it. Resolve the flag once in LLMProvider.call (OR-ed with the per-call
strict_schema arg) and pass it down instead, so every json_schema-capable
provider honours it through its existing strict_schema handling:

- OpenAI-compatible (+ llama.cpp delegate, Fireworks subclass) and LiteLLM:
  json_schema strict instead of soft json_object.
- Gemini already grammar-enforces its native response_schema (no-op).
- Batch retain path builds its request body directly (bypasses .call()), so it
  reads the flag itself and sets json_schema strict.

Providers without a strict mode (Anthropic, Claude Code, Codex) ignore the flag
and keep the soft path — unchanged.

Default false, so no behavior change for existing deployments. Corrects the
stale "OpenAI only" docstrings, documents the env var in configuration.md, and
adds tests/test_llm_strict_schema.py (config parsing, wrapper resolution,
openai/litellm/batch mappings).
2026-06-05 12:30:48 +02:00
Nicolò Boschi 01296d8d52 feat(llm): apply HINDSIGHT_API_LLM_EXTRA_BODY across all API providers (#2006)
extra_body was only threaded into the OpenAI-compatible (and Fireworks)
providers. Extend it to Anthropic, Gemini/VertexAI and LiteLLM (incl. the
Bedrock alias and the LiteLLM Router) so the same env-configured knob
(temperature, top_p, max_tokens, ...) tunes every provider with no code
changes — closing the gap reported in #1227.

Each provider merges the params in its own native space:
- Anthropic: Anthropic SDK extra_body kwarg (call + call_with_tools)
- Gemini/VertexAI: seeded into GenerateContentConfig (explicit per-call
  values win); Gemini nests generation params in the body
- LiteLLM/Bedrock/Router: top-level acompletion kwargs via setdefault so
  LiteLLM normalizes/drops them per-provider

Stays server-level (env) only — not per-bank configurable.

The docs-skill regen also syncs a small pre-existing drift (Fireworks AI
in the provider/integration lists).

Refs #1227
2026-06-05 12:30:26 +02:00
Nicolò Boschi e77931fa22 docs(performance): expand local-LLM concurrency guidance into a Local & Small Environments tuning section (#2002)
* docs(performance): add Tuning for Local & Small Environments section

Supersedes #1721. Keeps the local-LLM concurrency guidance from that PR
(HINDSIGHT_API_LLM_MAX_CONCURRENT, saturation symptom + diagnostics) and
expands it into a dedicated section covering the other knobs that matter
on laptops, single-GPU boxes, and local LLM servers:

- per-operation concurrency caps to reserve reflect headroom
- timeouts/retries for slow local generation
- smaller per-operation models + low reasoning effort + LLM=none
- built-in llama.cpp tuning (gpu layers, context size, threads, grammar)
- CPU reranker knobs (fp16, bucket batching, max concurrent, flashrank)
- CPU embeddings (force_cpu)

* docs(performance): drop saturation symptom + diagnostics block

* docs(performance): drop LLM_PROVIDER=none chunk-mode note

* docs(performance): add reranker candidate-set + consolidation batch-size levers; drop CPU embeddings note
2026-06-05 11:55:50 +02:00
23710f4a8f fix(oracle): make recall and mental-model history work on the Oracle backend (#1980)
* fix(oracle): make recall and mental-model history work on the Oracle backend

Two code paths emitted PostgreSQL-specific SQL that has no Oracle equivalent
and is not handled by the PG→Oracle query rewriter, so they raised hard
errors on the Oracle 23ai backend:

1. Recall — `retrieve_temporal_combined` expands a batch of seed ids for
   multi-hop temporal-link spreading with `FROM unnest($2::uuid[]) AS
   src(from_unit_id)`. Oracle has no `unnest`, so recall raised
   `ORA-03048` whenever the matched memories had temporal/causal links
   (the common case). Fix: guard the spreading loop on the connection's
   `backend_type`; on backends without `unnest` we skip only the multi-hop
   spread. The temporal entry points are still returned, and the
   semantic / keyword / graph retrievers are unaffected.

2. Mental-model history — `update_mental_model` trims the history array in
   SQL with `jsonb_agg(... ORDER BY ...)` over
   `jsonb_array_elements(...) WITH ORDINALITY`, which raised `ORA-00907`
   and made mental-model creation fail (the create path triggers a refresh
   that updates content). Fix: on Oracle, compute the trimmed history in
   Python (we already fetch the current array) and bind it as a single JSON
   value. The PostgreSQL SQL path is unchanged.

Both are instances of the dialect-asymmetry trap called out in CLAUDE.md.

Test plan:
- Oracle 23ai e2e smoke + HTTP integration: mental-model create/CRUD and
  full-lifecycle (previously failing with ORA-00907) now pass.
- Full Oracle integration suite shows zero ORA-03048 occurrences.
- PostgreSQL mental-model history unit tests (including max-entries
  trimming) still pass — the PG path is byte-identical.

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

* fix(oracle): return CLOB columns from RETURNING without a 4000-byte cap

The Oracle backend's RETURNING handler bound every non-numeric, non-timestamp
output column as DB_TYPE_VARCHAR. VARCHAR out-binds cap at 4000 bytes, so any
CLOB-backed column returned via a RETURNING clause raised
`ORA-22835: buffer too small for CLOB to CHAR conversion` once its value
exceeded 4000 bytes. This surfaced as mental-model creation failing on Oracle:
the post-create refresh UPDATEs `content` (a CLOB) with `RETURNING content`,
and a sufficiently long synthesized snapshot (>4000 bytes) aborted the update.

Fix: bind known CLOB columns (the JSON-as-CLOB set plus the large-text columns
content/text/context/structured_content/text_signals/search_vector) as
DB_TYPE_CLOB in the RETURNING var setup, and read the LOB handle back to a
string in _read_returning_values (the async pool yields AsyncLOB, whose read()
is awaited). Non-CLOB columns are unchanged.

Verified against Oracle 23ai:
- A 4277-byte CLOB now round-trips through UPDATE ... RETURNING (previously
  ORA-22835); other columns (RAW(16) ids, etc.) still convert correctly.
- Mental-model create/refresh with large content succeeds.
- RETURNING-heavy Oracle integration tests (retain, tags, document/memory CRUD,
  http retain/recall, full lifecycle) pass.

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

* fix(oracle): make the temporal entry-point query Oracle-compatible (no unnest)

The temporal-recall entry-point selection was rewritten on main (#1983) to gate
candidates by embedding similarity within the window. That new query expanded the
fact_types with `FROM unnest($3::text[]) AS ft CROSS JOIN LATERAL (...)`, which has
no Oracle equivalent — so after merging main, Oracle recall would again fail with
ORA-03048 on any temporal query, in the entry-point query this time (the spreading
guard added here only covers the multi-hop spread).

Rebuild the entry-point query as a UNION ALL of one similarity-ranked,
window-filtered arm per fact_type with the fact_type inlined as a literal — the
same shape retrieve_semantic_bm25_combined already uses and which the Oracle
backend runs. The `<=>` operator and `LIMIT` are translated to VECTOR_DISTANCE and
FETCH FIRST on execute; only `unnest` was untranslatable, and it's now gone.

Behavior on PostgreSQL is unchanged (each arm still hits the per-(bank, fact_type)
vector index; selection + coverage logic is identical) — verified by the existing
temporal selection tests and the recall_perf temporal benchmark (temporal arm
~0.002s on the 680k dense bank). Oracle output verified through the real
_rewrite_pg_to_oracle translator: no unnest, valid VECTOR_DISTANCE + FETCH FIRST.

---------

Co-authored-by: DK09876 <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-05 11:29:05 +02:00
cinos b5a324b77b feat(embeddings): add ONNX local provider (#1970)
* feat(embeddings): add ONNX local provider

* fix(embeddings): download ONNX external data sidecars

* fix(embeddings): address ONNX provider review feedback
2026-06-05 11:24:34 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> adbad877d5 chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#1938)
Bumps the npm_and_yarn group with 1 update in the /hindsight-integrations/flowise directory: [uuid](https://github.com/uuidjs/uuid).


Removes `uuid`

Updates `langsmith` from 0.3.87 to 0.7.3
- [Release notes](https://github.com/langchain-ai/langsmith-sdk/releases)
- [Commits](https://github.com/langchain-ai/langsmith-sdk/commits/v0.7.3)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version:
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: langsmith
  dependency-version: 0.7.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 11:24:07 +02:00
Evo d3eff9fba2 docs(admin-cli): document full backup table coverage from #1903 (#1929)
#1903 expanded BACKUP_TABLES to all 15 tables (the 7 previously-missing ones that could be silently dropped on restore), but the "backup includes" list still reflected the old ~8-table coverage. Update it to match: mental models, directives, webhooks, file storage, plus internal operational tables for a faithful full-database snapshot. Oracle-only observation_sources stays excluded (PostgreSQL-only backup). Regenerated skills/hindsight-docs mirror.
2026-06-05 11:23:39 +02:00
Evo ddef3d8c6b docs(cli): replace removed opinion fact-type with observation in recall example (#1917) 2026-06-05 11:23:12 +02:00
Evo ab61330698 docs(models): register fireworks so the Models grid + default-models table list it (#1860) (#1911)
* docs(models): register fireworks in llmProviders.json (#1860)

#1860 added fireworks to PROVIDER_DEFAULT_MODELS (config.py:535) but not to the
providers registry that renders the Models page grid + default-models table. The
registry docstring mandates it stay aligned with PROVIDER_DEFAULT_MODELS.

* docs(models): regenerate skills mirror for fireworks provider

Mirror of the generated <LLMProvidersGrid/> + <LLMProvidersTable/> output.
2026-06-05 11:22:16 +02:00
Stefan Weber 505a013812 Added OutSystems community integration (#1873)
Added integration entry and vendor icon
2026-06-05 11:21:13 +02:00
Derek Bouius a3797e2014 docs: update Gemini model recommendations to 3.x series (#1787)
Replace deprecated Gemini models with their 3.x successors:
- gemini-3-pro-preview → gemini-3.1-pro-preview (shut down March 2026)
- gemini-2.5-flash → gemini-3.5-flash
- gemini-2.5-flash-lite → gemini-3.1-flash-lite

Also update default models in config.py for gemini and vertexai providers.
2026-06-05 11:20:36 +02:00
Derek Bouius 1615456384 chore: update gemini embedding model from preview to GA (#1780)
Replace gemini-embedding-2-preview with gemini-embedding-2 in LiteLLM
SDK embedding tests now that the GA model is available.
2026-06-05 11:20:12 +02:00
Manfred + TARS 06c88e0435 fix: validate embedding dimensions before pgvector writes (#1670)
* fix: validate retain embedding dimensions

* test: cover consolidation embedding dimension validation

* test: align consolidation embedding fake with document encoder

* style: format embedding validation error message
2026-06-05 11:17:38 +02:00
Nicolò Boschi 8aa31edd4c feat(consolidation): enable observation dedup by default (0.97), skip on Oracle (#2000)
The create+update semantic dedup added in #1977 shipped opt-in (threshold 1.0).
Enable it by default at 0.97 so observations are deduplicated out of the box.

The merge path uses Postgres-only SQL, so consolidation skips dedup entirely on
Oracle (via _dedup_active) — it behaves exactly as before there, regardless of
the configured threshold. This is what lets the default flip without breaking
Oracle deployments.

Also fix MockLLM to return a valid keep-decision for the consolidation_dedup
scope, so mock-LLM consolidation tests (which now exercise the enabled-by-default
path) don't crash on the structured response and never spuriously merge.
2026-06-05 11:10:45 +02:00
Nicolò Boschi 4c33a4e55b fix(recall): bound temporal entry-point scan to top-50-per-fact_type (alternative to #1958) (#1983)
* fix(recall): select temporal entry points by similarity with window coverage

retrieve_temporal_combined Phase 1 ranked the *entire* date-window match set by
COALESCE(occurred_start, mentioned_at, occurred_end) and kept the 50 most recent.
Two problems, one perf and one functional:

- Perf: on banks with dense/near-uniform date metadata (e.g. a retain pipeline
  that stamps a large batch with one date) any recall window intersects
  (near-)all rows, so Phase 1 degraded to a full sequential scan + disk-spilling
  sort. EXPLAIN on a 680k-row bank: Seq Scan 680k + Sort 680k to keep 50
  ("Rows Removed by Filter: 679,950"), ~672ms Phase 1 alone (30s+ in prod).
- Functional: ranking by recency biases results toward the END of the window,
  and when dates are degenerate the "50 most recent" is a near-random sample
  that can drop the single most relevant in-window memory before similarity is
  ever considered.

Switch the entry-point gate to embedding similarity within the window
(ORDER BY embedding <=> query, per fact_type, LIMIT pool), then narrow the pool
to N per fact_type with coverage-first round-robin across time-buckets so the
entry points span the window's range instead of clustering. Degenerate dates
collapse to plain similarity order.

The planner serves the similarity-ordered window query from the existing
per-(bank, fact_type) HNSW index when the window is broad (the dense case) and
from the existing partial date indexes + an exact sort when it is narrow — so no
new index is needed. (An earlier revision of this PR added a recency expression
index; Option A makes it unnecessary, so it's removed.)

Measured on a 680k-row dense-date bank (recall_perf): temporal arm
1.174s -> 0.009s; the arm is now both fast and returns the most relevant
in-window memories, spread across the window.

This is the alternative to #1958, which skipped the temporal arm entirely above a
planner row estimate (losing temporal recall on large banks).

- tests (no LLM): coverage round-robin + degenerate-date fallback (pure
  selector); similarity-over-recency selection and window filtering (DB-backed)
- recall_perf: `generate --event-date` (dense zone) + `benchmark
  --temporal-date` (forces the temporal arm) to reproduce and track this

* docs(retrieval): explain temporal selection (relevance-gated + window coverage)
2026-06-05 10:57:38 +02:00
Evo 72985b6153 docs(admin-cli): document decommission-worker --yes/-y confirmation-skip flag (#1957) 2026-06-05 10:53:32 +02:00
Evoandr266-tech 8872b9d9ef docs(configuration): document HINDSIGHT_API_WORKER_IMPORT_DOCUMENTS_MAX_SLOTS worker slot reservation (#1978)
Co-authored-by: r266-tech <[email protected]>
2026-06-05 10:51:58 +02:00
formatme 56ed38c8c5 fix(mental-models): create bank before insert (#1994) 2026-06-05 10:49:49 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e0704a445e chore(deps): bump the uv group across 18 directories with 2 updates (#1982)
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: direct:production
  dependency-group: uv
- dependency-name: starlette
  dependency-version: 1.0.1
  dependency-type: indirect
  dependency-group: uv
- dependency-name: starlette
  dependency-version: 1.0.1
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: starlette
  dependency-version: 1.0.1
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: direct:production
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: starlette
  dependency-version: 1.0.1
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: starlette
  dependency-version: 1.0.1
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 10:47:07 +02:00
Evo 40871e231e docs(api/bank-templates): fix entity_labels manifest example — label-group objects, not string[] (#1984)
The Manifest Schema example documented entity_labels as a bare string array
(`["PERSON", "ORGANIZATION"]`), but BankTemplateConfig.entity_labels is
`list[dict[str, Any]]` and each entry is parsed via LabelGroup (which requires
a `key`). A bare string fails import validation, so the documented example is
not usable. Replace it with a minimal valid label group and point the field
table at the authoritative shape already documented in memory-banks.mdx.
2026-06-05 10:46:10 +02:00
Evo 56b4271d9f docs(models): vertexai default is retired gemini-2.0-flash-001 -> sync to gemini-2.5-flash-lite (#2001)
The Provider Default Models table advertised vertexai's default as
gemini-2.0-flash-001, which #1972 confirms is retired on Vertex AI
(404 NOT_FOUND). The live config default is google/gemini-2.5-flash-lite
(config.py:562 PROVIDER_DEFAULT_MODELS); the google/ prefix is stripped
for display. Regenerated the skills-docs mirror via generate-docs-skill.sh.
2026-06-05 10:44:48 +02:00
Evo 1226fd96ad docs(configuration): document HINDSIGHT_API_LLM_PROMPT_CACHE_ENABLED in LLM Provider table (#1990)
#1936 added the on-by-default HINDSIGHT_API_LLM_PROMPT_CACHE_ENABLED env var
but documented it only in models.mdx prose. Add the missing row to the
canonical LLM Provider table so operators can discover the cached-input
billing toggle from the env-var reference. Regenerated the skills mirror.
2026-06-05 10:41:42 +02:00
Evo 087a729d57 docs(retrieval): correct equal-weight claim after RECALL_STRATEGY_BOOSTS (#1974) (#1991)
#1974 added HINDSIGHT_API_RECALL_STRATEGY_BOOSTS (named low/medium/high
per-source boosts), making retrieval.md's absolute claim 'There are no
per-strategy weight multipliers' factually wrong. Scope the equal-weight
statement to RRF fusion itself, point readers to the boost knob, and note
at the pre-filter cap stage that boosted sources are more likely to survive.
Regenerated the skills mirror.
2026-06-05 10:41:24 +02:00
FelixandClaude Opus 4.8 c5a61db2b8 fix(integrations): raise _check_health default timeout 2s→10s to stop busy-daemon kill loop (#1992)
Under load an alive-but-busy daemon (mid 30–60s LLM fact-extraction) can fail
to answer GET /health within the 2s default. That false negative makes
get_api_url() fall through to _ensure_daemon_running() →
`hindsight-embed daemon start`, whose _clear_port() then SIGTERMs the live
daemon — producing a daemon restart/kill loop under sustained traffic.

Raise the default to 10s, matching the recall hook's own budget (referenced in
get_api_url's docstring), so a busy daemon has time to respond before it is
declared dead. Callers passing an explicit timeout are unaffected. Applied to
both the claude-code and codex integrations, which share the helper verbatim.

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-05 10:40:52 +02:00
Evo 86ec97183b docs(configuration): document HINDSIGHT_API_BANK_STATS_CACHE_TTL_SECONDS + _MAX_ENTRIES (#1993) 2026-06-05 10:40:22 +02:00
Nicolò Boschi 221acc8f66 release(claude-code): v0.7.1 2026-06-05 10:39:43 +02:00
Nicolò Boschi f4a3329cea feat(api): enable LLM request tracing by default with 1-day retention (#1996)
Flip DEFAULT_LLM_TRACE_ENABLED to True and DEFAULT_LLM_TRACE_RETENTION_DAYS
to 1 so LLM request traces are captured out of the box and swept after a
day. The retention sweep already enforces >0 day windows; existing tracing
tests toggle the recorder explicitly and are unaffected.
2026-06-05 10:39:33 +02:00
Nicolò Boschi 655435ea49 fix(claude-code): default enableKnowledgeTools to true; keep MCP server alive when disabled (#1999)
On a fresh plugin install the MCP server is registered unconditionally in
.mcp.json but exited immediately when enableKnowledgeTools was false (the
shipped default), so Claude Code reported a -32000 reconnect error on every
prompt.

- Default enableKnowledgeTools to true (settings.json + config DEFAULTS).
- When disabled, run an empty MCP server instead of exiting, so the
  registered process stays alive and no reconnect error is surfaced.

Fixes #1995
2026-06-05 10:38:46 +02:00
Nicolò Boschi 0db70bb88a fix(clients): expose reflect tool_calls/llm_calls trace in python + typescript wrappers (#1997)
* fix(python-client): expose reflect tool_calls/llm_calls trace in wrapper

The maintained high-level wrapper only exposed include_facts on
reflect()/areflect(), so there was no way to request the reflect trace
(trace.tool_calls / trace.llm_calls) without dropping down to the
generated API. The wire API and generated models already support it.

Add include_tool_calls and include_tool_call_output params to both
reflect() and areflect(), mapping them to ReflectIncludeOptions.tool_calls.
Add unit tests pinning the wrapper -> ReflectRequest.include mapping.

* fix(ts-client): expose reflect tool_calls/llm_calls trace (+facts) in wrapper

The TS wrapper's reflect() never sent an 'include' object, so the reflect
trace (trace.tool_calls / trace.llm_calls) and based_on facts were
unreachable from the convenience layer. The wire API and generated types
already support both.

Add includeFacts, includeToolCalls, and includeToolCallOutput options to
reflect(), mapping them onto ReflectRequest.include. Add mock-based unit
tests pinning the option -> include mapping.
2026-06-05 10:38:36 +02:00
Nicolò Boschi 61f9bc8c77 feat(consolidation): semantic dedup of near-duplicate observations (create + update) (#1977)
Weak consolidation models (e.g. gemini-2.5-flash-lite) emit near-duplicate
observations even when the twin is in context, and an UPDATE that rewrites +
re-embeds an observation can drift it into a near-twin of a different existing
observation. When consolidation_dedup_threshold < 1.0, an observation that is
>= the threshold cosine to an existing one is reconciled by a focused 1-by-1 LLM
"merge or keep" call (anchored on the observation text, not the source fact, so
it is the correct obs<->obs comparison):

- CREATE path: on "merge", fold the new source facts + synthesized text into the
  existing twin and skip the insert.
- UPDATE path: after the rewrite+re-embed, probe the new vector (excluding the
  row itself); on "merge", fold the updated observation's sources into the twin
  and delete the now-redundant updated row.

Default 1.0 disables it (no behaviour change). Postgres only. On the English
hermes obs benchmark with flash-lite at 1/4 scale, residual >=0.97 near-dups
drop from ~7% to 0-1%.
2026-06-05 10:19:11 +02:00
Ben d826d648d8 release(llamaindex): v0.1.5 2026-06-04 15:02:10 -04:00
DK09876andDK09876 ed34756cdc fix(llamaindex): default to Cloud + replace dead manual test with gated E2E + requires_real_llm bucketing (#1867)
* fix(llamaindex): default to Cloud without configure(); replace dead manual test with gated E2E; bucket

- resolve_client() now falls back to DEFAULT_HINDSIGHT_API_URL and the
  HINDSIGHT_API_KEY env var when configure() was never called (was raising).
  Updated the raise-test to assert the cloud-default + env-key behavior.
- Replace the [email protected]'d tests/test_manual.py (dead code —
  the class-level skip made it never run anywhere) with a real, gated
  tests/test_e2e.py covering the create_hindsight_tools roundtrip
  (retain/recall/reflect via tool.call()) AND the HindsightMemory.aget/put
  roundtrip against a live Hindsight server.
- Marked requires_real_llm; register the marker in pyproject; add the missing
  asyncio_mode = "auto"; the test-llamaindex-integration CI job now runs the
  deterministic bucket (-m "not requires_real_llm").

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

* fix(llamaindex): give HindsightMemory.from_defaults a real cloud-default ctor

Audit finding (2026-06-02): HindsightMemory's create paths are asymmetric
with what create_hindsight_tools offers. The tools factory uses
resolve_client() so callers get the standard cloud-default + env-var
fallback for free; the memory adapter required either an explicit client
(from_client) or an explicit URL (from_url) and its from_defaults() raised
NotImplementedError. Callers wanting the same "no-config → Cloud" path
had to wire it themselves.

Fix: from_defaults(bank_id, ...) now calls resolve_client() exactly the
way the tools factory does. Falls back to DEFAULT_HINDSIGHT_API_URL when
no URL is supplied; reads HINDSIGHT_API_KEY from the environment if no
api_key is supplied; explicit `client=` still wins.

Tests pinning the new behaviour:
- from_defaults with nothing supplied → Hindsight constructed with
  DEFAULT_HINDSIGHT_API_URL.
- from_defaults with api_key → constructed with the configured key.
- from_defaults with explicit client → no new Hindsight constructed.

Replaces the previous test_from_defaults_raises (which pinned the
NotImplementedError that we're removing).

Verification:
- Deterministic bucket: 86 pass / 4 deselected (84 prior + 2 new
  cloud-default tests; one prior raises-test rewritten).

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

* fix(llamaindex): reword 'Hindsight Cloud' in HindsightMemory.from_defaults docstring

V2 audit (2026-06-02) caught one 'Hindsight Cloud' literal introduced by
the cloud-default ctor fix (commit 92926e2c) at memory.py:126. Reworded
to drop the product name parenthetical — DEFAULT_HINDSIGHT_API_URL is
self-explanatory.

Goal-4 (OSS-clean) compliance restored. Behaviour unchanged.

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

* fix(llamaindex): fall back to last user msg when aget() input is None

HindsightMemory.aget() only triggered automatic recall when called with
input=<query>. Workflow-based agents in current LlamaIndex
(llama_index.core.agent.workflow.ReActAgent, FunctionAgent, etc.) call
memory.aget() WITHOUT input= on their main path. Result: Pattern 1
(HindsightMemory as a drop-in BaseMemory) silently stopped surfacing
recalled memories — retain still fired, but the recalled facts were never
injected into the agent's context. Cross-session memory looked broken even
though the bank had the right content.

Reproduced in the canonical cookbook (notebooks/08-llamaindex-react-agent
cell 7 returned "No tengo acceso a información..." after cell 5 stored
Alice's facts) and in a real-app smoke test.

Fix: when aget()/get() is called without input, fall back to the most
recent USER ChatMessage in local history as the recall query. That message
is already populated by the workflow agent's aput(user_msg) call before
aget(). If there's no user message in history, skip recall — no
semantically meaningful query to look up.

Verified end-to-end:
  S1 (write): agent.run("I'm Alice, data engineer at Acme, write Python,
              use Neovim", memory=mem1)
  10s wait
  S2 (fresh memory + agent.run("What's my name and editor?", memory=mem2))
    → "Your name is Alice, and you use Neovim as your editor."

Regression tests:
- test_get_without_input_falls_back_to_last_user_message — asserts recall
  fires with the last user msg as query
- test_get_without_input_and_empty_history_skips_recall — boundary case
- test_get_without_input_and_no_user_msg_skips_recall — only assistant
  history, no recall

The existing test_get_without_input_returns_history asserted recall was
NOT called when input was None; that assertion was load-bearing on the
old (broken-for-workflow-agents) behavior and is replaced by the three
tests above. 38/38 tests in test_memory.py pass.


---------

Co-authored-by: DK09876 <[email protected]>
2026-06-04 15:01:09 -04:00
Ben 28044b1782 blog(google-adk): update cover image (#1985)
* blog(google-adk): update cover image
2026-06-04 14:37:14 -04:00
Ben bfdcb366d7 blog: Long-Term Memory for Google ADK Agents with Hindsight (#1979)
* blog: Long-Term Memory for Google ADK Agents with Hindsight

Introduces the hindsight-google-adk integration. Covers the drop-in
BaseMemoryService path (Runner takes care of add_session_to_memory /
search_memory automatically), the alternative FunctionTool path for
mid-turn agent-driven retain/recall/reflect, bank-scoping patterns
({app_name}::{user_id} default with overrides), and production patterns
(per-environment tagging, bootstrapped banks with a mission, self-hosted
Hindsight, recall budget).
2026-06-04 14:19:52 -04:00
Chris BartholomewandNicolò Boschi 7683f29004 refactor(engine): cheaper bank stats — drop unused join, add freshness helper, result cache (#1859)
* feat(engine): TTL + coalescing cache for get_bank_stats

The bank stats query joins memory_links to memory_units and aggregates by
(fact_type, link_type). On large banks the link side can run into millions
of rows, making each call a multi-second parallel scan. The result is
inherently approximate — it backs a UI widget and a freshness hint in
reflect — so a short result cache is safe.

Adds BankStatsCache: per-process TTL cache keyed on (schema, bank_id) with
LRU eviction and concurrent-miss coalescing, so N callers that arrive on
the same cold key produce one DB roundtrip instead of N. Wired into
MemoryEngine.get_bank_stats after auth and validation; the DB body moves
to _compute_bank_stats unchanged.

Tunable via HINDSIGHT_API_BANK_STATS_CACHE_TTL_SECONDS (default 60s,
set to 0 to disable) and HINDSIGHT_API_BANK_STATS_CACHE_MAX_ENTRIES
(default 1024).

* refactor(engine): drop unused memory_links⇒memory_units join in bank stats

get_bank_stats used to compute a (fact_type, link_type) matrix joining
memory_links to memory_units to pick up the originating unit's fact_type.
On large banks that join can take seconds — and an audit of every caller
(UIs, MCP tool, SDK clients, integrations) shows that the matrix
(`link_breakdown`) and its fact-type rollup (`link_counts_by_fact_type`)
are declared in response types but never actually read.

This refactor:

* Replaces the JOIN with a single-table GROUP BY link_type on
  memory_links plus a small per-entity rollup over unit_entities. Both
  are cheap with the existing indexes and stay cheap even at multi-
  million-row scale.
* Keeps `links_breakdown` and `links_by_fact_type` in the response shape
  (returning empty values) so SDKs and openapi-generated clients do not
  break.
* Adds `MemoryEngine.get_bank_freshness(bank_id)` — a one-row aggregate
  over memory_units that returns just last_consolidated_at /
  pending_consolidation / failed_consolidation. Switches `reflect()` to
  call it; reflect used to call get_bank_stats and discard everything
  except those two scalars (and the previous hasattr-on-dict access
  pattern meant it was reading None back anyway).
* Adds three tests: stats response shape, freshness method correctness,
  and a regression test that reflect() never invokes the heavy stats
  loader.

Together with the result cache added in the previous commit, the
expensive per-bank join is no longer on any hot path.

* docs(engine): correct bank stats comments — hindsight-cli still reads the deprecated fields

The prior comments asserted "no consumer reads" link_counts_by_fact_type /
link_breakdown. That was wrong: hindsight-cli's `bank stats` renderer
iterates both. The data still degrades gracefully there (one section
prints empty, the other is skipped by an is_empty() guard), but the
deprecation note should reflect reality so the next reader doesn't
assume the CLI was audited and rip the fields out without updating it.

* fix(engine): invalidate bank stats cache on delete_bank / clear_memories

The TTL cache was serving pre-deletion counts for up to 60s after
delete_bank() (which also backs the DELETE /memories "clear" path),
breaking the contract that callers see fresh data immediately after a
destructive op. Two http integration tests were failing on shard 2/3
because the second stats read returned the cached pre-delete value.

Wire BankStatsCache.invalidate() into delete_bank after the deletion
commits. Other write paths (retain, consolidate) only loosen counts and
remain TTL-bounded — staleness there is acceptable polling behavior.

* docs(engine): clarify get_bank_freshness keeps failed_consolidation for contract

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-04 18:14:27 +02:00
Ben b383c6edd9 release(autogen): v0.1.3 2026-06-04 11:25:12 -04:00
DK09876andDK09876 0eb40a7c1b fix(autogen): default to Cloud + gated E2E + bucketing + add missing CI job (#1868)
- resolve_client() now falls back to DEFAULT_HINDSIGHT_API_URL and the
  HINDSIGHT_API_KEY env var when configure() was never called (was raising).
  Updated the raise-test to assert the cloud-default + env-key behavior.
- Fix two pre-existing broken tests (test_falls_back_to_global_config /
  test_explicit_url_overrides_config): mock_cls.assert_called_once_with was
  missing user_agent — switched to loose call_args.kwargs checks.
- Add a gated tests/test_e2e.py (retain/recall/reflect via tool.run_json) and
  mark requires_real_llm; register the marker.
- ADD the missing test-autogen-integration CI job (the autogen/ package had
  ZERO CI coverage — only ag2/ had a job). 4 places:
  * detect-changes output integrations-autogen
  * path filter hindsight-integrations/autogen/**
  * test-autogen-integration job (runs -m "not requires_real_llm")
  * test-autogen-integration entry in the aggregate gate

Co-authored-by: DK09876 <[email protected]>
2026-06-04 11:12:26 -04:00
Chris BartholomewandNicolò Boschi d7a3aa5269 feat(llm): provider prompt-prefix caching — retain + consolidation + reflect (bank-agnostic, default-on) (#1936)
* feat(gemini): add context-cache foundation (GeminiCacheManager + opt-in call() arg)

Wraps the google-genai SDK's CachedContent API so callers can reuse a
stable (system_instruction + response_schema) prefix across many
requests. Cached input tokens are billed at a fraction of the standard
input rate, which makes workloads with a fixed-prefix / small-user-message
shape — fact extraction, structured tagging, classification — far
cheaper to run.

This PR is foundation-only: no caller is wired up yet. Default
behaviour for every existing path is unchanged because
`cached_content_name` defaults to `None` and the cache manager is
never instantiated until a follow-up wires it in.

What's here
-----------
- `gemini_cache.GeminiCacheManager`: per-process map of prefix
  fingerprint → CachedContent resource name. Thread-safe via a single
  asyncio.Lock. Refreshes proactively at TTL minus a safety margin.
  Stable fingerprint normalisation strips auto-generated Pydantic
  schema titles so dynamically-built schema classes with identical
  shape hash to the same key (relevant for callers that rebuild the
  schema class on every request).
- `gemini_llm.GeminiLLM.call(cached_content_name=...)`: new optional
  arg. When set, the SDK config drops `system_instruction` and
  `response_schema` (those live in the cache) and instead passes
  `cached_content` to GenerateContentConfig. When unset, behaviour is
  byte-identical to before.
- `tests/test_gemini_cache.py`: 10 unit tests covering fingerprint
  stability, dict/list/Pydantic schema cases, get_or_create
  caching/recreate, "minimum token count" soft-fallback, transient
  SDK error soft-fallback, failed-create-doesn't-poison-cache, and
  the TTL refresh boundary.

Failure handling
----------------
- Gemini rejects creates whose prefix is below the model's minimum
  cacheable size with a "minimum"-style error message. The manager
  catches this, logs at DEBUG, and returns None so the caller
  transparently falls back to a non-cached call.
- Any other SDK error is logged at ERROR and also returns None — a
  bad create never crashes a request. Callers are required to treat
  None as "cache unavailable, use the normal path".

Not in this PR
--------------
- Wiring this into the fact-extraction pipeline (or any other caller)
- A metric for cached-token volume
Both will come in a focused follow-up so the foundation can land and
be reviewed independently.

* feat(gemini): wire retain fact-extraction to context cache; surface cached + thoughts tokens

Follow-on to the foundation commit on this branch — without this, the
cache manager is unreachable and the metric ignores half the cost
surface. This commit makes the change actually do something when the
flag is flipped on.

What lands
----------
1. Retain fact-extraction (engine/retain/fact_extraction.py) opts into
   the cache. The system prompt and response schema are fingerprinted
   and reused across calls; the user message is the only variable
   part on the wire. A cache lookup failure or "prefix too small"
   response from Gemini transparently falls back to the existing
   uncached path — caching is a soft optimisation, never a blocker.

2. New top-level flag HINDSIGHT_API_LLM_GEMINI_PROMPT_CACHE_ENABLED
   (also exposed as ``llm_gemini_prompt_cache_enabled`` on
   HindsightConfig). Defaults to False so upgrade-and-do-nothing is a
   no-op. Flipping to True opts every Gemini caller (currently only
   retain) into context caching.

3. Two new metrics:
   - hindsight.llm.tokens.cached_input — subset of input tokens billed
     at the cached rate. Lets dashboards split cache-hit vs cache-miss
     volume independently of total throughput.
   - hindsight.llm.tokens.thoughts — reasoning tokens emitted by
     Gemini 2.5+. Billed at the output rate by the provider but
     invisible to candidates_token_count, so absent from output-token
     dashboards today. Surfacing this is required for honest cost
     attribution.

4. Provider plumbing: GeminiLLM gains a ``gemini_prompt_cache_enabled``
   kwarg and a ``get_or_create_cached_prefix(...)`` accessor that lazy-
   builds a GeminiCacheManager on first opt-in. LLMProvider /
   create_llm_provider / ConfiguredLLMProvider pass the flag through
   the standard plumbing alongside the existing safety_settings.

Verification
------------
- ``uv run ruff check`` — clean
- ``uv run pytest tests/test_gemini_cache.py`` — 12 tests including
  two new integration tests that pin (a) flag-off → cache manager
  never built, and (b) flag-on → manager lazy-built, second lookup
  served from in-memory cache, no extra SDK call.
- ``uv run pytest tests/test_gemini_safety_settings.py`` — 13 tests
  still green (no signature drift; the NoOp metrics collector was
  updated alongside the real one).

Rollout
-------
- Land this commit. With the flag default-off, behaviour is identical
  to today: cache code paths exist but are never reached.
- Flip the flag per-env. The metric goes non-zero on cached_input
  within a few calls.
- Watch hindsight.llm.tokens.cached_input vs hindsight.llm.tokens.input
  to confirm cache-hit rate.

What's deliberately NOT in this PR
----------------------------------
- Extending caching to other Gemini callers (reflect tool-call,
  consolidation). Same mechanism applies — copy two lines from the
  retain path. Leave for a follow-up so this lands in one focused PR.
- Cross-pod cache sharing. Each pod warms its own cache. The cost of
  one extra full-price call per pod per fingerprint per TTL window is
  negligible relative to steady-state savings.

* feat(gemini): extend context caching to the tool-calling reflect loop

Adds caching support to the agentic tool-loop path. The reflect agent's
``system_prompt + tools`` is stable for the duration of a single reflect
(and across reflects against the same bank), so caching them once and
reusing the cache name across every iteration of the loop collapses the
dominant input cost — the prefix repeated on every turn.

Mechanism
---------
1. ``GeminiCacheManager.fingerprint(...)`` now accepts ``tools`` and
   includes the OpenAI-style tool list in the hash. A loop that swaps a
   tool gets a fresh cache automatically; a loop that doesn't, hits the
   cache deterministically. The tool list is serialised with sort_keys
   so upstream dict-reordering doesn't cause phantom cache misses.

2. ``GeminiCacheManager.get_or_create(...)`` accepts ``tools`` and
   converts the OpenAI-style entries into Gemini ``Tool`` /
   ``FunctionDeclaration`` shapes inside ``CreateCachedContentConfig``.
   The cached prefix now holds system_instruction + tools, so the
   subsequent ``call_with_tools(cached_content_name=...)`` invocation
   skips resending both.

3. ``GeminiLLM.call_with_tools(...)`` gains ``cached_content_name``.
   When set, ``system_instruction`` and ``tools`` are dropped from the
   per-request config (the SDK rejects re-sending them alongside
   ``cached_content``); ``tool_config`` (mode / allowed_function_names)
   stays per-request as it must.

4. ``GeminiLLM.get_or_create_cached_prefix(...)`` accepts ``tools``
   and forwards them to the cache manager.

5. ``reflect/agent.py:run_reflect_agent`` looks up (or creates) the
   cached prefix ONCE per reflect — right after the ``system_prompt``
   and ``tools`` are built — and reuses the returned cache name across
   every iteration of the agentic loop. The lookup is wrapped in a
   try/except so a cache-side failure can never block a reflect.

6. ``call_with_tools`` now extracts ``cached_content_token_count``
   and ``thoughts_token_count`` from ``usage_metadata`` and threads them
   through ``metrics.record_llm_call`` — same as ``call()`` already
   does. Without this the new ``hindsight.llm.tokens.cached_input`` and
   ``hindsight.llm.tokens.thoughts`` counters would never report the
   reflect-side share of cached/thinking tokens.

Tests (3 new on top of the 12 from earlier on this branch)
----------------------------------------------------------
- ``test_fingerprint_changes_with_tools``: adding a tool changes the
  fingerprint so a loop that adds a tool gets a fresh cache.
- ``test_fingerprint_stable_under_dict_reordering``: dict-key order in
  the OpenAI-style tools list does NOT change the fingerprint.
- ``test_get_or_create_passes_tools_to_create``: the ``caches.create``
  call actually receives the tools in its config — without this the
  cache would silently lack the tool definitions and the first
  ``call_with_tools(cached_content_name=...)`` would 400.

Verification
------------
- ``uv run pytest tests/test_gemini_cache.py tests/test_gemini_safety_settings.py``
  → 28/28 pass (15 cache + 13 safety; the safety-settings suite
  doubles as regression on the ``call_with_tools`` signature change).
- ``uv run ruff check`` on changed files — clean.

Behavioural envelope
--------------------
- Flag still defaults False — no caller is opted in by default.
- When flag is True, both ``retain_extract_facts`` (from the earlier
  commit on this branch) and ``reflect_tool_call`` opt in.
- A cache-side failure (transient SDK error, prefix too small, manager
  uninstantiated) returns None and the caller proceeds uncached. There
  is no path by which caching can break reflect or retain.

* fix(gemini): make explicit prompt caching actually work end-to-end

The caching paths could never produce a cache hit:

- CreateCachedContentConfig was given response_schema/response_mime_type,
  which the google-genai SDK forbids (extra_forbidden) — so every cache
  create raised and soft-fell-back to an uncached call. Cache only holds
  system_instruction (+ tools); response_schema is a generation-time
  constraint and stays on the per-request GenerateContentConfig.
- call() dropped response_schema when a cache was in use (assuming the
  schema lived in the cache — impossible). Keep it on the request; only
  system_instruction moves into the cache. Structured output is preserved.
- cached_content_name was plumbed into the leaf GeminiLLM.call /
  call_with_tools but NOT through the LLMProvider wrapper, so the real call
  path raised "unexpected keyword argument 'cached_content_name'". Thread it
  through both wrappers, forwarding only when set (other providers untouched).

With these, retain extraction caches the ~1.7k-token prefix at ~90%.

* feat(gemini): cache consolidation prefix + gate reflect cache to auto turns

- Consolidation: split the batch prompt into a stable system instruction
  (mission + rules + decision guide + output format) and a per-batch user
  message (facts + existing observations + capacity note). The system prefix
  is byte-identical across batches in a run, so it is cached and reused; the
  variable data and the per-batch response_schema stay out of the cached
  surface so it never busts. Measures ~30-40% cached/input per batch (the
  remainder is irreducible per-batch data).
- Reflect: Gemini rejects cached_content alongside a per-request tool_config
  ("CachedContent can not be used with ... tool_config"). The forced-retrieval
  iterations set tool_config, so only the `auto` iterations can reference the
  cache. Gate cached_content_name on tool_choice == "auto"; forced iterations
  send the prefix inline.

* test(gemini): per-operation cached-ratio test + consolidation split coverage

- New tests/test_gemini_implicit_cache_ratio.py: measures cached/input token
  ratio per operation (retain, reflect, consolidation) against real Gemini via
  the LLM-request tracer. Dual mode: default records the implicit-cache baseline
  (~0% for this access pattern); HINDSIGHT_GEMINI_EXPLICIT_CACHE=1 asserts the
  explicit cache engages (cached_tokens > 0, per-op ratio floor). Gated behind
  HINDSIGHT_RUN_GEMINI_EVALS=1 + a Gemini key.
- test_consolidation.py: unit test for the system/user prompt split (cacheable
  byte-stable prefix; data only in the user message). Fix the inline mock LLM
  callbacks to read facts from the user message(s) rather than messages[0], now
  that the stable instructions are a separate system message.

* perf(consolidation): move stable observation-format note into cached prefix

The "## INPUT FORMAT" boilerplate (the explanation of the observation JSON
shape: id/text/proof_count/occurred_*/source_memories) was re-sent in every
per-batch user message. It's stable, so move it into the cached system prefix
(build_consolidation_system_prompt); the per-batch user message now carries
only the variable facts + observations data. Lifts the cached/input ratio a
couple of points without changing what the model sees.

* feat(gemini): make cached prefix bank-agnostic (mission → user message)

The retain and consolidation system prompts embedded the per-bank mission, so
each distinct mission produced a different cache fingerprint → one CachedContent
per bank. With many banks/missions that multiplies create + storage cost and
cached-object count, and makes default-on uneconomical.

Move the mission out of the cached prefix into the per-request user message:
- retain: _build_extraction_prompt_and_schema now returns a bank-agnostic prompt;
  the mission rides in the user message via _retain_mission_preamble().
- consolidation: build_consolidation_system_prompt drops the mission param; the
  mission moves into build_consolidation_input (the user message).

Result: the cached prefix is identical across all banks, so a single shared
CachedContent serves every bank — cardinality drops from O(missions) to O(1) per
operation, and the cost-inversion for many-low-volume-bank workloads goes away.

Behavioral note: the mission now appears in the user turn rather than the system
prompt. Validate mission-adherence against the accuracy benchmarks before flipping
the global default on. Tests updated to assert the new location + cross-bank
prefix sharing.

* test(retain): assert different missions yield one shared cache prefix

Extend the mission-relocation test to prove the payoff directly: two banks with
different retain missions produce a byte-identical system prompt → the same cache
fingerprint → a single shared CachedContent instead of one per mission.

* test(retain): cacheable prefix invariant to per-bank free-text (concise/verbose)

Parametrized over the concise and verbose modes: the cached system prompt must be
byte-identical regardless of the retain mission (any value, incl. JSON/unicode/
long text) and custom instructions, so per-bank free-text can never fragment the
shared Gemini cache. Structural toggles (causal/labels/language) are intentionally
out of scope — they legitimately partition the cache via the fingerprint.

* refactor(llm): make prompt-prefix caching a provider-interface feature

Hoist caching out of Gemini-specific duck-typing into the LLMInterface contract,
mirroring supports_batch_api():
- LLMInterface.supports_prompt_caching() -> bool (default False) and
  get_or_create_cached_prefix(...) -> str | None (default None), with docs on how
  explicit-cache (Gemini handle), automatic-cache (OpenAI), and inline-marker
  (Anthropic cache_control) providers each map onto the hook.
- call()/call_with_tools() gain a provider-neutral cached_prefix handle (renamed
  from the Gemini-flavoured cached_content_name); the wrapper forwards it only
  when set so non-caching providers' signatures are untouched.
- GeminiLLM implements supports_prompt_caching(); the retain/consolidation/reflect
  call sites gate on it instead of hasattr().

The engine already decides WHAT is cacheable (bank-agnostic system prefix), so a
new provider only implements HOW — e.g. OpenAI can benefit with no code (stable
leading prefix is auto-cached) or a thin override.

* docs(models): add per-provider capability table (batch API, prompt caching)

Adds a "Provider Capabilities" table to the LLM section of the models page
showing which providers support the Batch API (OpenAI/Groq/Fireworks) and
explicit prompt-prefix caching (Gemini/Vertex via CachedContent), with notes on
OpenAI's automatic prefix caching and the bank-agnostic shared-cache design.
Includes the regenerated skills/hindsight-docs mirror.

* docs(models): drive provider capability table from llmProviders.json

Replace the hand-written capability table with a data-driven one so adding a
provider stays a single-file edit. The capability flags (batchApi, promptCaching)
live in llmProviders.json — the existing single source of truth for the provider
grid and default-models table — and a new LLMProviderCapabilities component (plus
a matching renderer in generate-docs-skill.sh) renders them. Tool-calling dropped
(not differentiating here). Keep flags aligned with supports_batch_api() /
supports_prompt_caching() on the provider classes.

* feat(llm): generic, default-on prompt caching knob

Rename the Gemini-specific opt-in flag to a provider-agnostic, default-on knob,
modelled on HINDSIGHT_API_RETAIN_BATCH_ENABLED:

- HINDSIGHT_API_LLM_GEMINI_PROMPT_CACHE_ENABLED → HINDSIGHT_API_LLM_PROMPT_CACHE_ENABLED
  (config field llm_gemini_prompt_cache_enabled → llm_prompt_cache_enabled, kwarg
  gemini_prompt_cache_enabled → prompt_cache_enabled), single global knob (not per-op).
- DEFAULT_LLM_PROMPT_CACHE_ENABLED = True. Safe to default on: the cached prefix is
  bank-agnostic (one shared cache) and creation soft-fails to an uncached call, so
  it never breaks a request. Providers that don't implement caching ignore the flag.
- Resolve the flag for every provider (drop the gemini/vertexai restriction) so any
  future provider that implements supports_prompt_caching() picks it up.

Docs: models page now says "on by default; disable with
HINDSIGHT_API_LLM_PROMPT_CACHE_ENABLED=false". The per-operation ratio test sets the
flag explicitly in both modes since the default is now on. Includes the regenerated
skills/hindsight-docs mirror.

* fix(gemini): fall back to uncached on a cached-request 400

A 400 from a generate request that references a CachedContent (expired/deleted
cache, cross-project mismatch, cache+tool_config incompatibility, ...) was treated
as a generic retryable error: the same cached request was retried, 400'd again,
and the whole operation failed. The soft-fallback only covered cache *creation*,
not the call that *uses* the cache.

Now, on the first 400 while a cache is in use, call()/call_with_tools():
- drop the cache and rebuild the request inline (re-send system prefix + schema/
  tools) so the request still succeeds,
- invalidate the dead cache name (GeminiCacheManager.invalidate) so the next
  operation recreates it instead of reusing the bad name,
- retry immediately (no backoff — it's a config switch, not a transient error).

If the uncached retry also 400s it's a genuine bad request and errors normally.

Supporting fix: system_instruction is now ALWAYS captured from the messages (it
was skipped when cached), so the fallback has the prefix to inline; the config
builder still omits it from the request while the cache carries it. New unit test
covers the 400 → uncached-retry → invalidate path. Cached success path unchanged
(real Gemini retain still 90.8%).

* fix(gemini): bound the cache-create call with a timeout

get_or_create holds the manager lock across the caches.create network call, which
correctly dedups concurrent callers (a 10-chunk retain batch produces exactly one
create, not ten). But with no timeout, a hung create would block every waiting
chunk indefinitely. Wrap the create in asyncio.wait_for (30s default, configurable
via create_timeout_seconds); on timeout it soft-fails to None and callers proceed
uncached instead of stalling the batch. Unit test covers the timeout path.

* style: ruff-format the prompt-cache config line (fixes verify-generated-files)

* test: fix consolidation-scope-parallelism mock + metrics counter count

- test_consolidation_scope_parallelism.py: the inline mock read facts from
  messages[0], which is now the (cached) system message after the consolidation
  prompt split — read the user message(s) instead.
- test_metrics.py: mock_meter provided 5 counter mocks but MetricsCollector now
  creates 7 (the cached_input + thoughts counters), so create_counter.side_effect
  ran out (StopIteration at setup). Bump both fixtures to 7.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-04 14:11:49 +02:00
Nicolò Boschi 01134047d1 feat(recall): per-strategy retrieval boost via env config (#1974)
Add HINDSIGHT_API_RECALL_STRATEGY_BOOSTS, a single env knob that lets a
deployment prioritise one or more retrieval arms (semantic/bm25/graph/temporal)
over the others using a human priority level — e.g. "graph:high" to strongly
favour graph hits, or "graph:high,semantic:low". Valid levels: low | medium |
high. A strategy listed without a level ("graph") defaults to medium; arms you
don't list keep their normal weight; empty disables the feature.

A named level (not a raw number) is the knob because the boost is applied in
two structurally different places on different score scales:
1. Before the reranker cap, as a weighted-RRF sort key, so boosted-arm
   candidates survive the global candidate budget instead of being trimmed by
   raw RRF score (rank-aware).
2. After the reranker, as a flat additive bump to the final ranking weight.

Level -> per-stage magnitudes (in engine/search/recall_boost.py) are tuned
against real recall traces (LoCoMo bank, 336 merged candidates -> 300-cap,
local ms-marco cross-encoder): the observed cap boundary RRF was ~0.0055, so
the stage-1 multipliers 1/3/6 map to rescue/promote/dominate; the cross-encoder
weight scale is [0,1] and bimodal, so the stage-2 additives 0.05/0.2/0.5 map to
nudge/compete/win-over-most-matches. A guard test keeps the level names in sync
with config. Global, read via get_config(), mirroring
recall_max_candidates_per_source.
2026-06-04 13:49:30 +02:00
Minghao Xiao 6b8fc53d79 fix(search): escape pgroonga BM25 query text (#1966) 2026-06-04 11:29:34 +02:00
Nicolò Boschi 602c9f55e2 feat(transfer): whole-bank export/import for cross-instance migration (#1884) (#1953)
* feat(transfer): admin export-bank command (whole-bank portable archive)

Add 'hindsight admin export-bank --bank <id> [--schema] [--include-history]'
that exports an entire bank to a portable ZIP for migrating it to a new
instance configured with a different embedding model / vector / text-search
backend. No embeddings are written — they are regenerated on import.

The archive is a superset of the documents archive:
  * logical document/fact/observation export (replayed + re-embedded on import);
  * bank config, mental models (vector stripped → re-embed), directives, webhooks
    carried as JSON rows;
  * audit_log / llm_requests only with --include-history.

Every bank-scoped table (BACKUP_TABLES) is classified logical / carried /
history / skipped; test_export_bank_covers_schema fails if a future migration
adds a table without classifying it. Import of the new sections is a follow-up.

Tests: schema-coverage guard + a contents test (archive_type, carried bank
config + webhook, no embeddings, history gated by the flag).

* feat(transfer): import-bank — restore a whole-bank archive (cross-instance migration)

Add the import half of bank migration:
  * transfer.import_bank: restores bank config, then docs/facts/observations
    (re-embedded with the TARGET instance's model via import_documents), then
    mental models, directives, webhooks as verbatim rows. Restores exact state —
    fires no webhooks and triggers no consolidation (observations/mental models
    are restored, not regenerated). _restore_rows coerces JSON values back to
    column types (timestamps/uuids/jsonb) and is idempotent (ON CONFLICT DO NOTHING).
  * MemoryEngine.import_bank_async / export_bank_async wrappers.
  * admin 'import-bank' command (boots a MemoryEngine for the target model);
    plus engine-backed export.

Tests: exact round-trip (export -> delete -> import) asserts every section —
bank config, documents, facts, observations, entities, temporal links, webhooks,
directives, mental models — matches exactly, with facts re-embedded (no NULL
vectors). Semantic links compared loosely (ANN index regenerated). Also a guard
that import-bank rejects a documents-only archive.

* docs(transfer): bank migration runbook (export-bank / import-bank)

Document the admin export-bank/import-bank commands and the blue-green runbook
for moving a bank to a new instance with a different embedding model / vector /
text-search backend, re-embedding on import without LLM re-extraction.

* refactor(transfer): drop unused export_bank_async engine method

Code-review: the engine wrapper had no caller but the test — the export-bank CLI
reads rows directly via transfer.export_bank (no engine/embeddings boot needed
for a read-only export). Call transfer.export_bank directly in the test instead.

* docs(transfer): document export-bank/import-bank + migration playbook on the Admin CLI page

Use the installed 'hindsight-admin <cmd>' convention (not 'uv run'). Add the full
export-bank/import-bank command reference and blue-green migration runbook to the
Admin CLI page; reduce the memory-banks section to a short summary that links there.

* refactor(transfer): _admin_connect helper + clearer _REPLAYED_TABLES naming

- Extract _admin_connect(db_url); resolve_database_url already handles pg0:// vs
  postgres://, so export-bank no longer re-implements the connect dance inline.
- Rename _LOGICAL_TABLES -> _REPLAYED_TABLES + clarify: entities/unit_entities/
  memory_links/entity_cooccurrences are NOT exported (rebuilt by the import
  pipeline); the bucket only exists for the coverage guard.

* fix(transfer): import-bank requires a non-existent target bank (no merge)

Importing into an existing bank silently merged: bank config kept (ON CONFLICT
DO NOTHING), docs per on_conflict, and mental_models/directives/webhooks added
alongside existing rows. import-bank restores a WHOLE bank, so refuse when the
target already exists — delete it or pass a fresh --target-bank.

Since a fresh target has no document conflicts, drop the now-meaningless
on_conflict knob from import_bank / import_bank_async / the import-bank CLI.

Test: importing an archive whose bank still exists raises.

* test(transfer): add manual two-instance bank-migration e2e script

scripts/dev/e2e-bank-migration.sh spins instance A (bge-small/384) and B
(bge-base/768), retains into A, runs export-bank -> import-bank, and asserts
recall on B returns the migrated fact ranked first with both instances on
different embedding dims. Self-asserting (exits non-zero on failure); not run in
CI (needs two cached models + an LLM key). Verified passing locally.

* test(transfer): drop manual e2e-bank-migration.sh script

Remove the two-instance migration e2e script from the repo (kept as a local-only
dev tool). Engine-level integration tests in test_document_transfer.py cover the
export/import round-trip.

* docs(admin-cli): add 'Running the CLI' intro (how to run, what it points to)

Explain that hindsight-admin connects directly to PostgreSQL (not the HTTP API),
uses the same config/.env as the API (HINDSIGHT_API_DATABASE_URL), is PostgreSQL-only,
and is typically run inside the API host/container (docker exec / kubectl exec).
2026-06-04 11:26:48 +02:00
Nicolò Boschi e1d5db5c59 fix(test): use current default model in Vertex AI integration test (#1972)
gemini-2.0-flash-001 was retired on Vertex AI (404 NOT_FOUND),
failing the live integration test. Switch to google/gemini-2.5-flash-lite,
matching the vertexai provider default in config.py.
2026-06-04 10:55:11 +02:00
Nicolò Boschi 2535db2745 fix(retain): make document lock/upsert dialect-aware for Oracle (#1944) (#1952)
The retain document-ownership gate used a single
`INSERT ... ON CONFLICT DO UPDATE ... RETURNING content_hash` upsert to
create-or-lock the document row and read its prior hash. PostgreSQL runs
this as-is, but the Oracle adapter rewrites `ON CONFLICT DO UPDATE` to a
`MERGE`, which cannot carry a `RETURNING` clause. The rewritten statement
returned no rows, so every retain 500'd with
`DPY-1003: the executed statement does not return rows`, turning the
`test-python-client-oracle` and `test-typescript-client-oracle` jobs red.

Move the lock-and-read step behind `DataAccessOps.lock_document_for_write`
so each backend implements it natively:
- PG: the same single-statement upsert (DO UPDATE always takes the row
  lock, avoiding the old two-step deadlock).
- Oracle: an idempotent insert (IGNORE_ROW_ON_DUPKEY_INDEX) followed by a
  `SELECT ... FOR UPDATE`, since MERGE can't RETURNING.

Adds regression tests: PG functional coverage of the placeholder→hash
transition and bank isolation, plus translator tests pinning the root
cause (MERGE drops RETURNING) and the Oracle fallback's clean rewrite.
2026-06-04 10:37:22 +02:00
Nicolò Boschi 613a699e9f fix(consolidation): eliminate duplicate observations via interleave dedup recall (#1907)
Round-robin interleave fusion for consolidation dedup recall (guarantees the semantic-#1 'twin' a slot so the LLM updates instead of duplicating), unified 'reranking' strategy param (cross_encoder/rrf/interleave), case-sensitive exact-dup guard, obs-dedup tool + benchmark wired into the perf dashboard (English dataset). Near-dup observation rate 4% -> 0% on the English hermes transcript (1/10 and 1/4), coverage 89% -> 94%, no false merges.
2026-06-03 17:37:57 +02:00
Nicolò Boschi 2834192800 feat(control-plane): "not enabled" splash for disabled audit logs & LLM requests (+ bank name fix) (#1950)
* feat(control-plane): show "not enabled" splash for disabled audit logs & LLM requests

Add a reusable FeatureNotEnabled component (centered icon + title +
description) and use it for the Audit Logs and LLM Requests tabs, plus
refactor the existing Observations splash to reuse it. Tabs gain an
"Off" badge when the feature is disabled.

To let the UI detect server-side gating, expose audit_log and llm_trace
in the /version features object (sourced from config.audit_log_enabled /
config.llm_trace_enabled), wire them through the features context and the
control-plane SDK type, and add i18n keys across all 10 locales.

* fix(retain): default bank name to bank_id in ensure_bank_exists

ensure_bank_exists inserted banks without a name (NULL), unlike the other
creation path (get_or_create_bank_profile, which defaults name to bank_id).
Since #1940 wired PATCH /config to ensure_bank_exists, a config PATCH on a
never-retained bank (and any retain-only bank) produced a NULL name, which
then 500'd the deprecated GET /profile endpoint (name is typed as a required
str). Default name to bank_id at insert so every creation path is consistent.

Extends the #1940 regression test to assert the auto-created bank's profile
returns 200 with name == bank_id.

* test(api): assert audit_log and llm_trace flags in /version response

* chore: regenerate openapi spec and client SDKs for new feature flags
2026-06-03 17:01:59 +02:00
Ben 1b3925f22f blog: Voice Agents That Remember — Adding Memory to Vapi with Hindsight (#1949)
* blog: Vapi Persistent Memory — Phone Agents That Remember Every Caller
2026-06-03 10:56:56 -04:00
Nicolò Boschi 1d6d73bce4 feat(transfer): export/import documents between banks without re-running the LLM (#1909)
* feat(transfer): export/import documents between banks without re-running the LLM

Export a bank's already-extracted facts (text, entity canonical names, causal
links, chunks) to a ZIP archive, and import them into another bank by replaying
the deterministic half of the retain pipeline — re-embedding locally with the
target bank's model and re-resolving entities. No LLM fact extraction runs on
import. Consolidated observations are excluded (regenerated by consolidation in
the target bank).

Two use cases: testing a different embedding model, and moving data between
banks/instances without LLM cost.

- engine/transfer/: schema, export, importer (LLM-free replay)
- MemoryEngine.export_documents_async / import_documents_async
- Admin CLI: export-documents / import-documents
- HTTP API: GET/POST /v1/default/banks/{bank_id}/document-transfer
- Gated by HINDSIGHT_API_ENABLE_DOCUMENT_EXPORT_API / _IMPORT_API
  (default on), surfaced via /version features for the control plane
- Control plane Documents page: Export All / Import (zip upload) +
  per-document Export, hidden when the backend disables the feature
- Tests, docs, regenerated OpenAPI spec and client SDKs

* fix(transfer): trigger consolidation, graph maintenance & webhooks on import

Imported documents were second-class citizens: unlike a normal retain, an
import fired no retain.completed webhooks and never enqueued consolidation
or graph maintenance, so imported facts never produced observations.

Thread an outbox callback factory through import_documents -> _import_one_document
so each imported document fires its retain.completed webhook transactionally
inside its own insert. After the import completes, submit async consolidation
(when observations + auto-consolidation are enabled) and graph maintenance,
mirroring the post-retain side effects.

* refactor(transfer): share post-insert maintenance helper between retain and import

The consolidation + graph-maintenance triggers added for import duplicated the
retain post-processing block verbatim. Extract it into
_submit_post_insert_maintenance and call it from both the retain pipeline and
the import pipeline, so the two paths stay in lockstep.

* feat(transfer): fire on_retain_complete per imported document

Import now fires the post-retain extension hook (usage tracking / metrics /
notifications) once per imported document, mirroring retain — so imported
facts are first-class for extensions. Token counts are zero and
processed_content_tokens is 0 (import runs no LLM extraction), so cost-metering
extensions correctly bill an import as free.

The importer returns per-document outcomes (ImportedDocument) so the engine can
build the RetainResult; these are not serialized into the operation's
result_metadata (the worker still writes counts only).

Tests: assert the hook fires once per document with zero tokens, and that
import queues a retain.completed webhook delivery per document.
2026-06-03 16:24:33 +02:00
Nicolò Boschi d695611ada fix(retain): stop bank_id routing key polluting fact attribution (#1680) (#1948)
* fix(retain): stop bank_id routing key polluting fact attribution (#1680)

The fact extractor injects a 'Narrator: {banks.name}' line that is stamped
into the who-dimension of every first-person fact (and the observations
consolidated from them). On auto-create banks.name defaults to bank_id, which
is typically a routing key (e.g. my-agent::channel-456::user-789), not a
speaker — so the routing key ends up embedded in stored fact text.

- Suppress the narrator when name == bank_id (_resolve_narrator).
- Make the Context take precedence over the narrator for speaker attribution:
  when the Context names a different first-person speaker (a user/customer in a
  transcript), those statements are classified 'world' and attributed to that
  speaker, not the agent.

Tests: pure unit tests for the suppression + injection logic, and a real-LLM
test (llm_judge) verifying user first-person statements are attributed to the
user as 'world'. The agent-self-log behaviour is unchanged.

* fix(retain): only add Context-precedence clause when context is set

The narrator's 'Context above takes precedence' clause referenced a
'Context: none' line when no context was provided. Gate it on context.

* test+docs: judge fact_type classification; document LLM-judge tests and world/experience facts

- test_narrator_context_override: assert fact_type via LLM judge (not a hard
  enum assert), matching the codebase's hs_llm_core pattern.
- CLAUDE.md + code-review skill: document real-LLM + llm_judge tests for any
  change to model-interpreted behaviour (classification, attribution, prompts).
- docs/developer/retain.md: clarify world vs experience facts — the split is
  by speaker; set the bank name and describe the speaker in context.
2026-06-03 16:22:51 +02:00
Maple Gao a14ce623c5 fix(control-plane): localize operations and graph legends (#1946) 2026-06-03 15:29:07 +02:00
Nicolò Boschi a809547aa8 fix(config): persist bank config PATCH for never-retained banks (#1940) (#1945)
Banks are created lazily on first retain, so a PATCH /config that preceded
any ingestion UPDATE-d zero rows and silently no-op'd while returning 200 —
the resolved response then reported global defaults with empty overrides.

Auto-create the bank (reusing ensure_bank_exists, which also creates the
per-bank vector indexes) before merging, and guard the JSONB merge with
COALESCE so a NULL config column doesn't drop the override.

Adds an API-level regression test covering enable_observations and
enable_auto_consolidation round-tripping for an uncreated bank.
2026-06-03 15:08:58 +02:00
Nicolò Boschi 70d98c7a27 fix(recall): gate VectorChord BM25 + add per-source candidate cap (#1707) (#1947)
VectorChord BM25 ranks *every* document via the `<&>` operator (which returns
the negative BM25 score), so a bare `ORDER BY ... LIMIT` padded each recall with
zero-score, non-matching rows. Unlike native tsvector — which has a boolean `@@`
match gate — the vchord arm had no gate, flooding RRF/reranking with weak
candidates and broadening answers (the #1707 regression).

- Gate the vchord BM25 arm on `-(search_vector <&> ...) > bm25_min_score`
  (default 0), the direct analogue of native's `@@` gate. Verified on a real
  VectorChord container: a query that returned 10 rows (2 real matches + 8 rows
  scoring exactly 0.0) now returns only the 2 genuine matches. Oracle's CONTAINS
  gate now shares the same configurable floor (behavior unchanged at 0).
- Add an optional per-source candidate cap applied to each arm (semantic, BM25,
  graph, temporal) before RRF, so one over-expanding backend cannot fill the
  reranker's global budget alone (HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE,
  default 0 = disabled). Verified live: cap=1 trims semantic 10->1, bm25 4->1.

New config: HINDSIGHT_API_BM25_MIN_SCORE, HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE.
2026-06-03 15:05:54 +02:00
Nicolò Boschi b1f6bbb8b4 feat(api): per-bank LLM request tracing via OTel GenAI recorder (#1922)
* feat(api): per-bank LLM request tracing via OTel GenAI recorder

Record every LLM call (success and failure) into a new `llm_requests`
table, per bank, when HINDSIGHT_API_LLM_TRACE_ENABLED=true (disabled by
default). Capture is wired into the OpenTelemetry GenAI record_llm_call
path: the DB tracer is registered as a span recorder alongside the OTLP
exporter, so providers' existing success calls flow through it and the
LLM wrapper forwards failures.

Each row stores input messages, model output, token usage
(input/output/cached/total from the provider response), finish reason,
provider/model/scope, timing, and caller metadata.

- GET /v1/default/banks/{bank}/llm-requests (+ /stats) read API
- Control-plane "LLM Requests" tab: list, filters, detail dialog, and a
  Calls/Tokens chart with Total/Breakdown and Cumulative toggles
- Reusable JsonViewer component (word-wrap + copy), applied to audit logs
- TokenUsage.cached_tokens; cached-token extraction for
  openai-compatible, gemini, anthropic
- Migrations for the table + token columns; backup/restore coverage
- Tests, docs, regenerated OpenAPI + SDK clients

* test(llm-trace): regression test for delta re-retain document_id binding

* feat(llm-trace): map produced/consumed memory_ids to retain & consolidation traces

Retain traces now carry metadata.memory_ids (the facts created); consolidation
traces carry metadata.source_memory_ids (memories consumed) and metadata.memory_ids
(observations created/updated). Accumulated at the DB-write sites onto the
operation-level trace context and flushed onto every row of the trace via
LLMTraceRecorder.attach_memory_ids (awaits in-flight fire-and-forget writes first
so the UPDATE never races ahead of the rows). Surfaced in the trace dialog as
'Memories created' / 'Source memories' chips.

* perf+feat(llm-trace): fire-and-forget mapping + bidirectional memory↔trace

Performance:
- attach_memory_ids is now fire-and-forget — it snapshots ids synchronously and
  patches the trace on a background task, off the retain/consolidation critical
  path. The pending-write flush is scoped to the operation's own trace_id
  (bucketed pending set) so it never waits on unrelated operations.

Memory ↔ trace navigation:
- New memory_id filter on the llm-requests listing, matching metadata.memory_ids
  (produced) OR metadata.source_memory_ids (consumed), so a memory resolves both
  the run that created it and the consolidation runs that used it as a source.
- Memory detail panel shows 'Created by' and 'Used by' sections opening the
  trace dialog. Regenerated OpenAPI spec + SDK clients.

* ui(llm-trace): rename 'Used by' to 'Consolidated by' on memory trace panel

* chore(clients): regenerate SDK clients after merge (llm_requests endpoints)

* ci(cli-coverage): mark llm_requests tracing endpoints UI-only

* fix(control-plane): drop invalid 'as const' on ternary (prod build typecheck)

* fix(llm-trace): guard trace_context() access for mock/substitute providers

run_consolidation_job and retain read the operation trace context off the
configured provider, but tests substitute a bare MockLLM without a
trace_context() method, which AttributeError'd and crashed all consolidation.
Add trace_context_of() to read it defensively (None when unsupported), so
tracing degrades gracefully and never breaks the operation.
2026-06-03 11:26:31 +02:00
Ben 24d6c2a43b blog: Using Entity Labels to Automatically Tag Memories in Hindsight (#1935)
* blog: Using Entity Labels to Automatically Tag Memories in Hindsight

Narrative explainer for the entity-labels feature — the controlled-
vocabulary classification system that runs during the retain pipeline.
Covers the four label types (value / multi-values / text / map), the
JSON-schema-enforced extraction path, the `tag: true` switch that
mirrors labels into memory tags for filterable recall, labels-only
mode, vocabulary-design best practices, and an end-to-end support-
ticket worked example with retain + recall code.

Fills a documentation gap: the feature has been called out in v0.6.1
and v0.7.0 release posts but never had a dedicated narrative piece.
Reference docs and Constellation post are cross-linked.
2026-06-02 15:13:41 -04:00
Nicolò Boschi 23168ebf68 fix(retain): pre-extraction freshness recheck + serialize concurrent same-doc writers (#1930)
Two fixes for concurrent retains targeting the same document:

1. Delta path now re-reads the document hash BEFORE the (expensive) LLM
   extraction. If a concurrent retain already committed identical content, we
   skip extraction and update metadata only; if it still differs we fall back
   to streaming. This avoids burning LLM tokens re-extracting work a concurrent
   request already did (staggered 10-way race: 10 -> 1 extraction call).

2. Streaming write-txn ownership gate is now a single atomic
   INSERT ... ON CONFLICT DO UPDATE (which locks the row) instead of
   INSERT ON CONFLICT DO NOTHING + a separate SELECT FOR UPDATE. DO NOTHING
   does not lock the existing row, which let concurrent same-document writers
   interleave the speculative-insert ShareLock with the later FOR UPDATE and
   cascade-DELETE in inconsistent orders, producing Postgres deadlocks.

Adds tests/test_retain_same_document_concurrency.py covering: identical
concurrent retains skip extraction, partial-overlap race completes cleanly,
staggered race avoids redundant extraction, and fully-different concurrent
retains no longer deadlock.
2026-06-02 18:24:31 +02:00
Nicolò Boschi dd75f0dbc8 chore(control-plane): bump next back to ^16.2.6 (undo 16.2.5 pin) (#1934)
Reverts the temporary `next` pin from #1928. Deeper investigation showed the
control-plane redirect loop (#1926) is NOT a 16.2.6 regression: it reproduces
identically on 16.2.5 and 16.2.6, and is triggered specifically by binding the
standalone server to HOSTNAME=127.0.0.1 (Next normalizes 127.0.0.1 -> localhost
in the proxy request URL but keeps 127.0.0.1 in the router's initUrl, so the
next-intl locale rewrite looks cross-origin and leaks as a 307 loop).

The production launchers (docker start-all.sh, bin/cli.js) bind HOSTNAME=0.0.0.0,
which serves 200 on every version, so the pin neither fixed #1926's repro nor was
needed for production. Restoring ^16.2.6 brings back the 16.2.6 security fixes
(proxy-bypass + SSRF). The 127.0.0.1-binding quirk is unrelated to the version.

Verified: npm ci -> single [email protected]; control-plane build typechecks; standalone
on HOSTNAME=0.0.0.0 serves /login, /banks/*, /es/login as 200.
2026-06-02 17:03:16 +02:00
Octopusandocto-patch d8dadc0a95 feat: upgrade MiniMax default model to M3 (#1914)
- Switch the minimax provider default from MiniMax-M2.7 to MiniMax-M3
  in PROVIDER_DEFAULT_MODELS (hindsight-api-slim/hindsight_api/config.py).
- Update the LiteLLM router test fixture to exercise MiniMax-M3.
- Update provider docstrings and example .env entries to mention MiniMax-M3
  while keeping MiniMax-M2.7 noted as a previous-generation option.
- Refresh hindsight-docs (developer/models, integrations/hermes,
  llmProviders.json) and the docs-skill reference table to list
  MiniMax-M3 as the documented default.

The deprecated MiniMax-M2.5 / M2.1 / M2 / M1 IDs are not referenced
anywhere in the active codebase, so no removals are required.

Co-authored-by: octo-patch <[email protected]>
2026-06-02 16:35:44 +02:00
Nicolò Boschi 401c3cd3fb docs: changelog and blog post for v0.7.2 (#1933)
* docs: changelog and blog post for v0.7.2

* docs: regenerate hindsight-docs skill references for v0.7.2

* docs: trim 0.7.2 blog to Flowise integration with docs link
2026-06-02 16:24:08 +02:00
Ben 7dffc0459d release(google-adk): v0.1.0 2026-06-02 10:02:16 -04:00
Ben f950e0c11c docs(guides): add Hermes memory guide batch (#1932) 2026-06-02 09:57:40 -04:00
Nicolò Boschi ffd7f94572 Release v0.7.2
- Update version to 0.7.2 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.7
2026-06-02 15:17:05 +02:00
Nicolò Boschi 201f5d7cda fix(control-plane): pin next to 16.2.5 to fix standalone i18n redirect loop (#1926) (#1928)
next 16.2.6 regressed how the standalone server resolves next-intl locale
rewrites. With the standalone default HOSTNAME=0.0.0.0, the i18n rewrite is
emitted as an absolute localhost URL and treated as cross-origin, so every page
route returns a 307 to itself (ERR_TOO_MANY_REDIRECTS). Bisected: 16.2.5 serves
200 with a relative rewrite; 16.2.6 and 16.2.7 loop. next dev is unaffected.

Pin next to 16.2.5 (exact) and add a root override so next-intl's peer dedupes
to the same single version — a 16.2.5/16.2.6 split fails the control-plane
typecheck. The Docker image build resolves the exact pin; CI `npm ci` installs
the pinned lockfile (single hoisted [email protected], all platform binaries kept).

Temporary: 16.2.6 is a security release, so we should return to a patched
version once the regression is fixed upstream. Tracking: vercel/next.js#94342.
2026-06-02 15:02:23 +02:00
Nicolò Boschi 8a1f0461cf docs(docker): drop --rm, add --name + restart policy in run examples (#1927)
A single child segfault under load propagates through start-all.sh and
exits the whole container; with the documented --rm run there was no
recovery. Replace --rm with --name hindsight --restart unless-stopped in
the documented server-run commands so a transient crash self-heals.

Leaves the throwaway --rm --entrypoint sh model-inspection command in
custom-models/README.md untouched. Refs #1918.
2026-06-02 14:19:16 +02:00
Nicolò Boschi 670c2be5e4 refactor(api): move audit-logs endpoint queries into MemoryEngine (#1925)
The /audit-logs and /audit-logs/stats handlers ran raw SQL directly in
the HTTP layer instead of going through a MemoryEngine method, violating
the API-layer data-access standard (queries belong in the engine; auth/
tenancy enforced there). Mirrors the llm-requests pattern from #1922.

- Add list_audit_logs / audit_log_stats engine methods. Both call
  get_bank_profile(create_if_missing=False) first, which runs
  _authenticate_tenant before any query, so the SQL is gated behind the
  same tenant auth every other op uses and scoped to the tenant schema.
- Move the audit response models into engine/audit.py so the engine can
  build and return them; HTTP handlers now just delegate.
- Add tenant-auth regression tests for both reads (invalid API key).

OpenAPI spec unchanged (model names/fields identical).

Closes #1923
2026-06-02 13:08:26 +02:00
Nicolò Boschi 99c7367fc0 perf(graph-maintenance): cast ANN seed embeddings once + add perf suite (#1919) (#1924)
The semantic-ANN relink pass in graph_maintenance was disproportionately
slow on small banks: ~50 seeds over a ~1k-unit bank took 1.5-3.7s and
dominated the whole job (97% of a 27s run).

Root cause: compute_semantic_links_ann stored seeds as text and computed
`mu.embedding <=> s.emb_text::vector` inside the LATERAL, re-parsing the
~5KB embedding string for every candidate row the probe touched
(seeds x bank_units text-parses per batch). Fix: cast each seed to
`vector` exactly once in a MATERIALIZED CTE. Measured ~25-48x faster on
small banks (per-batch ANN 1.47s -> 0.098s; medium job 27.3s -> 2.48s)
and ~2.4x on large banks, where the planner already auto-selects the
per-bank partial HNSW index. Behaviour is unchanged (identical results),
shared with retain Phase 3.

Also adds a `graph-maintenance` perf suite (populate via mock LLM + real
embeddings, delete 10% to enqueue relink victims, run the job, break
wall-clock down by probe) so this path is tracked in the periodic
benchmarks. large scale = 15k units to exercise the HNSW index path;
medium = 1k stays in the exact-scan regime.
2026-06-02 12:30:18 +02:00
Ben e4b50f8054 blog: Building a Hermes Coding Assistant on Windows That Remembers Your Codebase (#1912)
* blog: Running Hermes with Persistent Codebase Memory on Windows

Windows-specific companion to the Hermes coding-assistant codebase memory
post. Covers the native install path (no Docker, no WSL), the PYTHONUTF8
setup that mirrors the Windows CI smoke test, three coding workflows where
Hermes + Hindsight pays off on Windows, and the common Windows gotchas
(UTF-8 encoding, pg0 init time, long paths, Defender on the embedded
Postgres binary).

* blog: reframe Windows post around Nous's native-Windows announcement

- Retitle to "Hermes Agent on Windows: Add Persistent Codebase Memory
  with Hindsight" so the post reads as the news-companion piece.
- Lead with the Nous Research announcement (yesterday) and frame
  Hindsight as the memory layer that pairs with their freshly-shipped
  native Windows support.
- Tighten the Windows-gap paragraph and move the smoke-test callout
  later so it lands as "we were ready, now Hermes is too" rather than
  background scaffolding.
- Replace closing line to echo the news angle.
- Swap placeholder cover for the Windows x Hermes branded card.

* blog(windows): update cover image

* blog(windows): simplify setup to one command + mode picker

The actual Windows setup is just `hermes memory setup` plus the mode
selection prompt. Rewrite the section around the wizard's three modes
(Cloud / Local Embedded / Local External) instead of the old four-step
install dance, drop the pip-install pre-step (Local Embedded fetches
hindsight-embed via uvx automatically), and move the UTF-8 step out of
setup into the Gotchas section where it's self-contained. Also reframe
the "Local Mode" section as a mode-picker decision tree.

* blog(windows): swap cover image for coding post

* blog(windows): retitle to mirror the proven Hermes coding-post formula
2026-06-01 16:11:21 -04:00
Ben bddd22a852 blog: Hermes Agent on Windows — Set Up Persistent Memory with Hindsight (#1913)
Platform-neutral companion to the coding-focused Windows post. Same
news hook (Nous shipped Hermes native on Windows yesterday), same
one-command setup and three-mode picker, but framed around the broader
Hermes use cases: personal-assistant continuity, the Hermes Gateway
sharing one memory bank across Telegram/Discord/Slack, and long-running
research/writing projects.

Cross-links to the coding post via the public hindsight.vectorize.io URL
so the build-docs onBrokenLinks check doesn't fire before the coding
post merges.
2026-06-01 15:38:58 -04:00
Ben c032a74f17 feat(google-adk): add Hindsight integration for Google ADK (#1862)
* feat(google-adk): add Hindsight integration for Google ADK

Implements google.adk.memory.BaseMemoryService so Runner-driven agents
get persistent long-term memory automatically:

- HindsightMemoryService — retain on session end, recall on search_memory,
  with per-(app_name, user_id) bank scoping via a configurable template
- create_hindsight_tools — ADK FunctionTool wrappers for explicit
  hindsight_retain / hindsight_recall / hindsight_reflect

49/49 tests pass. CI job, release script, and changelog generator wired up.
Docs page + integrations.json + banner + sidebar entry added.

* feat(google-adk): add ADK icon from adk.dev

* test(google-adk): add end-to-end smoke script with real Gemini Runner

Exercises both integration patterns against the dev cloud:

- Phase 1: HindsightMemoryService (automatic memory) — Runner saves
  session A via add_session_to_memory; session B's agent calls
  load_memory which routes through search_memory and gets the facts back.
- Phase 2: create_hindsight_tools (explicit) — agent calls hindsight_retain
  directly in session C; session D's agent calls hindsight_recall.

Both phases pass live against api.dev.hindsight.vectorize.io with
gemini-2.0-flash.

* fix(google-adk): apply repo ruff format to smoke_runner.py
2026-06-01 13:39:43 -04:00
Nicolò Boschi a4650f2da5 chore(dev): one-shot dev setup script + fix control-plane production build (#1910)
* fix(control-plane): force NODE_ENV=production for production build

A globally-exported NODE_ENV=development (common in dev shells) overrides
Next.js's production default during `next build`, bundling React's development
build under the production server renderer. Static prerendering then crashes
with "Cannot read properties of null (reading 'useContext')" — even on the
built-in _global-error page.

Pin NODE_ENV=production for the build step so it is robust regardless of the
caller's shell. Docker is unaffected (it invokes next build directly in a clean
env).

* chore(dev): add one-shot dev environment setup script

Add scripts/dev/setup.sh: an idempotent bootstrap that installs the required
toolchains (uv/Python, Node/npm, Rust/cargo) when missing, creates .env,
configures git hooks, installs all Python + Node workspace deps, pre-downloads
the local ML models + tokenizer for offline use, and builds the TypeScript SDK
and Rust CLI. Flags: --skip-build, --skip-models, --with-docs, --force.

Document it in CONTRIBUTING.md as the recommended setup, keeping the manual
steps as a fallback.
2026-06-01 18:18:38 +02:00
1140 changed files with 82642 additions and 23424 deletions
+23 -1
View File
@@ -73,6 +73,11 @@ results = await asyncio.gather(*tasks, return_exceptions=True)
results = await asyncio.gather(*tasks, return_exceptions=True)
```
### API Layer & Data Access
- **No direct database access in `api/http.py`** (or any API router). HTTP handlers must not build SQL, call `acquire_with_retry` / `conn.fetch` / `conn.fetchrow` / `conn.execute`, or reference `fq_table(...)`. All persistence and queries live in `MemoryEngine` (the engine layer). A handler parses/validates the request, calls an engine method, shapes the HTTP response, and maps domain results to status codes (e.g. a `None` return → 404).
- **Authentication/tenancy is enforced inside each engine method, not assumed by the handler.** Every engine method that touches bank-scoped data must authenticate via `request_context` — typically `await self._authenticate_tenant(request_context)` (often indirectly through `get_bank_profile(...)`) — so the correct tenant schema is resolved before any query runs. Handlers must thread `request_context` through to the engine method; never query a tenant-scoped table assuming the schema is already set.
- Engine methods return typed models (Pydantic/dataclass), not raw dicts (see Type Safety).
### 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.
@@ -135,6 +140,13 @@ For each new or significantly changed function/endpoint/class:
Flag any new logic that lacks test coverage.
**LLM-behaviour changes need a real-LLM judge test, not MockLLM.** If the change alters how the model interprets a prompt — fact/observation extraction, `fact_type` (world/experience) classification, speaker attribution, instruction-following, prompt wording — there MUST be a test marked `pytest.mark.hs_llm_core` that runs the real pipeline and asserts via `tests.llm_judge.assert_meets_criteria` (not string/enum matching). Flag these as findings:
- A prompt/classification change verified only by MockLLM or string assertions (MockLLM echoes input — such tests pass spuriously). **Should fix.**
- A test that hard-asserts `fact_type == "world"/"experience"` (or other model-decided output) instead of judging it — non-deterministic, will flake across providers/runs. **Should fix** (move the classification check into the judge `criteria`; keep only genuinely deterministic structural asserts direct).
- Deterministic mechanics (prompt assembly, suppression/branching logic) that are covered *only* by a slow LLM test — these should also have fast non-LLM unit tests. **Note.**
See CLAUDE.md → Key Conventions → Testing for the full pattern.
### 7. Check API consistency
If any files in `hindsight-api-slim/hindsight_api/api/` were changed:
@@ -142,6 +154,12 @@ If any files in `hindsight-api-slim/hindsight_api/api/` were changed:
- Were the client SDKs regenerated? (`./scripts/generate-clients.sh`)
- Were the control plane proxy routes updated? (`hindsight-control-plane/src/app/api/`)
### 7b. Check API-layer data-access boundary
For each changed handler in `hindsight-api-slim/hindsight_api/api/` (e.g. `http.py`, `mcp.py`):
- **Flag any direct DB access in the handler** — `acquire_with_retry`, `conn.fetch` / `fetchrow` / `execute`, raw SQL strings, or `fq_table(...)`. These are a **must fix**: the query must be moved into a `MemoryEngine` method that returns a typed model, and the handler must call that method.
- **Verify authentication is enforced in the engine** — the handler must delegate to an engine method that authenticates via `request_context` (`_authenticate_tenant`, typically through `get_bank_profile`). A handler that reads/writes tenant-scoped data without an engine method enforcing auth is a **must fix** (tenant data could leak across schemas).
### 8. Check code comments
For each non-trivial change:
@@ -154,7 +172,8 @@ For each non-trivial change:
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.
- **Release process** — check that the integration name is in the `VALID_INTEGRATIONS` array in `scripts/release-integration.sh` AND in the `INTEGRATIONS` dict in `hindsight-dev/hindsight_dev/generate_changelog.py` (the changelog generator keeps its own list; a release fails at the changelog step if the name is missing there). If either is missing, flag it.
- **Docs gallery + sidebar entry** — the integration must have an entry in `hindsight-docs/src/data/integrations.json`. This file is the **single source of truth** that drives both the integrations gallery and the docs sidebar (the sidebar category is injected from it at render time across all docs versions). The entry needs an internal `/sdks/integrations/<slug>` `link` and a matching page at `hindsight-docs/docs-integrations/<slug>.md(x)`. The `hindsight-docs/scripts/check-integrations.mjs` build step enforces both directions — forward: every internal JSON entry has a doc page; reverse: every released tag (`integrations/<name>/vX.Y.Z`) appears in the JSON (private infra like `cloudflare-oauth-proxy` is in the script's `EXCLUDED` set). Flag any integration that is released (or being released) but missing from `integrations.json`, and any JSON entry without a doc page. Do **not** hand-edit `versioned_sidebars/*.json` to add integration links — they are positional placeholders filled from the JSON.
- **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
@@ -196,7 +215,10 @@ Present a clear summary organized by severity:
- Raw dict usage for structured data (including internal code)
- Multi-item tuple returns (including internal code)
- Missing tests for new endpoints
- Direct DB access (raw SQL / `acquire_with_retry` / `fq_table`) in an `api/` handler instead of a `MemoryEngine` method
- Tenant-scoped data accessed without authentication enforced in the engine (`_authenticate_tenant` / `get_bank_profile`)
- New integration missing tests, CI job, or release-integration.sh entry
- Released/added integration missing from `hindsight-docs/src/data/integrations.json`, or a JSON entry with no `docs-integrations/<slug>` page (fails the docs build via `check-integrations.mjs`)
- New PostgreSQL table missing from `BACKUP_TABLES` in `admin/cli.py` (silent data loss on restore)
**Should fix** — issues that hurt code quality:
+32 -2
View File
@@ -25,7 +25,7 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# 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.7
# HINDSIGHT_API_LLM_MODEL=MiniMax-M3 # or MiniMax-M2.7 for the previous generation
# Example: DeepSeek configuration (https://api.deepseek.com)
# HINDSIGHT_API_LLM_PROVIDER=deepseek
@@ -80,10 +80,23 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER=
# Embeddings Configuration (Optional - uses local by default)
# Provider: "local" (default), "tei", "openai", "cohere", "google", "openrouter", "zeroentropy", "litellm", or "litellm-sdk"
# Provider: "local" (default), "onnx", "tei", "openai", "cohere", "google", "openrouter", "zeroentropy", "litellm", or "litellm-sdk"
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
# For local provider:
# HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5
# For ONNX provider (local CPU embeddings without an Ollama/TEI sidecar):
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=onnx
# HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_ID=intfloat/multilingual-e5-small
# HINDSIGHT_API_EMBEDDINGS_ONNX_FILE=onnx/model.onnx
# HINDSIGHT_API_EMBEDDINGS_ONNX_DIMENSIONS=384
# HINDSIGHT_API_EMBEDDINGS_ONNX_MAX_TOKENS=512
# HINDSIGHT_API_EMBEDDINGS_ONNX_POOLING=mean
# HINDSIGHT_API_EMBEDDINGS_ONNX_NORMALIZE=true
# HINDSIGHT_API_EMBEDDINGS_ONNX_QUERY_PREFIX="query: "
# HINDSIGHT_API_EMBEDDINGS_ONNX_PASSAGE_PREFIX="passage: "
# Optional for local model paths or pre-downloaded artifacts:
# HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_PATH=/models/multilingual-e5-small/onnx/model.onnx
# HINDSIGHT_API_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH=/models/multilingual-e5-small
# Optional for China network / restricted HF access:
# HF_ENDPOINT=https://hf-mirror.com
# For TEI provider:
@@ -146,3 +159,20 @@ HINDSIGHT_API_LOG_LEVEL=info
# When set, visitors see a login page and must enter the key before
# accessing the dashboard or any /api/* routes (except /api/health).
# HINDSIGHT_CP_ACCESS_KEY=your-shared-secret-key
# Optional: Token the CP forwards to the dataplane admin API (/admin/*).
# Must match HINDSIGHT_API_ADMIN_TOKEN below. Leave unset for an open admin API.
# HINDSIGHT_CP_ADMIN_TOKEN=your-admin-token
# -----------------------------------------------------------------------------
# Admin surface (Optional, server-level)
# -----------------------------------------------------------------------------
# Enable the admin API (GET /admin/config) and the Control Plane /admin page.
# Off by default — the admin surface is invisible (404) until enabled.
# HINDSIGHT_API_ENABLE_ADMIN_API=true
# Optional: Require this bearer token for the admin API. When unset, the admin
# API is open (once enabled). When set, callers must send
# `Authorization: Bearer <token>`. Independent of the tenant API key.
# HINDSIGHT_API_ADMIN_TOKEN=your-admin-token
+2
View File
@@ -22,6 +22,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # fetch tags so check-released-integrations can see them
- uses: actions/setup-node@v6
with:
node-version: 20
+97
View File
@@ -23,7 +23,9 @@ on:
- retain
- recall
- recall-with-observations
- recall-temporal
- consolidation
- graph-maintenance
default: ""
locomo_conversations:
description: "LoComo conversation IDs (space-separated). Blank = curated set (conv-26 conv-30 conv-43)."
@@ -33,6 +35,18 @@ on:
description: "Skip LoComo job"
type: boolean
default: false
obs_skip:
description: "Skip observation-dedup benchmark job"
type: boolean
default: false
obs_dataset:
description: "Obs benchmark dataset substring (blank = English hermes transcript)."
type: string
default: ""
obs_fraction:
description: "Obs benchmark fraction (0-1] of each document to run."
type: string
default: "1.0"
ref:
description: "Git ref to test (branch, tag, or SHA). Defaults to main."
type: string
@@ -198,3 +212,86 @@ jobs:
PERF_DASHBOARD_TOKEN: ${{ secrets.PERF_DASHBOARD_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./scripts/benchmarks/publish-locomo-results.sh hindsight-dev/benchmarks/locomo/results/benchmark_results.json
obs:
# Observation-dedup quality benchmark: ingests a transcript, drains consolidation
# (serial SyncTaskBackend + embedded pg0 — no external DB / worker), and reports the
# near-duplicate observation rate. Real LLM via VertexAI, mirroring the LoComo job.
if: inputs.obs_skip != true
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
HINDSIGHT_API_ENABLE_OBSERVATIONS: "true"
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup GCP credentials
run: |
printf '%s' '${{ secrets.GCP_VERTEXAI_CREDENTIALS }}' > /tmp/gcp-credentials.json
PROJECT_ID=$(jq -r '.project_id' /tmp/gcp-credentials.json)
echo "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=$PROJECT_ID" >> $GITHUB_ENV
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Cache HuggingFace models
uses: actions/cache@v5
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api-slim/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api-slim
run: |
uv run --frozen --all-extras --index-strategy unsafe-best-match python -c "
from sentence_transformers import SentenceTransformer
print('Downloading embedding model...')
SentenceTransformer('BAAI/bge-small-en-v1.5')
print('Model downloaded successfully')
"
- name: Install hindsight-dev dependencies
run: |
cd hindsight-dev && uv sync --frozen --all-extras --index-strategy unsafe-best-match
- name: Run obs benchmark
# Default to the English hermes transcript at full fraction — a clean, deterministic
# consolidation-dedup signal (the Chinese variant adds a cross-lingual embedding
# confound). Override dataset/fraction via workflow_dispatch.
run: |
DATASET="${{ inputs.obs_dataset }}"
if [ -z "$DATASET" ]; then DATASET="hermes_session_2026-05-15_en"; fi
FRACTION="${{ inputs.obs_fraction }}"
if [ -z "$FRACTION" ]; then FRACTION="1.0"; fi
cd hindsight-dev
uv run python -m benchmarks.obs.obs_benchmark \
--dataset "$DATASET" --fraction "$FRACTION" --wipe-bank --output obs-results.json
- name: Upload obs results
if: always()
uses: actions/upload-artifact@v7
with:
name: obs-results-${{ github.sha }}
path: hindsight-dev/obs-results.json
retention-days: 90
- name: Publish obs to dashboard
if: success() && (github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'workflow_dispatch')
env:
PERF_DASHBOARD_TOKEN: ${{ secrets.PERF_DASHBOARD_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./scripts/benchmarks/publish-obs-results.sh hindsight-dev/obs-results.json
+21
View File
@@ -10,6 +10,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
id-token: write # for PyPI trusted publishing
contents: write # for creating GitHub releases (Obsidian plugin assets)
steps:
- uses: actions/checkout@v6
@@ -112,6 +113,26 @@ jobs:
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: npm run build
# ── Obsidian plugin — attach BRAT / community-store install assets ───────
# Obsidian plugins install from GitHub *release assets* (main.js,
# manifest.json, styles.css), not from npm — the npm publish below only
# gives us a versioned artifact. BRAT and the community store read these
# three files off the release for the tag.
- name: Attach Obsidian release assets
if: steps.type.outputs.type == 'typescript' && steps.info.outputs.integration == 'obsidian'
working-directory: ./hindsight-integrations/obsidian
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ steps.info.outputs.tag }}"
if gh release view "$TAG" >/dev/null 2>&1; then
gh release upload "$TAG" main.js manifest.json styles.css --clobber
else
gh release create "$TAG" main.js manifest.json styles.css \
--title "Obsidian plugin v${{ steps.info.outputs.version }}" \
--notes "Hindsight Obsidian plugin v${{ steps.info.outputs.version }}. Install via BRAT (point it at this release) or copy main.js/manifest.json/styles.css into <vault>/.obsidian/plugins/hindsight/."
fi
- name: Publish TypeScript package to npm
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
+419 -7
View File
@@ -34,25 +34,35 @@ jobs:
integrations-ai-sdk: ${{ steps.filter.outputs.integrations-ai-sdk }}
integrations-chat: ${{ steps.filter.outputs.integrations-chat }}
integrations-claude-code: ${{ steps.filter.outputs.integrations-claude-code }}
integrations-cline: ${{ steps.filter.outputs.integrations-cline }}
integrations-codex: ${{ steps.filter.outputs.integrations-codex }}
integrations-cursor-cli: ${{ steps.filter.outputs.integrations-cursor-cli }}
integrations-crewai: ${{ steps.filter.outputs.integrations-crewai }}
integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }}
integrations-pydantic-ai: ${{ steps.filter.outputs.integrations-pydantic-ai }}
integrations-ag2: ${{ steps.filter.outputs.integrations-ag2 }}
integrations-autogen: ${{ steps.filter.outputs.integrations-autogen }}
integrations-langgraph: ${{ steps.filter.outputs.integrations-langgraph }}
integrations-llamaindex: ${{ steps.filter.outputs.integrations-llamaindex }}
integrations-paperclip: ${{ steps.filter.outputs.integrations-paperclip }}
integrations-opencode: ${{ steps.filter.outputs.integrations-opencode }}
integrations-n8n: ${{ steps.filter.outputs.integrations-n8n }}
integrations-cloudflare-oauth-proxy: ${{ steps.filter.outputs.integrations-cloudflare-oauth-proxy }}
integrations-superagent: ${{ steps.filter.outputs.integrations-superagent }}
integrations-lockfiles: ${{ steps.filter.outputs.integrations-lockfiles }}
integrations-openai-agents: ${{ steps.filter.outputs.integrations-openai-agents }}
integrations-pipecat: ${{ steps.filter.outputs.integrations-pipecat }}
integrations-agentcore: ${{ steps.filter.outputs.integrations-agentcore }}
integrations-smolagents: ${{ steps.filter.outputs.integrations-smolagents }}
integrations-claude-agent-sdk: ${{ steps.filter.outputs.integrations-claude-agent-sdk }}
integrations-dify: ${{ steps.filter.outputs.integrations-dify }}
integrations-gemini-spark: ${{ steps.filter.outputs.integrations-gemini-spark }}
integrations-vapi: ${{ steps.filter.outputs.integrations-vapi }}
integrations-flowise: ${{ steps.filter.outputs.integrations-flowise }}
integrations-google-adk: ${{ steps.filter.outputs.integrations-google-adk }}
integrations-obsidian: ${{ steps.filter.outputs.integrations-obsidian }}
integrations-omo: ${{ steps.filter.outputs.integrations-omo }}
integrations-haystack: ${{ steps.filter.outputs.integrations-haystack }}
tools-agent-sdk: ${{ steps.filter.outputs.tools-agent-sdk }}
integrations-roo-code: ${{ steps.filter.outputs.integrations-roo-code }}
dev: ${{ steps.filter.outputs.dev }}
@@ -96,6 +106,9 @@ jobs:
docs:
- 'hindsight-docs/**'
- '*.md'
# Integration changes can add/rename integrations, which the docs
# build's integrations check validates against integrations.json.
- 'hindsight-integrations/**'
embed:
- 'hindsight-embed/**'
all-npm:
@@ -114,8 +127,12 @@ jobs:
- 'hindsight-integrations/chat/**'
integrations-claude-code:
- 'hindsight-integrations/claude-code/**'
integrations-cline:
- 'hindsight-integrations/cline/**'
integrations-codex:
- 'hindsight-integrations/codex/**'
integrations-cursor-cli:
- 'hindsight-integrations/cursor-cli/**'
integrations-crewai:
- 'hindsight-integrations/crewai/**'
integrations-litellm:
@@ -124,8 +141,14 @@ jobs:
- 'hindsight-integrations/pydantic-ai/**'
integrations-ag2:
- 'hindsight-integrations/ag2/**'
integrations-autogen:
- 'hindsight-integrations/autogen/**'
integrations-langgraph:
- 'hindsight-integrations/langgraph/**'
integrations-llamaindex:
- 'hindsight-integrations/llamaindex/**'
integrations-haystack:
- 'hindsight-integrations/haystack/**'
integrations-paperclip:
- 'hindsight-integrations/paperclip/**'
integrations-opencode:
@@ -134,6 +157,8 @@ jobs:
- 'hindsight-integrations/n8n/**'
integrations-cloudflare-oauth-proxy:
- 'hindsight-integrations/cloudflare-oauth-proxy/**'
integrations-superagent:
- 'hindsight-integrations/superagent/**'
integrations-lockfiles:
- 'hindsight-integrations/*/package-lock.json'
- 'hindsight-integrations/*/package.json'
@@ -146,6 +171,8 @@ jobs:
- 'hindsight-integrations/agentcore/**'
integrations-smolagents:
- 'hindsight-integrations/smolagents/**'
integrations-claude-agent-sdk:
- 'hindsight-integrations/claude-agent-sdk/**'
integrations-dify:
- 'hindsight-integrations/dify/**'
integrations-gemini-spark:
@@ -154,6 +181,12 @@ jobs:
- 'hindsight-integrations/vapi/**'
integrations-flowise:
- 'hindsight-integrations/flowise/**'
integrations-google-adk:
- 'hindsight-integrations/google-adk/**'
integrations-obsidian:
- 'hindsight-integrations/obsidian/**'
integrations-omo:
- 'hindsight-integrations/omo/**'
tools-agent-sdk:
- 'hindsight-tools/hindsight-agent-sdk/**'
integrations-roo-code:
@@ -414,6 +447,58 @@ jobs:
working-directory: ./hindsight-integrations/claude-code
run: python -m pytest tests/ -v
test-omo-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-omo == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run tests
working-directory: ./hindsight-integrations/omo
run: python -m pytest tests/ -v
test-cline-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-cline == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run tests
working-directory: ./hindsight-integrations/cline
run: python -m pytest tests/ -v
test-codex-integration:
needs: [detect-changes]
if: >-
@@ -440,6 +525,32 @@ jobs:
working-directory: ./hindsight-integrations/codex
run: python -m pytest tests/ -v
test-cursor-cli-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-cursor-cli == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run tests
working-directory: ./hindsight-integrations/cursor-cli
run: python -m pytest tests/ -v
build-ai-sdk-integration:
needs: [detect-changes]
if: >-
@@ -728,6 +839,43 @@ jobs:
working-directory: ./hindsight-integrations/pipecat
run: uv run pytest tests -v
test-google-adk-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-google-adk == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build google-adk integration
working-directory: ./hindsight-integrations/google-adk
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/google-adk
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/google-adk
run: uv run pytest tests -v
test-gemini-spark-integration:
needs: [detect-changes]
if: >-
@@ -776,17 +924,28 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
python-version-file: ".python-version"
- name: Install pytest
run: pip install pytest
- name: Build roo-code integration
working-directory: ./hindsight-integrations/roo-code
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/roo-code
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/roo-code
run: python -m pytest tests/ -v
run: uv run pytest tests -v
build-control-plane:
needs: [detect-changes]
@@ -868,6 +1027,7 @@ jobs:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
fetch-depth: 0 # fetch tags so check-released-integrations can see them
- name: Set up Node.js
uses: actions/setup-node@v6
@@ -876,6 +1036,12 @@ jobs:
cache: 'npm'
cache-dependency-path: package-lock.json
# Fail fast before the (slow) build: every integrations.json entry must have a
# doc page, and every released integration tag must be in integrations.json.
# Needs no npm install (pure Node) and uses the tags fetched above.
- name: Check integrations (single source of truth)
run: node hindsight-docs/scripts/check-integrations.mjs
- name: Install dependencies
run: npm ci --workspace=hindsight-docs
@@ -2761,6 +2927,45 @@ jobs:
working-directory: ./hindsight-integrations/ag2
run: uv run pytest tests -v
test-autogen-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-autogen == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build autogen integration
working-directory: ./hindsight-integrations/autogen
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/autogen
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/autogen
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-smolagents-integration:
needs: [detect-changes]
if: >-
@@ -2864,6 +3069,41 @@ jobs:
working-directory: ./hindsight-integrations/flowise
run: npm test
test-obsidian-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-obsidian == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install dependencies
working-directory: ./hindsight-integrations/obsidian
run: npm install --no-audit --no-fund
- name: Type check
working-directory: ./hindsight-integrations/obsidian
run: npx tsc --noEmit
- name: Build
working-directory: ./hindsight-integrations/obsidian
run: npm run build
- name: Run tests
working-directory: ./hindsight-integrations/obsidian
run: npm test
test-crewai-integration:
needs: [detect-changes]
if: >-
@@ -2939,6 +3179,45 @@ jobs:
working-directory: ./hindsight-integrations/vapi
run: uv run pytest tests -v
test-superagent-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-superagent == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build superagent integration
working-directory: ./hindsight-integrations/superagent
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/superagent
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/superagent
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs live Hindsight + provider keys and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-litellm-integration:
needs: [detect-changes]
if: >-
@@ -2974,7 +3253,9 @@ jobs:
- name: Run tests
working-directory: ./hindsight-integrations/litellm
run: uv run pytest tests -v
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs live Hindsight + provider keys and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-pydantic-ai-integration:
needs: [detect-changes]
@@ -3013,6 +3294,45 @@ jobs:
working-directory: ./hindsight-integrations/pydantic-ai
run: uv run pytest tests -v
test-langgraph-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-langgraph == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build langgraph integration
working-directory: ./hindsight-integrations/langgraph
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/langgraph
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/langgraph
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-llamaindex-integration:
needs: [detect-changes]
if: >-
@@ -3048,7 +3368,48 @@ jobs:
- name: Run tests
working-directory: ./hindsight-integrations/llamaindex
run: uv run pytest tests -v
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-haystack-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-haystack == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build haystack integration
working-directory: ./hindsight-integrations/haystack
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/haystack
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/haystack
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-openai-agents-integration:
needs: [detect-changes]
@@ -3085,7 +3446,47 @@ jobs:
- name: Run tests
working-directory: ./hindsight-integrations/openai-agents
run: uv run pytest tests -v
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-claude-agent-sdk-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-claude-agent-sdk == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build claude-agent-sdk integration
working-directory: ./hindsight-integrations/claude-agent-sdk
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/claude-agent-sdk
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/claude-agent-sdk
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-agentcore-integration:
needs: [detect-changes]
@@ -3950,16 +4351,20 @@ jobs:
- build-openclaw-integration
- smoke-openclaw-install
- test-claude-code-integration
- test-cline-integration
- test-codex-integration
- test-cursor-cli-integration
- build-ai-sdk-integration
- test-ai-sdk-integration-deno
- test-opencode-integration
- test-omo-integration
- test-cloudflare-oauth-proxy-integration
- build-chat-integration
- test-paperclip-integration
- test-pipecat-integration
- test-gemini-spark-integration
- test-vapi-integration
- test-google-adk-integration
- test-roo-code-integration
- build-control-plane
- build-docs
@@ -3980,19 +4385,26 @@ jobs:
- test-openclaw-integration
- test-integration
- test-ag2-integration
- test-autogen-integration
- test-smolagents-integration
- test-dify-integration
- test-flowise-integration
- test-obsidian-integration
- test-crewai-integration
- test-langgraph-integration
- test-superagent-integration
- test-litellm-integration
- test-pydantic-ai-integration
- test-llamaindex-integration
- test-openai-agents-integration
- test-agentcore-integration
- test-haystack-integration
- test-pip-slim
- test-embed
- test-embed-windows
- test-hindsight-all
- test-hindsight-agent-sdk
- test-claude-agent-sdk-integration
- test-doc-examples
- test-upgrade
- verify-generated-files
+2 -1
View File
@@ -59,4 +59,5 @@ hindsight-integrations/_drafts/
# Changelog is now tracked in hindsight-docs/src/pages/changelog.md
# CHANGELOG.md
blog-post*
blog-post*
.worktrees/
+24
View File
@@ -220,6 +220,30 @@ migration file dispatches through `run_for_dialect`, which calls either
**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.
### Testing
Most tests are deterministic (MockLLM, pure functions) — assert directly.
**Tests that verify LLM behaviour use a real LLM + an LLM-as-judge.** When the thing under test is *how the model interprets a prompt* (classification, attribution, dimension preservation, instruction-following), MockLLM can't simulate it and exact string/enum asserts flake across providers and runs. Use this pattern instead:
1. Mark the test module `pytestmark = pytest.mark.hs_llm_core` (single-provider; CI runs it in the core-LLM job). Use `hs_llm_mat` only for provider-matrix acceptance tests.
2. Call the real pipeline (`LLMConfig.from_env()`, `_get_raw_config()`), e.g. `extract_facts_from_text(...)`.
3. Assert with the judge, not string matching:
```python
from tests.llm_judge import assert_meets_criteria
facts_summary = "\n".join(f"- [{f.fact_type}] {f.fact}" for f in facts)
await assert_meets_criteria(
response=facts_summary,
criteria="The first-person user statements are classified 'world' and attributed to the user, not the agent.",
context="What the input said and who was speaking.",
)
```
Rules of thumb:
- **Judge anything non-deterministic** — including `fact_type` classification and speaker attribution. Do NOT hard-assert `fact_type == "..."`; pass a `[fact_type] fact` summary to the judge instead. Structural facts that ARE deterministic (counts, presence of a field, that a substring was injected into a prompt) stay as direct asserts in fast unit tests.
- **Split the test surface**: cover the deterministic mechanics (prompt assembly, suppression logic) with fast non-LLM unit tests, and the model-following behaviour with one `hs_llm_core` judge test. (Example pair: `test_narrator_resolution.py` + `test_narrator_context_override.py`.)
- The judge model is independent of the test provider (defaults to Gemini); never judge with the same call you're testing.
### Memory Banks
- Each bank is an isolated memory store (like a "brain" for one user/agent)
- Banks have dispositions (skepticism, literalism, empathy traits 1-5) affecting reflect
+25 -2
View File
@@ -9,13 +9,36 @@ Thanks for your interest in contributing to Hindsight!
git clone [email protected]:vectorize-io/hindsight.git
cd hindsight
```
2. Set up your environment:
2. Bootstrap your dev environment in one shot:
```bash
./scripts/dev/setup.sh
```
This is idempotent (safe to re-run) and gets you ready to develop, including
offline. It:
- installs the required toolchains if missing (uv/Python, Node/npm, Rust/cargo),
- creates `.env` from `.env.example` (remember to add your LLM API key),
- configures git hooks,
- installs all Python and Node workspace dependencies,
- pre-downloads the local ML models + tokenizer so the API runs offline,
- builds the TypeScript SDK and the Rust CLI.
Useful flags: `--skip-build` (deps only), `--skip-models` (skip ML model
download), `--with-docs` (also build the docs site), `--force` (rebuild
artifacts). Docker image builds are out of scope. Run
`./scripts/dev/setup.sh --help` for details.
### Manual setup
If you'd rather set things up by hand instead of running the script above:
1. Set up your environment:
```bash
cp .env.example .env
```
Edit the .env to add LLM API key and config as required
3. Install dependencies:
2. Install dependencies:
```bash
# Python dependencies
uv sync --directory hindsight-api/
+2 -2
View File
@@ -62,9 +62,9 @@ If you need more control over how and when your agent stores and recalls memorie
```bash
export OPENAI_API_KEY=sk-xxx
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
docker run -it --pull always --name hindsight --restart unless-stopped -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
-v hindsight-data:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
+2
View File
@@ -50,6 +50,8 @@ WORKDIR /app/api
# Sync dependencies using appropriate extras based on INCLUDE_LOCAL_MODELS
# local-ml: torch, sentence-transformers, transformers, einops, flashrank, mlx (optional)
# embedded-db: pg0-embedded (always included for embedded PostgreSQL support)
# ONNX Runtime embeddings are intentionally not bundled into the official
# standalone image; install the local-onnx extra in custom images when needed.
RUN if [ "$INCLUDE_LOCAL_MODELS" = "true" ]; then \
uv sync --extra local-ml --extra embedded-db; \
else \
+45
View File
@@ -43,11 +43,56 @@ check_pg0_data_integrity() {
return 0
}
# =============================================================================
# Embedded pg0 writability pre-check (#1483)
#
# The container runs as the unprivileged `hindsight` user (UID 1000). When the
# pg0 data directory is a host bind mount (e.g. `-v $HOME/dir:/home/hindsight/.pg0`)
# that is not owned by UID 1000 — the default on macOS Docker Desktop and most
# non-1000 Linux hosts — pg0 fails with the opaque "Permission denied (os error
# 13)". We cannot chown it ourselves without root (and the image is deliberately
# rootless), so we surface an actionable message up front instead.
#
# Docker *named* volumes are seeded with the image directory's ownership (UID
# 1000) on first use, so they avoid this entirely — hence the named-volume
# recommendation below and in the README.
# =============================================================================
check_pg0_writable() {
local pg0_data_dir="$1"
# Only relevant for embedded pg0; an external database doesn't use this dir.
if [ -n "${HINDSIGHT_API_DATABASE_URL:-}" ]; then
return 0
fi
mkdir -p "$pg0_data_dir" 2>/dev/null || true
if touch "$pg0_data_dir/.hindsight-write-test" 2>/dev/null; then
rm -f "$pg0_data_dir/.hindsight-write-test" 2>/dev/null || true
return 0
fi
echo "❌ The embedded database directory $pg0_data_dir is not writable by this container (UID $(id -u))."
echo ""
echo " A host directory was bind-mounted but is not owned by the container user (UID 1000)."
echo " Hindsight runs rootless and cannot fix this for you. Choose one:"
echo ""
echo " • Recommended — use a Docker named volume (auto-owned by the container):"
echo " -v hindsight-data:/home/hindsight/.pg0"
echo ""
echo " • Or keep the host path and run as your host user, chowning it to match:"
echo " sudo chown -R \$(id -u):\$(id -g) <host-directory>"
echo " docker run --user \$(id -u):\$(id -g) -e HOME=/home/hindsight ..."
echo ""
echo " See https://github.com/vectorize-io/hindsight/issues/1483"
return 1
}
if [ "${HINDSIGHT_START_ALL_SOURCE_ONLY:-false}" = "true" ]; then
return 0 2>/dev/null || exit 0
fi
check_pg0_data_integrity "${HOME}/.pg0"
check_pg0_writable "${HOME}/.pg0" || exit 1
# Service flags (default to true if not set)
ENABLE_API="${HINDSIGHT_ENABLE_API:-true}"
+49 -1
View File
@@ -8,7 +8,7 @@ source "$SCRIPT_DIR/start-all.sh"
unset HINDSIGHT_START_ALL_SOURCE_ONLY
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
trap 'chmod -R u+rwx "$TMP_DIR" 2>/dev/null || true; rm -rf "$TMP_DIR"' EXIT
assert_contains() {
local output="$1"
@@ -71,3 +71,51 @@ nonempty_output="$(check_pg0_data_integrity "$TMP_DIR/nonempty")"
assert_contains "$nonempty_output" "WARNING: pg0 data directory exists"
echo "start-all pg0 integrity checks passed"
# =============================================================================
# check_pg0_writable (#1483)
# These rely on filesystem permissions, which root bypasses; skip under root.
# =============================================================================
if [ "$(id -u)" != "0" ]; then
# Writable directory: returns 0, prints nothing, leaves no artifact behind.
mkdir -p "$TMP_DIR/writable"
writable_output="$(check_pg0_writable "$TMP_DIR/writable")"
assert_empty "$writable_output"
if [ -e "$TMP_DIR/writable/.hindsight-write-test" ]; then
echo "check_pg0_writable left its write-test file behind"
exit 1
fi
# Non-writable directory: returns 1 with actionable guidance.
mkdir -p "$TMP_DIR/readonly"
chmod 000 "$TMP_DIR/readonly"
set +e
readonly_output="$(check_pg0_writable "$TMP_DIR/readonly" 2>&1)"
readonly_rc=$?
set -e
chmod 755 "$TMP_DIR/readonly"
if [ "$readonly_rc" -eq 0 ]; then
echo "check_pg0_writable should fail on a non-writable directory"
exit 1
fi
assert_contains "$readonly_output" "not writable"
assert_contains "$readonly_output" "hindsight-data:/home/hindsight/.pg0"
assert_contains "$readonly_output" "--user"
# External database configured: skip the check regardless of dir perms.
mkdir -p "$TMP_DIR/extdb"
chmod 000 "$TMP_DIR/extdb"
set +e
HINDSIGHT_API_DATABASE_URL="postgres://x" check_pg0_writable "$TMP_DIR/extdb" >/dev/null 2>&1
extdb_rc=$?
set -e
chmod 755 "$TMP_DIR/extdb"
if [ "$extdb_rc" -ne 0 ]; then
echo "check_pg0_writable should skip when an external database is configured"
exit 1
fi
echo "start-all pg0 writability checks passed"
else
echo "⚠️ Running as root; skipping pg0 writability checks (permissions are bypassed)."
fi
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.7.1
appVersion: "0.7.1"
version: 0.8.0
appVersion: "0.8.0"
keywords:
- ai
- memory
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.7.1",
"version": "0.8.0",
"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",
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-all-slim"
version = "0.7.1"
version = "0.8.0"
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim==0.7.1",
"hindsight-api-slim==0.8.0",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
]
+3 -3
View File
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.7.1"
version = "0.8.0"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim[all]==0.7.1",
"hindsight-api-slim[all]==0.8.0",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
]
@@ -21,7 +21,7 @@ hindsight-embed = { workspace = true }
[project.optional-dependencies]
local-llm = [
"hindsight-api-slim[local-llm]==0.7.1",
"hindsight-api-slim[local-llm]==0.8.0",
]
test = [
"pytest>=7.0.0",
+1 -1
View File
@@ -99,7 +99,7 @@ hindsight-api
## Docker
```bash
docker run --rm -it -p 8888:8888 \
docker run -it --name hindsight --restart unless-stopped -p 8888:8888 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
+1 -1
View File
@@ -53,4 +53,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.7.1"
__version__ = "0.8.0"
@@ -54,23 +54,20 @@ _INDEX_TYPE_KEYWORDS = {
# pre-dispatcher code (internal benchmarks tuned around our embedding count
# and recall floor; see the link_utils / pool init call sites for the
# latency-vs-recall framing).
# - vchord exposes vchordrq.probes (no default; see VectorChord issue #392)
# and vchordrq.epsilon (default 1.9). probes = 10 / 30 are starting
# defaults pending a workload-specific sweep — vchordrq's recall curve
# shape differs from HNSW's, so the pgvector numbers don't translate
# directly. Revisit with a per-cluster benchmark once we have production
# recall data; until then these are deliberately conservative on the
# high-recall path. We leave epsilon at its default; tightening it is a
# separate trade-off.
# - vchord exposes vchordrq.probes, but its shape must match the index's
# build.internal.lists hierarchy. VectorChord 1.1 added per-index fallback
# parameters for this reason: a session GUC overrides every vchordrq index,
# and a single value can be invalid for listless or mixed-layout indexes.
# Hindsight's built-in vchord clause does not set lists, so the safe default
# is no session-level probe override; deployments that partition vchordrq
# indexes should attach probes to the index storage parameters instead.
# - pgvectorscale / pg_diskann / scann do not expose an equivalent per-statement
# knob in the engine today, so the dispatcher returns no statements for them.
_ANN_TUNING_LOW_LATENCY: dict[str, tuple[tuple[str, str], ...]] = {
"pgvector": (("hnsw.ef_search", "60"),),
"vchord": (("vchordrq.probes", "10"),),
}
_ANN_TUNING_HIGH_RECALL: dict[str, tuple[tuple[str, str], ...]] = {
"pgvector": (("hnsw.ef_search", "200"),),
"vchord": (("vchordrq.probes", "30"),),
}
_EXTENSION_INSTALL_SQL = {
@@ -17,7 +17,9 @@ import asyncpg
import typer
from ..config import DEFAULT_DATABASE_SCHEMA, HindsightConfig
from ..engine.memory_engine import _current_schema
from ..engine.schema import fq_table_explicit as _fq_table
from ..engine.transfer import export_bank
from ..extensions import TenantExtension, load_extension
from ..pg0 import parse_pg0_url, resolve_database_url
@@ -50,18 +52,38 @@ BACKUP_TABLES = [
"unit_entities",
"entity_cooccurrences",
"memory_links",
"observation_history",
"mental_models",
"mental_model_history",
"directives",
"async_operations",
"webhooks",
"file_storage",
"audit_log",
"llm_requests",
"graph_maintenance_queue",
]
MANIFEST_VERSION = "1"
async def _admin_connect(db_url: str) -> asyncpg.Connection:
"""Open a raw asyncpg connection to an admin DB URL.
``resolve_database_url`` handles both plain ``postgres://`` (passthrough) and
``pg0://`` (boots the embedded server and returns its real libpq URL), so this
is the only step needed to connect. JSON codecs are registered so ``jsonb``
columns decode to Python objects (used by the export row dumps).
"""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
conn = await asyncpg.connect(await resolve_database_url(db_url))
for type_name in ("json", "jsonb"):
await conn.set_type_codec(type_name, encoder=json.dumps, decoder=json.loads, schema="pg_catalog")
return conn
async def _backup(database_url: str, output_path: Path, schema: str = "public") -> dict[str, Any]:
"""Backup all tables to a zip file using binary COPY protocol."""
conn = await asyncpg.connect(database_url)
@@ -330,6 +352,123 @@ def run_db_migration(
typer.echo(f"Database migrations completed successfully for {len(schemas)} schema(s)")
async def _run_export_bank(db_url: str, bank_id: str, output: Path, schema: str, include_history: bool) -> int:
"""Export a whole bank to a ZIP archive."""
conn = await _admin_connect(db_url)
try:
# export_bank resolves table names via fq_table (the _current_schema
# contextvar); set it so the raw connection targets the right schema.
_current_schema.set(schema)
data = await export_bank(conn, bank_id, include_history=include_history)
finally:
await conn.close()
output.write_bytes(data)
return len(data)
@app.command(name="export-bank")
def export_bank_command(
bank_id: str = typer.Option(..., "--bank", "-b", help="Bank id to export."),
output: Path = typer.Option(..., "--output", "-o", help="Path to write the .zip archive."),
schema: str | None = typer.Option(
None,
"--schema",
"-s",
help="Database schema the bank lives in. Defaults to the configured base schema.",
),
include_history: bool = typer.Option(
False,
"--include-history",
help="Also export operational history (audit_log, llm_requests). Off by default.",
),
):
"""Export an entire bank to a portable ZIP (no embeddings — regenerated on import).
Carries documents, facts, observations, bank config, mental models, directives
and webhooks so the bank can be imported into a new instance configured with a
different embedding model / vector / text-search backend.
"""
config = HindsightConfig.from_env()
if not config.database_url:
typer.echo("Error: Database URL not configured.", err=True)
typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True)
raise typer.Exit(1)
target_schema = schema or config.database_schema or DEFAULT_DATABASE_SCHEMA
typer.echo(f"Exporting bank '{bank_id}' from schema '{target_schema}'...")
size = asyncio.run(_run_export_bank(config.database_url, bank_id, output, target_schema, include_history))
typer.echo(f"Exported bank '{bank_id}' to {output} ({size} bytes)")
async def _run_import_bank(archive_path: Path, schema: str, target_bank_id: str | None, include_history: bool):
"""Boot a MemoryEngine (for the target's embedding model) and restore a bank archive."""
# MemoryEngine is heavy (loads embeddings); import it lazily so other admin
# commands don't pay for it. _current_schema is imported at module top.
from ..engine.memory_engine import MemoryEngine
from ..models import RequestContext
archive_bytes = archive_path.read_bytes()
# run_migrations=True so a fresh target instance is provisioned at this
# instance's embedding dimension / vector / text-search backend before restore.
engine = MemoryEngine(run_migrations=True)
await engine.initialize()
try:
_current_schema.set(schema)
context = RequestContext(internal=True, user_initiated=True)
return await engine.import_bank_async(
archive_bytes,
context,
target_bank_id=target_bank_id,
include_history=include_history,
)
finally:
await engine.close()
@app.command(name="import-bank")
def import_bank_command(
archive: Path = typer.Option(..., "--archive", "-a", help="Path to the .zip produced by export-bank."),
schema: str | None = typer.Option(
None, "--schema", "-s", help="Target schema. Defaults to the configured base schema."
),
target_bank: str | None = typer.Option(
None, "--target-bank", help="Override the bank id (defaults to the archive's source bank)."
),
include_history: bool = typer.Option(
False, "--include-history", help="Also restore operational history if present in the archive."
),
):
"""Restore a whole bank from an export-bank archive into THIS instance.
Re-embeds facts with this instance's configured embedding model and rebuilds
links and indexes — the import half of a cross-instance migration. Run against
an instance configured with the desired embedding / vector / text-search backend.
The target bank must not already exist (import restores a whole bank, not a merge).
"""
config = HindsightConfig.from_env()
if not config.database_url:
typer.echo("Error: Database URL not configured.", err=True)
typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True)
raise typer.Exit(1)
target_schema = schema or config.database_schema or DEFAULT_DATABASE_SCHEMA
typer.echo(f"Importing bank archive '{archive}' into schema '{target_schema}'...")
result = asyncio.run(_run_import_bank(archive, target_schema, target_bank, include_history))
typer.echo(
f"Imported bank '{result.bank_id}': {result.documents_imported} doc(s), "
f"{result.facts_imported} fact(s), {result.observations_imported} observation(s), "
f"{result.mental_models_imported} mental model(s), "
f"{result.mental_model_history_imported} mm-history row(s), {result.directives_imported} directive(s), "
f"{result.webhooks_imported} webhook(s), {result.history_rows_imported} history row(s)"
)
async def _decommission_worker(db_url: str, worker_id: str, schema: str = "public") -> int:
"""Release all tasks owned by a worker, setting them back to pending status."""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
@@ -0,0 +1,253 @@
"""Move mental-model and observation history into dedicated tables.
Both histories were accumulated in a single JSONB/CLOB ``history`` column
(``mental_models.history`` and ``memory_units.history``), appended to on every
update. That design has two problems:
1. **Unbounded growth on observations.** The observation write path appended a
snapshot on every update with no cap at all, so a frequently-reinforced
observation grew its ``history`` array until it crossed Postgres's hard 256MB
jsonb limit (SQLSTATE 54000), after which every further UPDATE failed and the
row was stuck.
2. **Wrong-axis cap on mental models.** The mental-model cap bounded the *number*
of entries (50), not their *size* — a single large reflect snapshot could
still blow the budget — and rewrote the whole array (plus TOAST) on every
refresh, defeating HOT updates.
This migration creates one row per history entry in two dedicated tables, with
an index that makes "most recent N for this item" cheap, then drops the old
columns. The cap is now enforced at write time as a bounded DELETE of the
oldest over-cap rows (see config ``*_HISTORY_MAX_ENTRIES``).
Revision ID: a7b8c9d0e1f2
Revises: d3e4f5a6b7c8
Create Date: 2026-06-05
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a7b8c9d0e1f2"
down_revision: str | Sequence[str] | None = "d3e4f5a6b7c8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
# ---------------------------------------------------------------------------
# PostgreSQL
# ---------------------------------------------------------------------------
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
# Both tables share the same shape: surrogate id, FK to the parent, bank_id,
# the snapshot payload as a single JSONB ``content`` blob, and changed_at.
# The payload is per-row (one change per row) so it stays small — this is NOT
# the old single-column-grows-forever design; growth is bounded by row count
# plus the write-time cap. Folding the previous_* fields into one JSONB keeps
# the schema dialect-simple (no array columns) and flexible.
# --- mental_model_history -------------------------------------------------
# content: {"previous_content": ..., "previous_reflect_response": {...}}
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}mental_model_history (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
mental_model_id VARCHAR(64) NOT NULL,
bank_id VARCHAR(64) NOT NULL,
content JSONB NOT NULL,
changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
FOREIGN KEY (mental_model_id, bank_id)
REFERENCES {schema}mental_models(id, bank_id) ON DELETE CASCADE
)
"""
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_mm_history_model "
f"ON {schema}mental_model_history (bank_id, mental_model_id, changed_at DESC, id DESC)"
)
# --- observation_history --------------------------------------------------
# content: {"previous_text", "previous_tags", "previous_occurred_start",
# "previous_occurred_end", "previous_mentioned_at", "new_source_memory_ids"}
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}observation_history (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
observation_id UUID NOT NULL,
bank_id VARCHAR(64) NOT NULL,
content JSONB NOT NULL,
changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
FOREIGN KEY (observation_id)
REFERENCES {schema}memory_units(id) ON DELETE CASCADE
)
"""
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_observation_history_obs "
f"ON {schema}observation_history (observation_id, changed_at DESC, id DESC)"
)
# --- backfill mental models ----------------------------------------------
# Explode each row's history array into rows, preserving chronological order
# via WITH ORDINALITY so the IDENTITY id tie-breaks oldest->newest correctly.
# changed_at is promoted to its own column; the rest of the element becomes
# ``content`` (the ``- 'changed_at'`` strips the now-redundant key).
op.execute(
f"""
INSERT INTO {schema}mental_model_history (mental_model_id, bank_id, content, changed_at)
SELECT mm.id, mm.bank_id,
e - 'changed_at',
COALESCE(NULLIF(e->>'changed_at', '')::timestamptz, now())
FROM {schema}mental_models mm
CROSS JOIN LATERAL jsonb_array_elements(mm.history) WITH ORDINALITY a(e, ord)
WHERE mm.history IS NOT NULL
AND jsonb_typeof(mm.history) = 'array'
AND jsonb_array_length(mm.history) > 0
ORDER BY mm.id, mm.bank_id, ord
"""
)
# --- backfill observations -----------------------------------------------
op.execute(
f"""
INSERT INTO {schema}observation_history (observation_id, bank_id, content, changed_at)
SELECT mu.id, mu.bank_id,
e - 'changed_at',
COALESCE(NULLIF(e->>'changed_at', '')::timestamptz, now())
FROM {schema}memory_units mu
CROSS JOIN LATERAL jsonb_array_elements(mu.history) WITH ORDINALITY a(e, ord)
WHERE mu.fact_type = 'observation'
AND mu.history IS NOT NULL
AND jsonb_typeof(mu.history) = 'array'
AND jsonb_array_length(mu.history) > 0
ORDER BY mu.id, ord
"""
)
# --- drop the legacy columns ---------------------------------------------
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS history")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS history")
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
# Re-add the columns (empty — historical content is not reconstructed back
# into the array form; the dedicated tables are dropped below).
op.execute(f"ALTER TABLE {schema}mental_models ADD COLUMN IF NOT EXISTS history JSONB DEFAULT '[]'::jsonb")
op.execute(f"ALTER TABLE {schema}memory_units ADD COLUMN IF NOT EXISTS history JSONB DEFAULT '[]'::jsonb")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_observation_history_obs")
op.execute(f"DROP TABLE IF EXISTS {schema}observation_history")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_mm_history_model")
op.execute(f"DROP TABLE IF EXISTS {schema}mental_model_history")
# ---------------------------------------------------------------------------
# Oracle 23ai
# ---------------------------------------------------------------------------
def _oracle_upgrade() -> None:
# Same single-JSONB shape as PG: ``content`` holds the snapshot payload as a
# CLOB IS JSON. The legacy per-element JSON object (minus changed_at, promoted
# to its own column) is carried through verbatim on backfill — the array
# columns the previous design needed are gone.
op.execute(
"""
CREATE TABLE IF NOT EXISTS mental_model_history (
id NUMBER GENERATED BY DEFAULT AS IDENTITY,
mental_model_id VARCHAR2(256) NOT NULL,
bank_id VARCHAR2(256) NOT NULL,
content CLOB NOT NULL
CONSTRAINT mmh_content_json CHECK (content IS JSON),
changed_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_mental_model_history PRIMARY KEY (id),
CONSTRAINT fk_mmh_model FOREIGN KEY (mental_model_id, bank_id)
REFERENCES mental_models(id, bank_id) ON DELETE CASCADE
)
"""
)
op.execute(
"CREATE INDEX idx_mm_history_model ON mental_model_history (bank_id, mental_model_id, changed_at DESC, id DESC)"
)
op.execute(
"""
CREATE TABLE IF NOT EXISTS observation_history (
id NUMBER GENERATED BY DEFAULT AS IDENTITY,
observation_id RAW(16) NOT NULL,
bank_id VARCHAR2(256) NOT NULL,
content CLOB NOT NULL
CONSTRAINT oh_content_json CHECK (content IS JSON),
changed_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_observation_history PRIMARY KEY (id),
CONSTRAINT fk_oh_obs FOREIGN KEY (observation_id)
REFERENCES memory_units(id) ON DELETE CASCADE
)
"""
)
op.execute(
"CREATE INDEX idx_observation_history_obs ON observation_history (observation_id, changed_at DESC, id DESC)"
)
bind = op.get_bind()
# Backfill via JSON_TABLE. ``content`` is the whole element (FORMAT JSON PATH
# '$'); changed_at is also promoted to its own column. Backfilled content may
# therefore still carry a redundant changed_at key, which the read path
# ignores in favour of the column — harmless, and avoids JSON surgery here.
bind.exec_driver_sql(
"""
INSERT INTO mental_model_history (mental_model_id, bank_id, content, changed_at)
SELECT mm.id, mm.bank_id, jt.content, NVL(jt.changed_at, SYSTIMESTAMP)
FROM mental_models mm,
JSON_TABLE(mm.history, '$[*]' COLUMNS (
seq FOR ORDINALITY,
content CLOB FORMAT JSON PATH '$',
changed_at TIMESTAMP WITH TIME ZONE PATH '$.changed_at'
)) jt
WHERE mm.history IS NOT NULL
ORDER BY mm.id, mm.bank_id, jt.seq
"""
)
bind.exec_driver_sql(
"""
INSERT INTO observation_history (observation_id, bank_id, content, changed_at)
SELECT mu.id, mu.bank_id, jt.content, NVL(jt.changed_at, SYSTIMESTAMP)
FROM memory_units mu,
JSON_TABLE(mu.history, '$[*]' COLUMNS (
seq FOR ORDINALITY,
content CLOB FORMAT JSON PATH '$',
changed_at TIMESTAMP WITH TIME ZONE PATH '$.changed_at'
)) jt
WHERE mu.fact_type = 'observation' AND mu.history IS NOT NULL
ORDER BY mu.id, jt.seq
"""
)
op.execute("ALTER TABLE mental_models DROP COLUMN history")
op.execute("ALTER TABLE memory_units DROP COLUMN history")
def _oracle_downgrade() -> None:
op.execute("ALTER TABLE mental_models ADD history CLOB DEFAULT '[]' NOT NULL")
op.execute("ALTER TABLE memory_units ADD history CLOB DEFAULT '[]'")
op.execute("DROP TABLE observation_history CASCADE CONSTRAINTS")
op.execute("DROP TABLE mental_model_history CASCADE CONSTRAINTS")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -0,0 +1,156 @@
"""Repair: install maintenance routines on the ``public`` / base-schema run.
The original maintenance-routines migration (``e5f6a7b8c9d0``) only created the
shared ``public.banks_needing_consolidation()`` and
``public.schemas_with_expired_rows(...)`` routines when the run had *no*
``target_schema`` at all. But the single-tenant runtime always migrates an
explicit schema — which defaults to ``public`` — so on every default
PostgreSQL deployment the migration was stamped as applied while the functions
were never created. Background maintenance then logs::
Retention sweep failed for llm_requests: function public.schemas_with_expired_rows(...) does not exist
Consolidation reconcile discovery failed: function public.banks_needing_consolidation() does not exist
See https://github.com/vectorize-io/hindsight/issues/2056.
Because ``e5f6a7b8c9d0`` is already stamped on affected ``0.8.0`` databases,
editing it would not re-run it there. This forward migration re-installs the
functions idempotently (``CREATE OR REPLACE``) on the run that targets the
shared ``public`` schema (base run with no ``target_schema``, or an explicit
``target_schema=public``), self-healing already-upgraded deployments and
covering fresh upgrades from earlier versions.
Per-tenant runs against a non-``public`` schema still skip it: re-issuing
``CREATE OR REPLACE FUNCTION public....`` from each concurrent tenant migration
aborts with ``tuple concurrently updated`` on the ``pg_proc`` catalog row, and
the base/public run has already created the functions for every tenant to use.
Runs that target ``public`` are serialized by the per-schema migration advisory
lock, so only one wins the create.
PostgreSQL only — the worker poller and these tables are not wired for Oracle,
so the Oracle slot is intentionally absent (mirrors ``e5f6a7b8c9d0``).
Revision ID: b2d4f6a8c1e3
Revises: e5f6a7b8c9d0
Create Date: 2026-06-08
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "b2d4f6a8c1e3"
down_revision: str | Sequence[str] | None = "e5f6a7b8c9d0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _should_install_public_routines(target_schema: str | None) -> bool:
"""True for the run that must (re)create the shared ``public.*`` routines.
The routines physically live in ``public`` (hard-coded ``public.`` qualifier
in the SQL below), so they must be installed exactly once — on the base run
(no ``target_schema``) or on the run that explicitly targets ``public``. A
run against any other tenant schema skips it to avoid concurrent
``CREATE OR REPLACE`` on the same ``pg_proc`` row.
"""
return not target_schema or target_schema == "public"
def _pg_upgrade() -> None:
if not _should_install_public_routines(context.config.get_main_option("target_schema")):
return
# Banks with eligible-but-unscheduled facts and no in-flight consolidation.
# Auto-consolidation is filtered here only at the bank level (cheap prune);
# the full hierarchical resolution (global -> tenant -> bank, plus
# enable_observations) is done by the caller for the small returned set.
op.execute(
"""
CREATE OR REPLACE FUNCTION public.banks_needing_consolidation()
RETURNS TABLE(schema_name text, bank_id text)
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
BEGIN
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'memory_units' AND c.relkind = 'r'
LOOP
RETURN QUERY EXECUTE format($q$
SELECT %1$L::text, m.bank_id
FROM %1$I.memory_units m
JOIN %1$I.banks b ON b.bank_id = m.bank_id
WHERE m.consolidated_at IS NULL
AND m.consolidation_failed_at IS NULL
AND m.fact_type IN ('experience', 'world')
AND COALESCE(b.config -> 'enable_auto_consolidation', 'true'::jsonb) <> 'false'::jsonb
AND NOT EXISTS (
SELECT 1 FROM %1$I.async_operations o
WHERE o.bank_id = m.bank_id
AND o.operation_type = 'consolidation'
AND o.status IN ('pending', 'processing')
)
GROUP BY m.bank_id
$q$, sch);
END LOOP;
END;
$fn$;
"""
)
# Schemas holding at least one row of p_table older than p_days. p_ts_col is
# the timestamp column to compare. Returns nothing when p_days <= 0
# (retention disabled).
op.execute(
"""
CREATE OR REPLACE FUNCTION public.schemas_with_expired_rows(
p_table text, p_ts_col text, p_days int
)
RETURNS SETOF text
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
has_expired boolean;
BEGIN
IF p_days IS NULL OR p_days <= 0 THEN
RETURN;
END IF;
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = p_table AND c.relkind = 'r'
LOOP
EXECUTE format(
'SELECT EXISTS (SELECT 1 FROM %I.%I WHERE %I < NOW() - make_interval(days => $1))',
sch, p_table, p_ts_col
) INTO has_expired USING p_days;
IF has_expired THEN
RETURN NEXT sch;
END IF;
END LOOP;
END;
$fn$;
"""
)
def _pg_downgrade() -> None:
# No-op: ``e5f6a7b8c9d0`` owns the lifecycle of these functions and drops
# them on its own downgrade. This migration only ever (re)creates them, so
# there is nothing to undo without racing that migration's DROP.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,96 @@
"""Add llm_requests table for per-bank LLM request tracing.
Stores one row per logical LLM call Hindsight makes (success and failure),
capturing the input messages, model output, token usage (input/output/cached/
total), finish reason, and caller metadata. Disabled by default at the
application layer (HINDSIGHT_API_LLM_TRACE_ENABLED); this migration only
creates the table.
PostgreSQL only — the tracing subsystem is not wired for Oracle, so the Oracle
slot is intentionally absent (mirrors the audit_log table).
Revision ID: d3e4f5a6b7c8
Revises: c1d2e3f4a5b6
Create Date: 2026-06-01
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "d3e4f5a6b7c8"
down_revision: str | Sequence[str] | None = "c1d2e3f4a5b6"
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 _pg_upgrade() -> None:
schema = _get_schema_prefix()
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}llm_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bank_id TEXT,
operation TEXT,
scope TEXT,
-- OTel-style grouping: trace_id is shared by every LLM call of one
-- operation invocation (e.g. all calls of a single reflect run);
-- parent_span_id is that operation span; span_id is this call.
trace_id TEXT,
span_id TEXT,
parent_span_id TEXT,
provider TEXT,
model TEXT,
status TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
duration_ms INTEGER,
input_tokens INTEGER,
output_tokens INTEGER,
cached_tokens INTEGER,
total_tokens INTEGER,
input JSONB,
output JSONB,
error TEXT,
llm_info JSONB DEFAULT '{{}}'::jsonb,
metadata JSONB DEFAULT '{{}}'::jsonb
)
"""
)
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_llm_requests_bank_started ON {schema}llm_requests (bank_id, started_at DESC)"
)
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_llm_requests_status_started ON {schema}llm_requests (status, started_at DESC)"
)
op.execute(f"CREATE INDEX IF NOT EXISTS idx_llm_requests_started ON {schema}llm_requests (started_at DESC)")
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_llm_requests_trace ON {schema}llm_requests (bank_id, trace_id, started_at)"
)
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_llm_requests_started")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_llm_requests_status_started")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_llm_requests_bank_started")
op.execute(f"DROP TABLE IF EXISTS {schema}llm_requests")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,153 @@
"""Add server-side routines for background maintenance sweeps.
Installs two PL/pgSQL discovery routines in the ``public`` schema. Both loop
over every schema that actually holds the relevant table (via ``pg_class``), so
a single function call covers all tenants in one round-trip instead of the
per-tenant query storm that a client-side loop would create at thousands of
tenants.
- ``public.banks_needing_consolidation()`` -> (schema_name, bank_id) for banks
that have eligible-but-unscheduled facts (``consolidated_at IS NULL AND
consolidation_failed_at IS NULL`` for consolidatable fact types), have
auto-consolidation not explicitly disabled at the bank level, and have no
consolidation operation already pending/processing. Drives the periodic
reconcile that re-schedules consolidation after a terminal failure left facts
stranded (see HINDSIGHT_API_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS).
- ``public.schemas_with_expired_rows(p_table, p_ts_col, p_days)`` -> schema
names that hold at least one ``p_table`` row older than ``p_days``. Drives the
cross-tenant retention sweeps for ``audit_log`` and ``llm_requests``; the loop
then issues a DELETE only against the returned schemas.
These are read-only (STABLE) discovery routines — the caller performs the
enqueue/DELETE — so installing them never mutates data.
PostgreSQL only — the worker poller and these tables are not wired for Oracle,
so the Oracle slot is intentionally absent (mirrors the audit_log / llm_requests
table migrations). The routines live in ``public`` and are CREATE OR REPLACE, so
running this migration once per tenant schema is idempotent.
Revision ID: e5f6a7b8c9d0
Revises: a7b8c9d0e1f2
Create Date: 2026-06-05
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "e5f6a7b8c9d0"
down_revision: str | Sequence[str] | None = "a7b8c9d0e1f2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _is_base_schema_run() -> bool:
"""True only for the base-schema migration (no per-tenant target_schema).
These routines live in the shared ``public`` schema, so they must be created
exactly once. Running ``CREATE OR REPLACE FUNCTION public....`` again from each
concurrent per-tenant migration aborts with ``tuple concurrently updated`` on
the ``pg_proc`` catalog row, so tenant runs skip it (the base run already
created the function for every tenant to use).
"""
return not context.config.get_main_option("target_schema")
def _pg_upgrade() -> None:
if not _is_base_schema_run():
return
# Banks with eligible-but-unscheduled facts and no in-flight consolidation.
# Auto-consolidation is filtered here only at the bank level (cheap prune);
# the full hierarchical resolution (global -> tenant -> bank, plus
# enable_observations) is done by the caller for the small returned set.
op.execute(
"""
CREATE OR REPLACE FUNCTION public.banks_needing_consolidation()
RETURNS TABLE(schema_name text, bank_id text)
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
BEGIN
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'memory_units' AND c.relkind = 'r'
LOOP
RETURN QUERY EXECUTE format($q$
SELECT %1$L::text, m.bank_id
FROM %1$I.memory_units m
JOIN %1$I.banks b ON b.bank_id = m.bank_id
WHERE m.consolidated_at IS NULL
AND m.consolidation_failed_at IS NULL
AND m.fact_type IN ('experience', 'world')
AND COALESCE(b.config -> 'enable_auto_consolidation', 'true'::jsonb) <> 'false'::jsonb
AND NOT EXISTS (
SELECT 1 FROM %1$I.async_operations o
WHERE o.bank_id = m.bank_id
AND o.operation_type = 'consolidation'
AND o.status IN ('pending', 'processing')
)
GROUP BY m.bank_id
$q$, sch);
END LOOP;
END;
$fn$;
"""
)
# Schemas holding at least one row of p_table older than p_days. p_ts_col is
# the timestamp column to compare. Returns nothing when p_days <= 0
# (retention disabled).
op.execute(
"""
CREATE OR REPLACE FUNCTION public.schemas_with_expired_rows(
p_table text, p_ts_col text, p_days int
)
RETURNS SETOF text
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
has_expired boolean;
BEGIN
IF p_days IS NULL OR p_days <= 0 THEN
RETURN;
END IF;
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = p_table AND c.relkind = 'r'
LOOP
EXECUTE format(
'SELECT EXISTS (SELECT 1 FROM %I.%I WHERE %I < NOW() - make_interval(days => $1))',
sch, p_table, p_ts_col
) INTO has_expired USING p_days;
IF has_expired THEN
RETURN NEXT sch;
END IF;
END LOOP;
END;
$fn$;
"""
)
def _pg_downgrade() -> None:
if not _is_base_schema_run():
return
op.execute("DROP FUNCTION IF EXISTS public.banks_needing_consolidation()")
op.execute("DROP FUNCTION IF EXISTS public.schemas_with_expired_rows(text, text, int)")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
+386 -221
View File
@@ -6,6 +6,8 @@ the FastAPI application with all API endpoints.
"""
import asyncio
import dataclasses
import hmac
import json
import logging
import re
@@ -17,7 +19,13 @@ from typing import Any, Literal
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, Request, UploadFile
from fastapi.middleware.gzip import GZipMiddleware
from hindsight_api.engine.audit import AuditEntry, AuditLogger
from hindsight_api.engine.audit import (
AuditEntry,
AuditLogger,
AuditLogListResponse,
AuditLogStatsResponse,
)
from hindsight_api.engine.llm_trace import LLMRequestListResponse, LLMRequestStatsResponse
from hindsight_api.extensions import AuthenticationError
@@ -73,7 +81,7 @@ def FieldWithDefault(default_factory: Callable, **kwargs) -> Any:
return Field(default_factory=default_factory, json_schema_extra=json_extra, **kwargs)
from hindsight_api.config import get_config
from hindsight_api.config import _get_raw_config, get_config
from hindsight_api.engine.memory_engine import Budget, _current_schema, _get_tiktoken_encoding, fq_table
from hindsight_api.engine.providers.none_llm import LLMNotAvailableError
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, MemoryFact, TokenUsage
@@ -1200,6 +1208,33 @@ class BankConfigResponse(BaseModel):
overrides: dict[str, Any] = Field(description="Bank-specific configuration overrides only (Python field names)")
class AdminConfigResponse(BaseModel):
"""Response model for the server-level (admin) configuration view.
Returns the resolved ``HindsightConfig`` as a flat dict keyed by Python field
name. Credential fields (API keys, tokens, service-account keys, base URLs) are
masked: present as ``"***"`` when set and ``None`` when unset, so an operator can
see which credentials are configured without ever seeing their values.
"""
config: dict[str, Any] = Field(
description="Resolved server-level configuration (Python field names); credentials are redacted"
)
# Name suffixes that mark a config field as secret-bearing. Used in addition to
# HindsightConfig._CREDENTIAL_FIELDS so the admin config view never leaks a provider
# credential even if the (denylist) credential set misses a field. Suffixes are
# singular on purpose — "_token" must not also match value-bearing "_tokens" fields
# like recall_max_tokens.
_SENSITIVE_FIELD_SUFFIXES = ("_api_key", "_token", "_secret", "_access_key", "_account_key", "_password")
def _is_sensitive_config_field(field_name: str, credential_fields: set[str]) -> bool:
"""Whether a config field must be redacted in the admin view."""
return field_name in credential_fields or field_name.endswith(_SENSITIVE_FIELD_SUFFIXES)
class GraphDataResponse(BaseModel):
"""Response model for graph data endpoint."""
@@ -1444,6 +1479,18 @@ class ReprocessDocumentResponse(BaseModel):
items_count: int
class DocumentImportSubmitResponse(BaseModel):
"""Response for the async document-import endpoint (202).
The import runs in the background; poll the operations endpoint for status.
The imported/skipped counts (documents_imported, facts_imported,
observations_imported, etc.) are written to the operation's result_metadata.
"""
operation_id: str
status: str = "pending"
class DeleteResponse(BaseModel):
"""Response model for delete operations."""
@@ -2150,6 +2197,28 @@ async def apply_bank_template_manifest(
)
class OperationProgress(BaseModel):
"""Last-known progress snapshot for a long-running async operation.
Written at coarse phase/batch boundaries by the worker (consolidation, batch
retain). Lets an operator polling the operation status API distinguish a healthy
long-running job (``processed`` advancing across polls) from a frozen one (same
numbers, no movement in ``at``). Absent (``null``) on operations that never
reached a checkpoint completed-instantly or pre-feature rows.
"""
stage: str = Field(description="Coarse phase the operation last reported (e.g. 'processing_batch').")
at: str = Field(description="ISO-8601 timestamp when this snapshot was written.")
processed: int | None = Field(
default=None, description="Units of work finished so far (sub-batches, memories), when known."
)
total: int | None = Field(default=None, description="Total units of work for the operation, when known.")
detail: dict[str, int] | None = Field(
default=None,
description="Operation-specific counters (e.g. observations_created, round, items_in_sub_batch).",
)
class OperationResponse(BaseModel):
"""Response model for a single async operation."""
@@ -2174,6 +2243,10 @@ class OperationResponse(BaseModel):
items_count: int
document_id: str | None = None
created_at: str
updated_at: str | None = Field(
default=None,
description="When this operation's row last changed (claim, progress heartbeat, or completion).",
)
status: str
error_message: str | None
retry_count: int | None = Field(
@@ -2190,6 +2263,10 @@ class OperationResponse(BaseModel):
"some backpressure window opens. Always null for completed tasks."
),
)
progress: OperationProgress | None = Field(
default=None,
description="Last-known progress snapshot for a running operation; null if none was recorded.",
)
class ConsolidationRequest(BaseModel):
@@ -2325,6 +2402,10 @@ class OperationStatusResponse(BaseModel):
"immediate pickup."
),
)
progress: OperationProgress | None = Field(
default=None,
description="Last-known progress snapshot for a running operation; null if none was recorded.",
)
result_metadata: dict[str, Any] | None = Field(
default=None,
description="Internal metadata for debugging. Structure may change without notice. Not for production use.",
@@ -2361,7 +2442,12 @@ class FeaturesInfo(BaseModel):
mcp: bool = Field(description="Whether MCP (Model Context Protocol) server is enabled")
worker: bool = Field(description="Whether the background worker is enabled")
bank_config_api: bool = Field(description="Whether per-bank configuration API is enabled")
admin_api: bool = Field(description="Whether the admin API (/admin) is enabled")
file_upload_api: bool = Field(description="Whether file upload/conversion API is enabled")
document_export_api: bool = Field(description="Whether the document export endpoint is enabled")
document_import_api: bool = Field(description="Whether the document import endpoint is enabled")
audit_log: bool = Field(description="Whether audit logging is enabled")
llm_trace: bool = Field(description="Whether per-bank LLM request tracing is enabled")
class VersionResponse(BaseModel):
@@ -2377,6 +2463,8 @@ class VersionResponse(BaseModel):
"worker": True,
"bank_config_api": False,
"file_upload_api": True,
"document_export_api": True,
"document_import_api": True,
},
}
}
@@ -2913,6 +3001,31 @@ def _register_routes(app: FastAPI):
api_key = authorization.strip()
return RequestContext(api_key=api_key)
def require_admin(authorization: str | None = Header(default=None)) -> None:
"""Guard for the admin surface.
- 404 when the admin API is disabled (so the surface is invisible by default).
- When ``HINDSIGHT_API_ADMIN_TOKEN`` is set, require it as a bearer token
(or a bare token) and reject with 401 otherwise. When unset, the admin API
is open (auth is optional) consistent with the rest of the deployment.
"""
config = _get_raw_config()
if not config.enable_admin_api:
raise HTTPException(
status_code=404,
detail="Admin API is disabled. Set HINDSIGHT_API_ENABLE_ADMIN_API=true to enable.",
)
expected = config.admin_api_token
if expected:
token = None
if authorization:
if authorization.lower().startswith("bearer "):
token = authorization[7:].strip()
else:
token = authorization.strip()
if not token or not hmac.compare_digest(token, expected):
raise HTTPException(status_code=401, detail="Invalid or missing admin token")
def precheck_for(operation: str):
"""
Build a FastAPI dependency that runs ``OperationValidator.precheck``.
@@ -3022,10 +3135,42 @@ def _register_routes(app: FastAPI):
mcp=config.mcp_enabled,
worker=config.worker_enabled,
bank_config_api=config.enable_bank_config_api,
admin_api=config.enable_admin_api,
file_upload_api=config.enable_file_upload_api,
document_export_api=config.enable_document_export_api,
document_import_api=config.enable_document_import_api,
audit_log=config.audit_log_enabled,
llm_trace=config.llm_trace_enabled,
),
)
@app.get(
"/admin/config",
response_model=AdminConfigResponse,
summary="Get resolved server-level configuration",
description="Returns the resolved server-level configuration with credentials redacted. "
"Gated by HINDSIGHT_API_ENABLE_ADMIN_API and, when set, HINDSIGHT_API_ADMIN_TOKEN.",
tags=["Admin"],
operation_id="get_admin_config",
dependencies=[Depends(require_admin)],
)
async def admin_config_endpoint() -> AdminConfigResponse:
"""Expose the resolved ``HindsightConfig`` for operator inspection.
Sensitive fields (the credential denylist plus any field whose name ends in a
secret-bearing suffix see ``_is_sensitive_config_field``) are masked so values
never leave the server: ``"***"`` when set, ``None`` when unset. All other fields
are returned as-is.
"""
config = _get_raw_config()
credential_fields = type(config).get_credential_fields()
raw = dataclasses.asdict(config)
redacted = {
key: ("***" if value is not None else None) if _is_sensitive_config_field(key, credential_fields) else value
for key, value in raw.items()
}
return AdminConfigResponse(config=redacted)
@app.get(
"/metrics",
summary="Prometheus metrics endpoint",
@@ -5265,6 +5410,124 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in GET /v1/default/banks/{bank_id}/export: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
# =====================================================================
# Document Transfer (Export / Import between banks — no LLM re-extraction)
# =====================================================================
@app.get(
# Dedicated path (not under /documents/) to avoid colliding with the
# greedy GET /documents/{document_id:path} route, which would otherwise
# capture "export"/"import" as a document id.
"/v1/default/banks/{bank_id}/document-transfer",
summary="Export documents",
description="Export documents (extracted facts, entity names, causal links, chunks) from a bank as a "
"transfer ZIP archive. Embeddings and database ids are not included — importing re-embeds with the target "
"bank's model and re-resolves entities. Consolidated observations are excluded unless include_observations=true. "
"Pass document_id query params to export specific documents, or omit to export the whole bank.",
operation_id="export_documents",
tags=["Document Transfer"],
responses={200: {"content": {"application/zip": {}}, "description": "Transfer archive"}},
)
async def api_export_documents(
bank_id: str,
document_id: list[str] | None = Query(default=None, description="Document id(s) to export; omit for all"),
include_observations: bool = Query(
default=False, description="Also export consolidated observations (restored on import)"
),
request_context: RequestContext = Depends(get_request_context),
):
"""Export documents from a bank into a transfer ZIP archive."""
from fastapi.responses import Response
try:
if not get_config().enable_document_export_api:
raise HTTPException(
status_code=404,
detail="Document export API is disabled. "
"Set HINDSIGHT_API_ENABLE_DOCUMENT_EXPORT_API=true to enable.",
)
profile = await app.state.memory.get_bank_profile(
bank_id, request_context=request_context, create_if_missing=False
)
if profile is None:
raise HTTPException(status_code=404, detail=f"Bank '{bank_id}' not found")
try:
archive = await app.state.memory.export_documents_async(
bank_id,
request_context,
list(document_id) if document_id else None,
include_observations=include_observations,
)
except ValueError as e:
# e.g. include_observations combined with a document_id subset.
raise HTTPException(status_code=400, detail=str(e))
return Response(
content=archive,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{bank_id}-documents.zip"'},
)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
logger.error(f"Error in GET /v1/default/banks/{bank_id}/document-transfer: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/document-transfer",
response_model=DocumentImportSubmitResponse,
status_code=202,
summary="Import documents (async)",
description="Submit a transfer archive (produced by the export endpoint) for import into a bank. Runs as a "
"background operation: facts are re-embedded with the target bank's embedding model and entities are "
"re-resolved — no LLM extraction. Returns an operation_id; poll "
"GET /v1/default/banks/{bank_id}/operations/{operation_id} for status and the imported/skipped counts in "
"result_metadata. Use on_conflict to control existing document ids: skip (default), replace, or new-id.",
operation_id="import_documents",
tags=["Document Transfer"],
)
@audited("import_documents", request_param=None)
async def api_import_documents(
bank_id: str,
file: UploadFile = File(..., description="Transfer ZIP archive"),
on_conflict: str = Query(default="skip", description="skip | replace | new-id"),
request_context: RequestContext = Depends(get_request_context),
):
"""Submit a transfer archive for async import into a bank."""
try:
if not get_config().enable_document_import_api:
raise HTTPException(
status_code=404,
detail="Document import API is disabled. "
"Set HINDSIGHT_API_ENABLE_DOCUMENT_IMPORT_API=true to enable.",
)
if on_conflict not in ("skip", "replace", "new-id"):
raise HTTPException(
status_code=400, detail=f"Invalid on_conflict '{on_conflict}' (expected skip|replace|new-id)"
)
archive_bytes = await file.read()
try:
submission = await app.state.memory.import_documents_async(
bank_id, archive_bytes, request_context, on_conflict
)
except ValueError as e:
# Invalid archive / unsupported schema version — fail fast.
raise HTTPException(status_code=400, detail=str(e))
return DocumentImportSubmitResponse(operation_id=submission["operation_id"])
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
logger.error(f"Error in POST /v1/default/banks/{bank_id}/document-transfer: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/bank-template-schema",
summary="Get bank template JSON Schema",
@@ -6202,48 +6465,13 @@ def _register_routes(app: FastAPI):
raise HTTPException(status_code=500, detail=str(e))
# ---- Audit Logs ----
# Response models live in engine/audit.py so the MemoryEngine read methods
# (list_audit_logs / audit_log_stats) can build and return them directly.
class AuditLogEntry(BaseModel):
"""A single audit log entry."""
id: str
action: str
transport: str
bank_id: str | None
started_at: str | None
ended_at: str | None
duration_ms: int | None = Field(
default=None,
description="Server-computed duration in milliseconds (started_at → ended_at). Null if not yet completed.",
)
request: dict[str, Any] | None
response: dict[str, Any] | None
metadata: dict[str, Any]
class AuditLogListResponse(BaseModel):
"""Response model for list audit logs endpoint."""
bank_id: str
total: int
limit: int
offset: int
items: list[AuditLogEntry]
class AuditLogStatsBucket(BaseModel):
"""A single time bucket in audit log stats."""
time: str
actions: dict[str, int]
total: int
class AuditLogStatsResponse(BaseModel):
"""Response model for audit log stats endpoint."""
bank_id: str
period: str
trunc: str
start: str
buckets: list[AuditLogStatsBucket]
# ---- LLM Request Traces ----
# Response models + queries live in the engine (engine/llm_trace.py and
# MemoryEngine.list_llm_requests / llm_request_stats). The handlers below
# only parse params, delegate to the engine, and map a missing bank to 404.
@app.get(
"/v1/default/banks/{bank_id}/audit-logs",
@@ -6265,120 +6493,19 @@ def _register_routes(app: FastAPI):
):
"""List audit log entries for a bank."""
try:
from hindsight_api.engine.memory_engine import fq_table
pool = await app.state.memory._get_backend()
# Read endpoint: verify bank exists without auto-creating it.
if (
await app.state.memory.get_bank_profile(
bank_id, request_context=request_context, create_if_missing=False
)
is None
):
result = await app.state.memory.list_audit_logs(
bank_id,
request_context=request_context,
action=action,
transport=transport,
start_date=datetime.fromisoformat(start_date.replace("Z", "+00:00")) if start_date else None,
end_date=datetime.fromisoformat(end_date.replace("Z", "+00:00")) if end_date else None,
limit=limit,
offset=offset,
)
if result is None:
raise HTTPException(status_code=404, detail=f"Bank '{bank_id}' not found")
from hindsight_api.engine.db_utils import acquire_with_retry
async with acquire_with_retry(pool) as conn:
where_clauses = ["bank_id = $1"]
params: list[Any] = [bank_id]
idx = 2
if action:
where_clauses.append(f"action = ${idx}")
params.append(action)
idx += 1
if transport:
where_clauses.append(f"transport = ${idx}")
params.append(transport)
idx += 1
if start_date:
parsed_start = datetime.fromisoformat(start_date.replace("Z", "+00:00"))
where_clauses.append(f"started_at >= ${idx}")
params.append(parsed_start)
idx += 1
if end_date:
parsed_end = datetime.fromisoformat(end_date.replace("Z", "+00:00"))
where_clauses.append(f"started_at < ${idx}")
params.append(parsed_end)
idx += 1
where_sql = " AND ".join(where_clauses)
table = fq_table("audit_log")
# Get total count
count_row = await conn.fetchrow(
f"SELECT COUNT(*) as total FROM {table} WHERE {where_sql}",
*params,
)
total = count_row["total"] if count_row else 0
# Get paginated results
params.append(limit)
params.append(offset)
rows = await conn.fetch(
f"""
SELECT id, action, transport, bank_id, started_at, ended_at,
request, response, metadata
FROM {table}
WHERE {where_sql}
ORDER BY started_at DESC
LIMIT ${idx} OFFSET ${idx + 1}
""",
*params,
)
items = []
for row in rows:
duration_ms = None
started = row["started_at"]
ended = row["ended_at"]
if started and ended and hasattr(started, "total_seconds"):
duration_ms = int((ended - started).total_seconds() * 1000)
elif started and ended:
try:
duration_ms = int((ended - started).total_seconds() * 1000)
except (TypeError, AttributeError):
pass
def _safe_iso(val):
if val is None:
return None
return val.isoformat() if hasattr(val, "isoformat") else str(val)
def _safe_json(val):
if val is None:
return None
if isinstance(val, dict):
return val
return json.loads(val) if isinstance(val, str) else val
items.append(
{
"id": str(row["id"]),
"action": row["action"],
"transport": row["transport"],
"bank_id": row["bank_id"],
"started_at": _safe_iso(started),
"ended_at": _safe_iso(ended),
"duration_ms": duration_ms,
"request": _safe_json(row["request"]),
"response": _safe_json(row["response"]),
"metadata": _safe_json(row["metadata"]) or {},
}
)
return {
"bank_id": bank_id,
"total": total,
"limit": limit,
"offset": offset,
"items": items,
}
return result
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
@@ -6405,72 +6532,15 @@ def _register_routes(app: FastAPI):
):
"""Get audit log counts grouped by time bucket."""
try:
from hindsight_api.engine.db_utils import acquire_with_retry
from hindsight_api.engine.memory_engine import fq_table
pool = await app.state.memory._get_backend()
# Read endpoint: verify bank exists without auto-creating it.
if (
await app.state.memory.get_bank_profile(
bank_id, request_context=request_context, create_if_missing=False
)
is None
):
result = await app.state.memory.audit_log_stats(
bank_id,
request_context=request_context,
action=action,
period=period,
)
if result is None:
raise HTTPException(status_code=404, detail=f"Bank '{bank_id}' not found")
# Determine time range (always per-day buckets)
from datetime import timedelta as _td
now = datetime.now(timezone.utc)
trunc = "day"
if period == "1d":
start = now - _td(days=1)
elif period == "30d":
start = now - _td(days=30)
else: # 7d default
start = now - _td(days=7)
table = fq_table("audit_log")
async with acquire_with_retry(pool) as conn:
where_clauses = ["bank_id = $1", "started_at >= $2"]
params: list[Any] = [bank_id, start]
idx = 3
if action:
where_clauses.append(f"action = ${idx}")
params.append(action)
idx += 1
where_sql = " AND ".join(where_clauses)
rows = await conn.fetch(
f"""
SELECT date_trunc('{trunc}', started_at) AS bucket,
action,
COUNT(*) AS count
FROM {table}
WHERE {where_sql}
GROUP BY bucket, action
ORDER BY bucket ASC
""",
*params,
)
buckets: dict[str, dict[str, int]] = {}
for row in rows:
bucket_key = row["bucket"].isoformat()
if bucket_key not in buckets:
buckets[bucket_key] = {}
buckets[bucket_key][row["action"]] = row["count"]
return {
"bank_id": bank_id,
"period": period,
"trunc": trunc,
"start": start.isoformat(),
"buckets": [{"time": k, "actions": v, "total": sum(v.values())} for k, v in buckets.items()],
}
return result
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
@@ -6480,3 +6550,98 @@ def _register_routes(app: FastAPI):
logger.error(f"Error getting audit log stats: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/llm-requests",
summary="List LLM request traces",
description="List traced LLM requests for a bank, ordered by most recent first. "
"Requires LLM request tracing to be enabled (HINDSIGHT_API_LLM_TRACE_ENABLED).",
operation_id="list_llm_requests",
tags=["LLM Traces"],
response_model=LLMRequestListResponse,
)
async def api_list_llm_requests(
bank_id: str,
status: str | None = Query(None, description="Filter by status (success, error)"),
operation: str | None = Query(None, description="Filter by operation (retain, reflect, consolidation)"),
scope: str | None = Query(None, description="Filter by call scope"),
provider: str | None = Query(None, description="Filter by LLM provider"),
trace_id: str | None = Query(None, description="Filter to one operation run (all LLM calls sharing a trace)"),
document_id: str | None = Query(None, description="Filter to LLM calls that processed a given document"),
memory_id: str | None = Query(
None, description="Filter to the operation run(s) that produced or consumed a given memory_unit"
),
group: bool = Query(
False, description="Paginate by operation run (trace) instead of by call; returns whole runs"
),
start_date: str | None = Query(None, description="Filter from this ISO datetime (inclusive)"),
end_date: str | None = Query(None, description="Filter until this ISO datetime (exclusive)"),
limit: int = Query(50, ge=1, le=500, description="Max items to return"),
offset: int = Query(0, ge=0, description="Offset for pagination"),
request_context: RequestContext = Depends(get_request_context),
):
"""List traced LLM requests for a bank."""
try:
result = await app.state.memory.list_llm_requests(
bank_id,
request_context=request_context,
status=status,
operation=operation,
scope=scope,
provider=provider,
trace_id=trace_id,
document_id=document_id,
memory_id=memory_id,
group=group,
start_date=datetime.fromisoformat(start_date.replace("Z", "+00:00")) if start_date else None,
end_date=datetime.fromisoformat(end_date.replace("Z", "+00:00")) if end_date else None,
limit=limit,
offset=offset,
)
if result is None:
raise HTTPException(status_code=404, detail=f"Bank '{bank_id}' not found")
return result
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
logger.error(f"Error listing LLM requests: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/llm-requests/stats",
summary="LLM request statistics",
description="Get LLM request counts grouped by time bucket and status for charting.",
operation_id="llm_request_stats",
tags=["LLM Traces"],
response_model=LLMRequestStatsResponse,
)
async def api_llm_request_stats(
bank_id: str,
operation: str | None = Query(None, description="Filter by operation"),
period: str = Query("7d", description="Time period: 1d, 7d, or 30d"),
request_context: RequestContext = Depends(get_request_context),
):
"""Get LLM request counts grouped by time bucket and status."""
try:
result = await app.state.memory.llm_request_stats(
bank_id,
request_context=request_context,
operation=operation,
period=period,
)
if result is None:
raise HTTPException(status_code=404, detail=f"Bank '{bank_id}' not found")
return result
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
logger.error(f"Error getting LLM request stats: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=str(e))
+333 -8
View File
@@ -143,6 +143,7 @@ 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"
ENV_LLM_DEFAULT_HEADERS = "HINDSIGHT_API_LLM_DEFAULT_HEADERS"
ENV_LLM_STRICT_SCHEMA = "HINDSIGHT_API_LLM_STRICT_SCHEMA"
# LiteLLM Router chain — provider-specific config consumed by the "litellmrouter"
# provider. Each entry is a deployment; the Router tries them in declared order and
@@ -209,6 +210,17 @@ ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
ENV_EMBEDDINGS_LOCAL_FORCE_CPU = "HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE = "HINDSIGHT_API_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE"
ENV_EMBEDDINGS_ONNX_MODEL_ID = "HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_ID"
ENV_EMBEDDINGS_ONNX_MODEL_PATH = "HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_PATH"
ENV_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH = "HINDSIGHT_API_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH"
ENV_EMBEDDINGS_ONNX_FILE = "HINDSIGHT_API_EMBEDDINGS_ONNX_FILE"
ENV_EMBEDDINGS_ONNX_DIMENSIONS = "HINDSIGHT_API_EMBEDDINGS_ONNX_DIMENSIONS"
ENV_EMBEDDINGS_ONNX_MAX_TOKENS = "HINDSIGHT_API_EMBEDDINGS_ONNX_MAX_TOKENS"
ENV_EMBEDDINGS_ONNX_POOLING = "HINDSIGHT_API_EMBEDDINGS_ONNX_POOLING"
ENV_EMBEDDINGS_ONNX_NORMALIZE = "HINDSIGHT_API_EMBEDDINGS_ONNX_NORMALIZE"
ENV_EMBEDDINGS_ONNX_QUERY_PREFIX = "HINDSIGHT_API_EMBEDDINGS_ONNX_QUERY_PREFIX"
ENV_EMBEDDINGS_ONNX_PASSAGE_PREFIX = "HINDSIGHT_API_EMBEDDINGS_ONNX_PASSAGE_PREFIX"
ENV_EMBEDDINGS_ONNX_OUTPUT_NAME = "HINDSIGHT_API_EMBEDDINGS_ONNX_OUTPUT_NAME"
ENV_EMBEDDINGS_TEI_URL = "HINDSIGHT_API_EMBEDDINGS_TEI_URL"
ENV_EMBEDDINGS_OPENAI_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY"
ENV_EMBEDDINGS_OPENAI_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL"
@@ -297,6 +309,7 @@ ENV_RERANKER_LITELLM_TIMEOUT = "HINDSIGHT_API_RERANKER_LITELLM_TIMEOUT"
ENV_RERANKER_LITELLM_SDK_TIMEOUT = "HINDSIGHT_API_RERANKER_LITELLM_SDK_TIMEOUT"
ENV_RERANKER_GOOGLE_TIMEOUT = "HINDSIGHT_API_RERANKER_GOOGLE_TIMEOUT"
ENV_RERANKER_MAX_CANDIDATES = "HINDSIGHT_API_RERANKER_MAX_CANDIDATES"
ENV_SEMANTIC_MIN_SIMILARITY = "HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY"
ENV_RERANKER_FLASHRANK_MODEL = "HINDSIGHT_API_RERANKER_FLASHRANK_MODEL"
ENV_RERANKER_FLASHRANK_CACHE_DIR = "HINDSIGHT_API_RERANKER_FLASHRANK_CACHE_DIR"
ENV_RERANKER_FLASHRANK_CPU_MEM_ARENA = "HINDSIGHT_API_RERANKER_FLASHRANK_CPU_MEM_ARENA"
@@ -338,6 +351,8 @@ 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_ENABLE_ADMIN_API = "HINDSIGHT_API_ENABLE_ADMIN_API"
ENV_ADMIN_API_TOKEN = "HINDSIGHT_API_ADMIN_TOKEN"
ENV_DEFAULT_BANK_TEMPLATE = "HINDSIGHT_API_DEFAULT_BANK_TEMPLATE"
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
@@ -346,6 +361,8 @@ 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"
ENV_BANK_STATS_CACHE_TTL_SECONDS = "HINDSIGHT_API_BANK_STATS_CACHE_TTL_SECONDS"
ENV_BANK_STATS_CACHE_MAX_ENTRIES = "HINDSIGHT_API_BANK_STATS_CACHE_MAX_ENTRIES"
# OpenTelemetry tracing configuration
ENV_OTEL_TRACES_ENABLED = "HINDSIGHT_API_OTEL_TRACES_ENABLED"
@@ -363,6 +380,16 @@ ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOU
# Gemini safety settings
ENV_LLM_GEMINI_SAFETY_SETTINGS = "HINDSIGHT_API_LLM_GEMINI_SAFETY_SETTINGS"
# Gemini prompt caching. When enabled, retain fact-extraction reuses a
# CachedContent prefix for the static system_instruction + response_schema,
# cutting per-call input cost on workloads with many small documents.
# Provider-agnostic prompt-prefix caching. Providers that support it (currently
# Gemini/Vertex via CachedContent) reuse the large, fixed, bank-agnostic system
# prefix at the cached-input rate; providers that don't simply ignore it. On by
# default — the prefix is bank-agnostic so a single cache is shared across all
# banks, and creation soft-fails to an uncached call, so it never breaks a request.
ENV_LLM_PROMPT_CACHE_ENABLED = "HINDSIGHT_API_LLM_PROMPT_CACHE_ENABLED"
# Retain settings
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
@@ -400,14 +427,20 @@ ENV_FILE_CONVERSION_MAX_BATCH_SIZE = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SI
ENV_ENABLE_FILE_UPLOAD_API = "HINDSIGHT_API_ENABLE_FILE_UPLOAD_API"
ENV_FILE_DELETE_AFTER_RETAIN = "HINDSIGHT_API_FILE_DELETE_AFTER_RETAIN"
# Document transfer (export/import documents between banks without re-running the LLM)
ENV_ENABLE_DOCUMENT_EXPORT_API = "HINDSIGHT_API_ENABLE_DOCUMENT_EXPORT_API"
ENV_ENABLE_DOCUMENT_IMPORT_API = "HINDSIGHT_API_ENABLE_DOCUMENT_IMPORT_API"
# Observations settings (consolidated knowledge from facts)
ENV_ENABLE_OBSERVATIONS = "HINDSIGHT_API_ENABLE_OBSERVATIONS"
ENV_ENABLE_AUTO_CONSOLIDATION = "HINDSIGHT_API_ENABLE_AUTO_CONSOLIDATION"
ENV_CONSOLIDATION_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE"
ENV_CONSOLIDATION_MAX_MEMORIES_PER_ROUND = "HINDSIGHT_API_CONSOLIDATION_MAX_MEMORIES_PER_ROUND"
ENV_CONSOLIDATION_LLM_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_LLM_BATCH_SIZE"
ENV_CONSOLIDATION_DEDUP_THRESHOLD = "HINDSIGHT_API_CONSOLIDATION_DEDUP_THRESHOLD"
ENV_CONSOLIDATION_LLM_PARALLELISM = "HINDSIGHT_API_CONSOLIDATION_LLM_PARALLELISM"
ENV_CONSOLIDATION_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_MAX_TOKENS"
ENV_CONSOLIDATION_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_CONSOLIDATION_MAX_COMPLETION_TOKENS"
ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS"
ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = (
"HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION"
@@ -417,6 +450,7 @@ ENV_CONSOLIDATION_MAX_ATTEMPTS = "HINDSIGHT_API_CONSOLIDATION_MAX_ATTEMPTS"
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_OBSERVATION_HISTORY_MAX_ENTRIES = "HINDSIGHT_API_OBSERVATION_HISTORY_MAX_ENTRIES"
ENV_ENABLE_MENTAL_MODEL_HISTORY = "HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY"
ENV_MENTAL_MODEL_HISTORY_MAX_ENTRIES = "HINDSIGHT_API_MENTAL_MODEL_HISTORY_MAX_ENTRIES"
@@ -448,6 +482,11 @@ ENV_DB_COMMAND_TIMEOUT = "HINDSIGHT_API_DB_COMMAND_TIMEOUT"
ENV_DB_ACQUIRE_TIMEOUT = "HINDSIGHT_API_DB_ACQUIRE_TIMEOUT"
ENV_DB_STATEMENT_TIMEOUT = "HINDSIGHT_API_DB_STATEMENT_TIMEOUT"
# Wall-clock cap on model/connection initialization at startup. If embeddings,
# cross-encoder, or LLM verification hang (e.g. an offline HuggingFace download
# or an unreachable provider), the daemon fails fast instead of hanging forever.
ENV_MODEL_INIT_TIMEOUT = "HINDSIGHT_API_MODEL_INIT_TIMEOUT"
# Worker configuration (distributed task processing)
ENV_WORKER_ENABLED = "HINDSIGHT_API_WORKER_ENABLED"
ENV_WORKER_ID = "HINDSIGHT_API_WORKER_ID"
@@ -468,6 +507,7 @@ WORKER_SLOT_RESERVATION_TYPES: dict[str, tuple[str, int]] = {
"file_convert_retain": ("HINDSIGHT_API_WORKER_FILE_CONVERT_RETAIN_MAX_SLOTS", 0),
"refresh_mental_model": ("HINDSIGHT_API_WORKER_REFRESH_MENTAL_MODEL_MAX_SLOTS", 0),
"graph_maintenance": ("HINDSIGHT_API_WORKER_GRAPH_MAINTENANCE_MAX_SLOTS", 0),
"import_documents": ("HINDSIGHT_API_WORKER_IMPORT_DOCUMENTS_MAX_SLOTS", 0),
}
ENV_WORKER_CONSOLIDATION_BANK_PRIORITY = "HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY"
ENV_RETAIN_MAX_CONCURRENT = "HINDSIGHT_API_RETAIN_MAX_CONCURRENT"
@@ -493,11 +533,32 @@ ENV_RECALL_BUDGET_ADAPTIVE_HIGH = "HINDSIGHT_API_RECALL_BUDGET_ADAPTIVE_HIGH"
ENV_RECALL_BUDGET_MIN = "HINDSIGHT_API_RECALL_BUDGET_MIN"
ENV_RECALL_BUDGET_MAX = "HINDSIGHT_API_RECALL_BUDGET_MAX"
# Recall candidate gating (per-source cap + BM25 score floor)
ENV_BM25_MIN_SCORE = "HINDSIGHT_API_BM25_MIN_SCORE"
ENV_RECALL_MAX_CANDIDATES_PER_SOURCE = "HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE"
# Per-strategy recall boost. Prioritises specific retrieval arms (semantic,
# bm25, graph, temporal) on recall via a human priority level — e.g.
# "graph:high" to strongly favour graph hits, or "graph:high,semantic:low".
# Valid levels: low | medium | high. The level (not a raw number) is the knob
# because the boost is applied on two different score scales — see
# engine/search/recall_boost.py for the level -> magnitude mapping and rationale.
# Empty disables the feature.
ENV_RECALL_STRATEGY_BOOSTS = "HINDSIGHT_API_RECALL_STRATEGY_BOOSTS"
# 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"
# LLM request tracing settings
ENV_LLM_TRACE_ENABLED = "HINDSIGHT_API_LLM_TRACE_ENABLED"
ENV_LLM_TRACE_SCOPES = "HINDSIGHT_API_LLM_TRACE_SCOPES"
ENV_LLM_TRACE_RETENTION_DAYS = "HINDSIGHT_API_LLM_TRACE_RETENTION_DAYS"
ENV_LLM_TRACE_MAX_CHARS = "HINDSIGHT_API_LLM_TRACE_MAX_CHARS"
# Background maintenance settings
ENV_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS = "HINDSIGHT_API_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS"
# Disposition settings
ENV_DISPOSITION_SKEPTICISM = "HINDSIGHT_API_DISPOSITION_SKEPTICISM"
ENV_DISPOSITION_LITERALISM = "HINDSIGHT_API_DISPOSITION_LITERALISM"
@@ -513,9 +574,9 @@ DEFAULT_LLM_PROVIDER = "openai"
PROVIDER_DEFAULT_MODELS = {
"openai": "gpt-4o-mini",
"anthropic": "claude-haiku-4-5",
"gemini": "gemini-2.5-flash",
"gemini": "gemini-3.5-flash",
"groq": "openai/gpt-oss-120b",
"minimax": "MiniMax-M2.7",
"minimax": "MiniMax-M3",
"deepseek": "deepseek-v4-flash",
"zai": "glm-4.5-flash",
"opencode-go": "deepseek-v4-flash",
@@ -523,7 +584,7 @@ PROVIDER_DEFAULT_MODELS = {
"ollama-cloud": "gemma3:12b",
"llamacpp": "gemma-4-e2b-it",
"lmstudio": "local-model",
"vertexai": "google/gemini-2.5-flash-lite",
"vertexai": "google/gemini-3.1-flash-lite",
"openai-codex": "gpt-5.4-mini",
"claude-code": "claude-sonnet-4-5-20250929",
"mock": "mock-model",
@@ -542,6 +603,14 @@ 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
# True = ask schema-capable backends to grammar-enforce structured output via
# json_schema strict (OpenAI-compatible, LiteLLM; Gemini already enforces its
# native response_schema). Default False keeps the soft "schema-in-prompt +
# json_object" path, which weaker self-hosted instruction-followers can violate
# (prose preambles, markdown fences, invalid JSON) — wedging retain/consolidation
# on parse retries.
DEFAULT_LLM_STRICT_SCHEMA = False
DEFAULT_LLM_MAX_CONCURRENT = 32
DEFAULT_LLM_MAX_RETRIES = 3 # Max retry attempts for LLM API calls
DEFAULT_LLM_INITIAL_BACKOFF = 1.0 # Initial backoff in seconds for retry exponential backoff
@@ -561,6 +630,13 @@ DEFAULT_EMBEDDINGS_PROVIDER = "local"
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE = False # Security: disabled by default, required for some models
DEFAULT_EMBEDDINGS_ONNX_MODEL_ID = "intfloat/multilingual-e5-small"
DEFAULT_EMBEDDINGS_ONNX_FILE = "onnx/model.onnx"
DEFAULT_EMBEDDINGS_ONNX_MAX_TOKENS = 512
DEFAULT_EMBEDDINGS_ONNX_POOLING = "mean"
DEFAULT_EMBEDDINGS_ONNX_NORMALIZE = True
DEFAULT_EMBEDDINGS_ONNX_QUERY_PREFIX = "query: "
DEFAULT_EMBEDDINGS_ONNX_PASSAGE_PREFIX = "passage: "
DEFAULT_EMBEDDINGS_OPENAI_MODEL = "text-embedding-3-small"
DEFAULT_EMBEDDINGS_OPENAI_BATCH_SIZE = 100
DEFAULT_EMBEDDINGS_GEMINI_MODEL = "gemini-embedding-001"
@@ -592,6 +668,65 @@ DEFAULT_RERANKER_LITELLM_TIMEOUT = 60.0
DEFAULT_RERANKER_LITELLM_SDK_TIMEOUT = 60.0
DEFAULT_RERANKER_GOOGLE_TIMEOUT = 60.0
DEFAULT_RERANKER_MAX_CANDIDATES = 300
DEFAULT_SEMANTIC_MIN_SIMILARITY = 0.3
# Minimum BM25 score a row must exceed to enter fusion. 0.0 gates out
# zero-score (non-matching) rows on backends — notably VectorChord — whose
# operator ranks every document rather than pre-filtering to term matches.
DEFAULT_BM25_MIN_SCORE = 0.0
# Per-source candidate cap applied to each retrieval arm (semantic, BM25, graph,
# temporal) before RRF, so a single over-expanding backend cannot fill the
# reranker's global candidate budget on its own. 0 disables the cap.
DEFAULT_RECALL_MAX_CANDIDATES_PER_SOURCE = 0
# Per-strategy recall boost, as a comma-separated "strategy:level" list (e.g.
# "graph:high,semantic:low"). Empty disables the feature. See
# ENV_RECALL_STRATEGY_BOOSTS for the full rationale.
DEFAULT_RECALL_STRATEGY_BOOSTS = ""
# Retrieval arms that can be boosted; mirrors fusion.py source_names.
RECALL_STRATEGY_NAMES = ("semantic", "bm25", "graph", "temporal")
# User-facing priority levels. Kept in sync with recall_boost.BOOST_LEVELS by a
# guard test; defined here (not imported) so config stays free of the heavy
# engine.search import graph.
RECALL_BOOST_LEVELS = ("low", "medium", "high")
# Level applied when a strategy is listed without one (e.g. "graph" or "graph:").
DEFAULT_RECALL_BOOST_LEVEL = "medium"
def _parse_strategy_boosts(raw: str | None) -> dict[str, str]:
"""Parse a "strategy:level,strategy:level" string into a boost map.
A strategy listed without a level (``"graph"`` or ``"graph:"``) defaults to
``medium``. Only the strategies you list are boosted; any strategy you omit
keeps its normal, unboosted weight. Unknown strategy names, unknown levels,
and malformed entries are skipped with a warning so a typo degrades to a
no-op boost rather than breaking recall.
"""
if not raw or not raw.strip():
return {}
boosts: dict[str, str] = {}
for entry in raw.split(","):
entry = entry.strip()
if not entry:
continue
name, _sep, level = entry.partition(":")
name = name.strip().lower()
level = level.strip().lower() or DEFAULT_RECALL_BOOST_LEVEL
if name not in RECALL_STRATEGY_NAMES:
logger.warning(
"Ignoring unknown recall strategy %r in boost (valid: %s)", name, ", ".join(RECALL_STRATEGY_NAMES)
)
continue
if level not in RECALL_BOOST_LEVELS:
logger.warning(
"Ignoring unknown recall boost level %r for %r (valid: %s)",
level,
name,
", ".join(RECALL_BOOST_LEVELS),
)
continue
boosts[name] = level
return boosts
DEFAULT_RERANKER_FLASHRANK_MODEL = "ms-marco-MiniLM-L-12-v2" # Best balance of speed and quality
DEFAULT_RERANKER_FLASHRANK_CACHE_DIR = None # Use default cache directory
DEFAULT_RERANKER_FLASHRANK_CPU_MEM_ARENA = False # Disable ONNX CPU memory arena to bound RSS
@@ -659,6 +794,8 @@ 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_ENABLE_ADMIN_API = False # Admin surface (server config view) is off unless explicitly enabled
DEFAULT_ADMIN_API_TOKEN: str | None = None # None = admin API open (when enabled); set = required bearer token
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
@@ -667,6 +804,8 @@ 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
DEFAULT_BANK_STATS_CACHE_TTL_SECONDS = 60.0 # TTL for get_bank_stats result cache; 0 disables
DEFAULT_BANK_STATS_CACHE_MAX_ENTRIES = 1024 # LRU bound across (schema, bank) keys
# Retain settings
DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction LLM call
@@ -685,6 +824,7 @@ DEFAULT_RETAIN_BATCH_TOKENS = 10_000 # ~40KB of text # Max chars per sub-batch
DEFAULT_RETAIN_ENTITY_LOOKUP = "trigram" # "full" or "trigram"
DEFAULT_RETAIN_ENTITY_RESOLUTION_BATCH_SIZE = 100 # Unique entity names per pg_trgm candidate lookup query
DEFAULT_RETAIN_BATCH_ENABLED = False # Use LLM Batch API for fact extraction (only when async=True)
DEFAULT_LLM_PROMPT_CACHE_ENABLED = True # Reuse the fixed system prefix via provider prompt caching
DEFAULT_RETAIN_BATCH_POLL_INTERVAL_SECONDS = 60 # Batch API polling interval in seconds
# File storage defaults
@@ -696,28 +836,45 @@ DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE = 10 # Max files per batch upload
DEFAULT_ENABLE_FILE_UPLOAD_API = True # Enable file upload endpoint
DEFAULT_FILE_DELETE_AFTER_RETAIN = True # Delete file bytes after retain (saves storage)
# Document transfer defaults (export/import enabled by default; gated independently)
DEFAULT_ENABLE_DOCUMENT_EXPORT_API = True
DEFAULT_ENABLE_DOCUMENT_IMPORT_API = True
# Observations defaults (consolidated knowledge from facts)
DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default
DEFAULT_ENABLE_AUTO_CONSOLIDATION = True # Auto-consolidation after retain enabled by default
DEFAULT_ENABLE_OBSERVATION_HISTORY = True # Observation history tracking enabled by default
DEFAULT_ENABLE_MENTAL_MODEL_HISTORY = True # Mental model history tracking enabled by default
# Each history entry snapshots previous_content + previous_reflect_response. Without
# a cap, sustained mental-model refresh load grows the jsonb array unboundedly until
# it crosses Postgres's hard 256MB jsonb limit and subsequent UPDATEs fail with
# SQLSTATE 54000. 50 keeps the array well under 100MB even with large reflect
# responses, while preserving enough recent history for meaningful audit / rollback.
# History (mental-model refresh snapshots and observation update snapshots) lives in
# the dedicated mental_model_history / observation_history tables, one row per change.
# On every write we insert the new entry and delete the oldest rows beyond the cap,
# so the per-item history can never grow unboundedly (the old single-JSONB-column
# design hit Postgres's hard 256MB jsonb limit -> SQLSTATE 54000 and stuck rows).
# 50 preserves enough recent history for meaningful audit / rollback per item.
# A cap <= 0 removes the trim (unbounded growth) — to turn history OFF use the
# enable_* flag, not a zero cap.
DEFAULT_MENTAL_MODEL_HISTORY_MAX_ENTRIES = 50
DEFAULT_OBSERVATION_HISTORY_MAX_ENTRIES = 50
DEFAULT_CONSOLIDATION_MAX_ATTEMPTS = 3 # Outer retry attempts for consolidation LLM batch calls
DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization)
DEFAULT_CONSOLIDATION_MAX_MEMORIES_PER_ROUND = (
100 # Max memories per consolidation round (0 = unlimited). Limits how long one bank holds a worker slot.
)
DEFAULT_CONSOLIDATION_LLM_BATCH_SIZE = 8 # Facts per LLM call (1 = no batching; >1 = batch mode)
# Cosine >= this between a newly-created or freshly-updated observation and an existing one
# triggers a focused 1-by-1 LLM "merge or keep" pass (the LLM reads both, so numbers/negation/
# entities are respected). Enabled by default; set to 1.0 to disable. Postgres only — the merge
# path uses Postgres-only SQL, so consolidation skips it on Oracle regardless of this value.
DEFAULT_CONSOLIDATION_DEDUP_THRESHOLD = 0.97
DEFAULT_CONSOLIDATION_LLM_PARALLELISM = (
4 # Max tag groups consolidated concurrently per op. Locks on overlapping write
# scopes degrade to sequential automatically; matches retain_max_concurrent.
)
DEFAULT_CONSOLIDATION_MAX_TOKENS = 512 # Max tokens for recall when finding related observations
# Unset by default: the key is omitted from the LLM call so every provider keeps its current implicit output
# budget — 100% backwards compatible. Operators on providers with a low hidden default (notably Bedrock imported
# models, which cap at 4096 and truncate structured consolidation JSON) set this explicitly to fix #1939.
DEFAULT_CONSOLIDATION_MAX_COMPLETION_TOKENS = None
DEFAULT_CONSOLIDATION_RECALL_BUDGET = "low" # Budget level for consolidation recall (low/mid/high)
DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS = (
4096 # Total token budget for source facts in consolidation recall (-1 = unlimited)
@@ -737,6 +894,7 @@ DEFAULT_DB_POOL_MAX_SIZE = 100
DEFAULT_DB_COMMAND_TIMEOUT = 60 # seconds
DEFAULT_DB_ACQUIRE_TIMEOUT = 30 # seconds
DEFAULT_DB_STATEMENT_TIMEOUT = 600 # seconds (Postgres statement_timeout applied on every pool connection; 0 disables)
DEFAULT_MODEL_INIT_TIMEOUT = 300 # seconds (cap on startup model/connection init; covers first-time downloads)
# Worker configuration (distributed task processing)
DEFAULT_WORKER_ENABLED = True # API runs worker by default (standalone mode)
@@ -789,6 +947,18 @@ 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
# LLM request tracing defaults
DEFAULT_LLM_TRACE_ENABLED = True # Enabled by default
DEFAULT_LLM_TRACE_SCOPES = "" # Empty = trace all call scopes
DEFAULT_LLM_TRACE_RETENTION_DAYS = 1 # Retain trace rows for 1 day by default
DEFAULT_LLM_TRACE_MAX_CHARS = 50000 # Truncate stored input/output beyond this many chars
# Background maintenance defaults
# Periodic reconcile that re-schedules consolidation for banks with eligible-but-unscheduled
# facts (e.g. after a consolidation operation failed terminally and left them unscheduled).
# 0 disables the reconcile sweep.
DEFAULT_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS = 300
# Default MCP tool descriptions (can be customized via env vars)
DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory.
@@ -1052,6 +1222,7 @@ class HindsightConfig:
llm_default_headers: (
dict | None
) # Custom headers passed as default_headers to provider SDK clients (e.g. {"X-Component-Id": "hindsight"} for proxies / request tracing)
llm_strict_schema: bool # Grammar-enforce structured output via the provider's strongest schema mode (see DEFAULT_LLM_STRICT_SCHEMA)
# LiteLLM Router chain (provider-specific; consumed by the "litellmrouter" provider).
# List of deployment dicts evaluated in order with fallback on transient errors.
@@ -1067,6 +1238,10 @@ class HindsightConfig:
# Gemini safety settings (None = use Gemini defaults; list of dicts with category/threshold)
llm_gemini_safety_settings: list | None
# Gemini prompt caching toggle. When True, retain extraction reuses a
# CachedContent prefix for its system prompt + response schema.
llm_prompt_cache_enabled: bool
# 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
@@ -1119,6 +1294,17 @@ class HindsightConfig:
embeddings_local_model: str
embeddings_local_force_cpu: bool
embeddings_local_trust_remote_code: bool
embeddings_onnx_model_id: str
embeddings_onnx_model_path: str | None
embeddings_onnx_tokenizer_name_or_path: str | None
embeddings_onnx_file: str
embeddings_onnx_dimensions: int | None
embeddings_onnx_max_tokens: int
embeddings_onnx_pooling: str
embeddings_onnx_normalize: bool
embeddings_onnx_query_prefix: str
embeddings_onnx_passage_prefix: str
embeddings_onnx_output_name: str | None
embeddings_tei_url: str | None
embeddings_openai_base_url: str | None
embeddings_cohere_api_key: str | None
@@ -1158,6 +1344,10 @@ class HindsightConfig:
reranker_tei_max_concurrent: int
reranker_tei_http_timeout: float
reranker_max_candidates: int
semantic_min_similarity: float
bm25_min_score: float
recall_max_candidates_per_source: int
recall_strategy_boosts: dict[str, str]
reranker_cohere_api_key: str | None
reranker_cohere_model: str
reranker_cohere_base_url: str | None
@@ -1201,6 +1391,10 @@ class HindsightConfig:
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
# Admin surface (static, server-level only). enable_admin_api gates the /admin API +
# control-plane page; admin_api_token (when set) is the required bearer token.
enable_admin_api: bool
admin_api_token: str | None
# 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
@@ -1213,6 +1407,8 @@ class HindsightConfig:
mental_model_refresh_concurrency: int
link_expansion_per_entity_limit: int
link_expansion_timeout: float
bank_stats_cache_ttl_seconds: float
bank_stats_cache_max_entries: int
# Retain settings
retain_max_completion_tokens: int
@@ -1251,18 +1447,23 @@ class HindsightConfig:
file_conversion_max_batch_size: int # Max files per request
enable_file_upload_api: bool
file_delete_after_retain: bool
enable_document_export_api: bool
enable_document_import_api: bool
# Observations settings (consolidated knowledge from facts)
enable_observations: bool
enable_auto_consolidation: bool
enable_observation_history: bool
observation_history_max_entries: int
enable_mental_model_history: bool
mental_model_history_max_entries: int
consolidation_batch_size: int
consolidation_dedup_threshold: float
consolidation_max_memories_per_round: int
consolidation_llm_batch_size: int
consolidation_llm_parallelism: int
consolidation_max_tokens: int
consolidation_max_completion_tokens: int | None
consolidation_recall_budget: str
consolidation_source_facts_max_tokens: int
consolidation_source_facts_max_tokens_per_observation: int
@@ -1318,6 +1519,7 @@ class HindsightConfig:
db_command_timeout: int
db_acquire_timeout: int
db_statement_timeout: int
model_init_timeout: float
# Worker configuration (distributed task processing)
worker_enabled: bool
@@ -1349,6 +1551,17 @@ class HindsightConfig:
audit_log_actions: list[str] # Allowlist of action types (empty = all)
audit_log_retention_days: int # -1 = keep forever, >0 = delete after N days
# LLM request tracing configuration (static - server-level only)
llm_trace_enabled: bool # Master switch for per-bank LLM request tracing
llm_trace_scopes: list[str] # Allowlist of call scopes to trace (empty = all)
llm_trace_retention_days: int # -1 = keep forever, >0 = delete after N days
llm_trace_max_chars: int # Truncate stored input/output beyond this many chars
# Background maintenance configuration (static - server-level only)
# Interval for the periodic sweep that re-schedules consolidation for banks with
# eligible-but-unscheduled facts. 0 = disabled.
consolidation_reconcile_interval_seconds: int
# Webhook configuration (static - server-level only, not per-bank)
webhook_url: str | None # Global webhook URL (None = disabled)
webhook_secret: str | None # HMAC signing secret (None = unsigned)
@@ -1407,6 +1620,8 @@ class HindsightConfig:
# File parser credentials
"file_parser_iris_token",
"file_parser_llama_parse_api_key",
# Admin surface token (never exposed via the admin config view itself)
"admin_api_token",
}
# CONFIGURABLE_FIELDS: Safe behavioral settings that can be customized per-tenant/bank
@@ -1549,6 +1764,11 @@ class HindsightConfig:
self.text_search_extension_pg_search_tokenizer
)
if not 0.0 <= self.semantic_min_similarity <= 1.0:
raise ValueError(
f"Invalid semantic_min_similarity: {self.semantic_min_similarity}. Must be between 0.0 and 1.0"
)
# When LLM provider is "none", force chunks-only mode and disable LLM-dependent features
if self.llm_provider == "none":
self.retain_extraction_mode = "chunks"
@@ -1597,6 +1817,21 @@ class HindsightConfig:
" and ".join(missing),
)
if self.embeddings_provider == "onnx":
try:
import importlib
importlib.import_module("onnxruntime")
importlib.import_module("transformers")
except ImportError:
logger.warning(
"ONNX embeddings provider configured, but 'onnxruntime' and/or "
"'transformers' is not installed. The API will fail at model init time. Either:\n"
" 1. Install ONNX deps: pip install hindsight-api-slim[local-onnx]\n"
" 2. Use a different embeddings provider, e.g. HINDSIGHT_API_EMBEDDINGS_PROVIDER=local "
"or openai"
)
# Validate that sum of per-operation slot reservations does not exceed max_slots
total_reserved = sum(self.worker_slot_reservations.values())
if total_reserved > self.worker_max_slots:
@@ -1649,6 +1884,7 @@ class HindsightConfig:
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")),
llm_default_headers=json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null")),
llm_strict_schema=os.getenv(ENV_LLM_STRICT_SCHEMA, str(DEFAULT_LLM_STRICT_SCHEMA)).lower() in ("true", "1"),
llm_litellmrouter_config=_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG),
# Vertex AI
llm_vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or DEFAULT_LLM_VERTEXAI_PROJECT_ID,
@@ -1657,6 +1893,10 @@ 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")),
llm_prompt_cache_enabled=os.getenv(
ENV_LLM_PROMPT_CACHE_ENABLED, str(DEFAULT_LLM_PROMPT_CACHE_ENABLED)
).lower()
in ("1", "true", "yes", "on"),
# 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))),
@@ -1755,6 +1995,36 @@ class HindsightConfig:
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE, str(DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE)
).lower()
in ("true", "1"),
embeddings_onnx_model_id=os.getenv(ENV_EMBEDDINGS_ONNX_MODEL_ID, DEFAULT_EMBEDDINGS_ONNX_MODEL_ID),
embeddings_onnx_model_path=os.getenv(ENV_EMBEDDINGS_ONNX_MODEL_PATH) or None,
embeddings_onnx_tokenizer_name_or_path=os.getenv(ENV_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH) or None,
embeddings_onnx_file=os.getenv(ENV_EMBEDDINGS_ONNX_FILE, DEFAULT_EMBEDDINGS_ONNX_FILE),
embeddings_onnx_dimensions=_parse_optional_positive_int(
ENV_EMBEDDINGS_ONNX_DIMENSIONS,
os.getenv(ENV_EMBEDDINGS_ONNX_DIMENSIONS),
),
embeddings_onnx_max_tokens=_parse_positive_int(
ENV_EMBEDDINGS_ONNX_MAX_TOKENS,
os.getenv(ENV_EMBEDDINGS_ONNX_MAX_TOKENS),
DEFAULT_EMBEDDINGS_ONNX_MAX_TOKENS,
),
embeddings_onnx_pooling=_parse_optional_choice(
ENV_EMBEDDINGS_ONNX_POOLING,
os.getenv(ENV_EMBEDDINGS_ONNX_POOLING),
frozenset({"mean", "cls"}),
)
or DEFAULT_EMBEDDINGS_ONNX_POOLING,
embeddings_onnx_normalize=os.getenv(
ENV_EMBEDDINGS_ONNX_NORMALIZE, str(DEFAULT_EMBEDDINGS_ONNX_NORMALIZE)
).lower()
in ("true", "1"),
embeddings_onnx_query_prefix=os.getenv(
ENV_EMBEDDINGS_ONNX_QUERY_PREFIX, DEFAULT_EMBEDDINGS_ONNX_QUERY_PREFIX
),
embeddings_onnx_passage_prefix=os.getenv(
ENV_EMBEDDINGS_ONNX_PASSAGE_PREFIX, DEFAULT_EMBEDDINGS_ONNX_PASSAGE_PREFIX
),
embeddings_onnx_output_name=os.getenv(ENV_EMBEDDINGS_ONNX_OUTPUT_NAME) or None,
embeddings_tei_url=os.getenv(ENV_EMBEDDINGS_TEI_URL),
embeddings_openai_base_url=os.getenv(ENV_EMBEDDINGS_OPENAI_BASE_URL) or None,
embeddings_openai_batch_size=_parse_positive_int(
@@ -1876,6 +2146,14 @@ class HindsightConfig:
os.getenv(ENV_RERANKER_TEI_HTTP_TIMEOUT, str(DEFAULT_RERANKER_TEI_HTTP_TIMEOUT))
),
reranker_max_candidates=int(os.getenv(ENV_RERANKER_MAX_CANDIDATES, str(DEFAULT_RERANKER_MAX_CANDIDATES))),
semantic_min_similarity=float(os.getenv(ENV_SEMANTIC_MIN_SIMILARITY, str(DEFAULT_SEMANTIC_MIN_SIMILARITY))),
bm25_min_score=float(os.getenv(ENV_BM25_MIN_SCORE, str(DEFAULT_BM25_MIN_SCORE))),
recall_max_candidates_per_source=int(
os.getenv(ENV_RECALL_MAX_CANDIDATES_PER_SOURCE, str(DEFAULT_RECALL_MAX_CANDIDATES_PER_SOURCE))
),
recall_strategy_boosts=_parse_strategy_boosts(
os.getenv(ENV_RECALL_STRATEGY_BOOSTS, DEFAULT_RECALL_STRATEGY_BOOSTS)
),
# Cohere reranker (with backward-compatible fallback to shared API key)
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),
@@ -1950,6 +2228,8 @@ class HindsightConfig:
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",
enable_admin_api=os.getenv(ENV_ENABLE_ADMIN_API, str(DEFAULT_ENABLE_ADMIN_API)).lower() == "true",
admin_api_token=os.getenv(ENV_ADMIN_API_TOKEN) or DEFAULT_ADMIN_API_TOKEN,
default_bank_template=_parse_default_bank_template(os.getenv(ENV_DEFAULT_BANK_TEMPLATE)),
# Recall
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
@@ -1965,6 +2245,12 @@ class HindsightConfig:
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))),
bank_stats_cache_ttl_seconds=float(
os.getenv(ENV_BANK_STATS_CACHE_TTL_SECONDS, str(DEFAULT_BANK_STATS_CACHE_TTL_SECONDS))
),
bank_stats_cache_max_entries=int(
os.getenv(ENV_BANK_STATS_CACHE_MAX_ENTRIES, str(DEFAULT_BANK_STATS_CACHE_MAX_ENTRIES))
),
# Optimization flags
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
@@ -2028,6 +2314,14 @@ class HindsightConfig:
ENV_FILE_DELETE_AFTER_RETAIN, str(DEFAULT_FILE_DELETE_AFTER_RETAIN)
).lower()
== "true",
enable_document_export_api=os.getenv(
ENV_ENABLE_DOCUMENT_EXPORT_API, str(DEFAULT_ENABLE_DOCUMENT_EXPORT_API)
).lower()
== "true",
enable_document_import_api=os.getenv(
ENV_ENABLE_DOCUMENT_IMPORT_API, str(DEFAULT_ENABLE_DOCUMENT_IMPORT_API)
).lower()
== "true",
# Observations settings (consolidated knowledge from facts)
enable_observations=os.getenv(ENV_ENABLE_OBSERVATIONS, str(DEFAULT_ENABLE_OBSERVATIONS)).lower() == "true",
enable_auto_consolidation=os.getenv(
@@ -2038,6 +2332,12 @@ class HindsightConfig:
ENV_ENABLE_OBSERVATION_HISTORY, str(DEFAULT_ENABLE_OBSERVATION_HISTORY)
).lower()
== "true",
observation_history_max_entries=int(
os.getenv(
ENV_OBSERVATION_HISTORY_MAX_ENTRIES,
str(DEFAULT_OBSERVATION_HISTORY_MAX_ENTRIES),
)
),
enable_mental_model_history=os.getenv(
ENV_ENABLE_MENTAL_MODEL_HISTORY, str(DEFAULT_ENABLE_MENTAL_MODEL_HISTORY)
).lower()
@@ -2057,6 +2357,9 @@ class HindsightConfig:
str(DEFAULT_CONSOLIDATION_MAX_MEMORIES_PER_ROUND),
)
),
consolidation_dedup_threshold=float(
os.getenv(ENV_CONSOLIDATION_DEDUP_THRESHOLD, str(DEFAULT_CONSOLIDATION_DEDUP_THRESHOLD))
),
consolidation_llm_batch_size=int(
os.getenv(ENV_CONSOLIDATION_LLM_BATCH_SIZE, str(DEFAULT_CONSOLIDATION_LLM_BATCH_SIZE))
),
@@ -2072,6 +2375,11 @@ class HindsightConfig:
consolidation_max_tokens=int(
os.getenv(ENV_CONSOLIDATION_MAX_TOKENS, str(DEFAULT_CONSOLIDATION_MAX_TOKENS))
),
consolidation_max_completion_tokens=(
int(os.getenv(ENV_CONSOLIDATION_MAX_COMPLETION_TOKENS))
if os.getenv(ENV_CONSOLIDATION_MAX_COMPLETION_TOKENS)
else DEFAULT_CONSOLIDATION_MAX_COMPLETION_TOKENS
),
consolidation_recall_budget=os.getenv(ENV_CONSOLIDATION_RECALL_BUDGET, DEFAULT_CONSOLIDATION_RECALL_BUDGET),
consolidation_source_facts_max_tokens=int(
os.getenv(ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS, str(DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS))
@@ -2099,6 +2407,7 @@ class HindsightConfig:
db_command_timeout=int(os.getenv(ENV_DB_COMMAND_TIMEOUT, str(DEFAULT_DB_COMMAND_TIMEOUT))),
db_acquire_timeout=int(os.getenv(ENV_DB_ACQUIRE_TIMEOUT, str(DEFAULT_DB_ACQUIRE_TIMEOUT))),
db_statement_timeout=int(os.getenv(ENV_DB_STATEMENT_TIMEOUT, str(DEFAULT_DB_STATEMENT_TIMEOUT))),
model_init_timeout=float(os.getenv(ENV_MODEL_INIT_TIMEOUT, str(DEFAULT_MODEL_INIT_TIMEOUT))),
# Worker configuration
worker_enabled=os.getenv(ENV_WORKER_ENABLED, str(DEFAULT_WORKER_ENABLED)).lower() == "true",
worker_id=os.getenv(ENV_WORKER_ID) or DEFAULT_WORKER_ID,
@@ -2183,6 +2492,22 @@ class HindsightConfig:
audit_log_retention_days=int(
os.getenv(ENV_AUDIT_LOG_RETENTION_DAYS, str(DEFAULT_AUDIT_LOG_RETENTION_DAYS))
),
# LLM request tracing configuration (static, server-level only)
llm_trace_enabled=os.getenv(ENV_LLM_TRACE_ENABLED, str(DEFAULT_LLM_TRACE_ENABLED)).lower() == "true",
llm_trace_scopes=[
s.strip() for s in os.getenv(ENV_LLM_TRACE_SCOPES, DEFAULT_LLM_TRACE_SCOPES).split(",") if s.strip()
],
llm_trace_retention_days=int(
os.getenv(ENV_LLM_TRACE_RETENTION_DAYS, str(DEFAULT_LLM_TRACE_RETENTION_DAYS))
),
llm_trace_max_chars=int(os.getenv(ENV_LLM_TRACE_MAX_CHARS, str(DEFAULT_LLM_TRACE_MAX_CHARS))),
# Background maintenance configuration (static, server-level only)
consolidation_reconcile_interval_seconds=int(
os.getenv(
ENV_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS,
str(DEFAULT_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS),
)
),
# 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,
@@ -266,12 +266,20 @@ class ConfigResolver:
# Validate recall budget fields
_validate_recall_budget_updates(normalized_updates)
# Merge with existing config (JSONB || operator)
# Persist the override. Banks are created lazily (on first retain), so a
# PATCH that precedes any ingestion would otherwise UPDATE zero rows and
# silently no-op while returning 200. Ensure the bank row exists first
# (this also creates its per-bank vector indexes), then merge defensively:
# COALESCE guards against a NULL config column (NULL || jsonb is NULL),
# which would drop the override even when a row is updated.
from .engine.retain.fact_storage import ensure_bank_exists
async with self._backend.acquire() as conn:
await ensure_bank_exists(conn, bank_id, ops=self._backend.ops)
await conn.execute(
f"""
UPDATE {fq_table("banks")}
SET config = config || $1::jsonb,
SET config = COALESCE(config, '{{}}'::jsonb) || $1::jsonb,
updated_at = now()
WHERE bank_id = $2
""",
@@ -16,11 +16,59 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from pydantic import BaseModel, Field
from ..engine.db_utils import acquire_with_retry
logger = logging.getLogger(__name__)
class AuditLogEntry(BaseModel):
"""A single audit log entry."""
id: str
action: str
transport: str
bank_id: str | None
started_at: str | None
ended_at: str | None
duration_ms: int | None = Field(
default=None,
description="Server-computed duration in milliseconds (started_at → ended_at). Null if not yet completed.",
)
request: dict[str, Any] | None
response: dict[str, Any] | None
metadata: dict[str, Any]
class AuditLogListResponse(BaseModel):
"""Response model for list audit logs endpoint."""
bank_id: str
total: int
limit: int
offset: int
items: list[AuditLogEntry]
class AuditLogStatsBucket(BaseModel):
"""A single time bucket in audit log stats."""
time: str
actions: dict[str, int]
total: int
class AuditLogStatsResponse(BaseModel):
"""Response model for audit log stats endpoint."""
bank_id: str
period: str
trunc: str
start: str
buckets: list[AuditLogStatsBucket]
@dataclass
class AuditEntry:
"""A single audit log entry."""
@@ -59,11 +107,11 @@ def _safe_json(data: Any) -> str | None:
return None
_SWEEP_INTERVAL_SECONDS = 3600 # Run retention sweep every hour
class AuditLogger:
"""Fire-and-forget audit log writer with optional retention sweep."""
"""Fire-and-forget audit log writer.
Retention of old rows is handled by the background :class:`MaintenanceLoop`.
"""
def __init__(
self,
@@ -71,14 +119,11 @@ class AuditLogger:
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."""
@@ -128,48 +173,6 @@ class AuditLogger:
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(
@@ -0,0 +1,122 @@
"""TTL + coalescing cache for `get_bank_stats`.
`get_bank_stats` aggregates over `memory_links` (and joins to `memory_units`),
which can be a multi-second parallel sequential scan on banks with millions of
rows. The result is intentionally approximate (it powers a UI widget and a
freshness hint inside `reflect`), so caching it for a few tens of seconds is
safe and dramatically reduces planner-driven thrash from clients that poll.
The cache also coalesces concurrent misses on the same key onto a single
in-flight task so that N concurrent callers produce one query rather than N.
"""
from __future__ import annotations
import asyncio
import time
from collections import OrderedDict
from typing import Any, Awaitable, Callable
class BankStatsCache:
"""Per-process TTL cache keyed on (schema, bank_id).
`ttl_seconds <= 0` disables caching: each call passes straight through to
the loader. `max_entries` bounds memory in environments with many banks.
"""
def __init__(self, *, ttl_seconds: float, max_entries: int) -> None:
self._ttl = float(ttl_seconds)
self._max_entries = int(max_entries) if max_entries and max_entries > 0 else 0
self._entries: OrderedDict[tuple[str, str], tuple[float, dict[str, Any]]] = OrderedDict()
self._in_flight: dict[tuple[str, str], asyncio.Future[dict[str, Any]]] = {}
self._lock = asyncio.Lock()
@property
def enabled(self) -> bool:
return self._ttl > 0
def _now(self) -> float:
return time.monotonic()
def _get_fresh_unlocked(self, key: tuple[str, str]) -> dict[str, Any] | None:
entry = self._entries.get(key)
if entry is None:
return None
expires_at, value = entry
if expires_at <= self._now():
# Expired — drop so the loader runs again.
self._entries.pop(key, None)
return None
# Mark as recently used for LRU eviction.
self._entries.move_to_end(key)
return value
def _store_unlocked(self, key: tuple[str, str], value: dict[str, Any]) -> None:
if not self.enabled:
return
self._entries[key] = (self._now() + self._ttl, value)
self._entries.move_to_end(key)
if self._max_entries:
while len(self._entries) > self._max_entries:
self._entries.popitem(last=False)
async def get_or_load(
self,
schema: str,
bank_id: str,
loader: Callable[[], Awaitable[dict[str, Any]]],
) -> dict[str, Any]:
"""Return cached stats for `(schema, bank_id)` or call `loader()`.
Concurrent misses on the same key are coalesced onto a single
in-flight loader.
"""
if not self.enabled:
return await loader()
key = (schema, bank_id)
async with self._lock:
cached = self._get_fresh_unlocked(key)
if cached is not None:
return cached
in_flight = self._in_flight.get(key)
if in_flight is None:
in_flight = asyncio.get_running_loop().create_future()
self._in_flight[key] = in_flight
is_owner = True
else:
is_owner = False
if not is_owner:
return await asyncio.shield(in_flight)
try:
value = await loader()
except BaseException as exc:
async with self._lock:
self._in_flight.pop(key, None)
if not in_flight.done():
in_flight.set_exception(exc)
# Suppress "Future exception was never retrieved" when no other
# caller was waiting on this loader — we re-raise to the owner
# immediately and the future is a no-op in that case.
in_flight.exception()
raise
async with self._lock:
self._store_unlocked(key, value)
self._in_flight.pop(key, None)
if not in_flight.done():
in_flight.set_result(value)
return value
async def invalidate(self, schema: str, bank_id: str) -> None:
"""Drop any cached stats for `(schema, bank_id)`."""
async with self._lock:
self._entries.pop((schema, bank_id), None)
async def clear(self) -> None:
async with self._lock:
self._entries.clear()
@@ -22,19 +22,30 @@ import time
import uuid
from collections import defaultdict
from contextlib import AsyncExitStack
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from itertools import combinations
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal
from pydantic import BaseModel, field_validator
from ...config import get_config
from ...worker.stage import set_stage
from ..db_utils import acquire_with_retry
from ..llm_trace import (
record_created_memory_ids,
record_source_memory_ids,
reset_trace_context,
set_trace_context,
trace_context_of,
)
from ..llm_wrapper import sanitize_llm_output
from ..memory_engine import Budget, fq_table
from ..retain import embedding_utils
from .prompts import build_batch_consolidation_prompt
from .prompts import (
build_consolidation_input,
build_consolidation_system_prompt,
)
if TYPE_CHECKING:
from asyncpg import Connection
@@ -46,6 +57,254 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _norm_obs_text(text: str) -> str:
"""Whitespace-normalised observation text for exact-duplicate matching.
Collapses runs of whitespace only; case is preserved. The reconciliation guard
drops a CREATE on the premise that an exact-text match loses no information — but
case-folding would also drop a create differing only in case (e.g. "TLS" vs "tls"),
which *does* lose information, so we match case-sensitively.
"""
return " ".join((text or "").split()).strip()
def _duplicate_create_target(
create_text: str,
shown_obs_by_text: "dict[str, MemoryFact]",
update_texts: set[str],
) -> str | None:
"""Return a human label for what ``create_text`` duplicates, or None if novel.
A CREATE is a duplicate when its normalised text matches an observation that was
already shown to the LLM, or the text of an UPDATE issued in the same response
(the model occasionally UPDATEs the twin to text X and also CREATEs X). Exact-text
match means no information is lost by dropping the CREATE.
"""
norm = _norm_obs_text(create_text)
matched = shown_obs_by_text.get(norm)
if matched is not None:
return f"shown observation {str(matched.id)[:8]}"
if norm in update_texts:
return "an UPDATE in this response"
return None
# Top-K existing observations probed (by the new observation's own embedding) when
# semantic dedup is enabled. Small: we only need the nearest few candidates.
_DEDUP_TOP_K = 5
class _DedupDecision(BaseModel):
"""Focused 1-by-1 verdict for whether a new observation duplicates an existing one."""
action: Literal["merge", "keep"]
text: str = "" # the synthesized merged observation (when action == "merge")
reason: str = ""
_DEDUP_PROMPT = """You reconcile long-term memory observations. A NEW observation is about to be \
stored, and it is highly similar to an EXISTING one:
[NEW] {new}
[EXISTING] {existing}
If they assert the SAME fact (wording aside), respond action="merge" and provide `text`: a single \
observation that preserves EVERY detail from both. If they differ in ANY important detail — a \
number/quantity, a named entity or language, a negation, or a condition — respond action="keep"."""
def _dedup_active(config: Any) -> bool:
"""Whether create/update semantic dedup runs for this consolidation.
Enabled when the resolved threshold is < 1.0, EXCEPT on Oracle: the merge path uses
Postgres-only SQL (``unnest``/``array_agg``, ``UPDATE ... FROM``), so on Oracle dedup is
skipped — it behaves exactly as it did before this feature, regardless of the configured
threshold. This is why the feature can ship enabled-by-default without breaking Oracle.
"""
if config is None or getattr(config, "consolidation_dedup_threshold", 1.0) >= 1.0:
return False
return get_config().database_backend != "oracle"
@dataclass
class _DedupOutcome:
"""Result of probing one observation against its in-scope neighbours.
``best_id`` is the nearest observation at/above the threshold (None if none),
``merged_text`` is the LLM-synthesized union text (set only when ``should_merge``).
"""
best_id: str | None
merged_text: str
should_merge: bool
async def _dedup_adjudicate(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
config: Any,
dedup_llm_config: Any,
anchor_text: str,
anchor_emb_str: str | None,
tags: list[str] | None,
exclude_id: str | None,
) -> _DedupOutcome:
"""Probe one observation's embedding against in-scope observations and adjudicate a merge.
Anchored on the observation text — the correct obs<->obs comparison, unlike consolidation
recall which is anchored on the raw fact. Returns the nearest observation at/above
``consolidation_dedup_threshold`` and, when found, the LLM's focused 1-by-1 merge-or-keep
verdict (scope ``consolidation_dedup``): the LLM reads both texts, so a word-level difference
(number / negation / entity) is respected. ``exclude_id`` skips the anchor observation itself
(used by the UPDATE path, where the anchor row already exists and would self-match at 1.0).
``anchor_emb_str`` reuses an already-computed embedding (the UPDATE path just embedded it);
pass None to embed ``anchor_text`` here (the CREATE path).
"""
from ..search.retrieval import retrieve_semantic_bm25_combined
threshold = config.consolidation_dedup_threshold
if anchor_emb_str is None:
embs = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [anchor_text])
if not embs:
return _DedupOutcome(best_id=None, merged_text="", should_merge=False)
anchor_emb_str = str(embs[0])
tags_match = "all_strict" if tags else "any"
grouped = await retrieve_semantic_bm25_combined(
conn, anchor_emb_str, anchor_text, bank_id, ["observation"], _DEDUP_TOP_K, tags=tags, tags_match=tags_match
)
results = grouped.get("observation", ([], []))[0]
best_id: str | None = None
best_text = ""
best_sim = threshold # only candidates at/above the threshold are considered
for r in results:
rid = str(r.id)
if exclude_id is not None and rid == exclude_id:
continue # never match the anchor observation against itself
sim = r.similarity or 0.0
if sim >= best_sim:
best_id, best_text, best_sim = rid, r.text, sim
if best_id is None:
return _DedupOutcome(best_id=None, merged_text="", should_merge=False)
decision: _DedupDecision = await dedup_llm_config.call(
messages=[{"role": "user", "content": _DEDUP_PROMPT.format(new=anchor_text, existing=best_text)}],
response_format=_DedupDecision,
scope="consolidation_dedup",
)
if decision.action != "merge":
return _DedupOutcome(best_id=best_id, merged_text="", should_merge=False)
return _DedupOutcome(best_id=best_id, merged_text=decision.text.strip() or best_text, should_merge=True)
async def _dedup_reconcile_create(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
config: Any,
dedup_llm_config: Any,
create_text: str,
create_source_ids: list[uuid.UUID],
tags: list[str] | None,
) -> str | None:
"""Semantic dedup for a single CREATE (create-time, focused 1-by-1).
On "merge", folds the new source facts + the synthesized text into the existing
observation and returns its id (caller skips the CREATE). Returns None when there is
no near twin or the LLM keeps them distinct.
"""
outcome = await _dedup_adjudicate(
conn, memory_engine, bank_id, config, dedup_llm_config, create_text, None, tags, exclude_id=None
)
if not outcome.should_merge or outcome.best_id is None:
return None
# Fold the new source facts into the twin and persist the merged text. We keep the twin's
# existing embedding: the merged text is >= threshold similar, so the stored vector stays
# representative and we avoid a re-embed + a dialect-specific vector UPDATE.
await conn.execute(
f"""
UPDATE {fq_table("memory_units")}
SET text = $1,
source_memory_ids = (SELECT array_agg(DISTINCT e) FROM unnest(source_memory_ids || $2::uuid[]) e),
proof_count = (SELECT count(DISTINCT e) FROM unnest(source_memory_ids || $2::uuid[]) e),
updated_at = now()
WHERE id = $3::uuid
""",
outcome.merged_text,
create_source_ids,
uuid.UUID(outcome.best_id),
)
return outcome.best_id
async def _dedup_reconcile_update(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
config: Any,
dedup_llm_config: Any,
updated_id: str,
updated_text: str,
updated_emb_str: str | None,
tags: list[str] | None,
) -> None:
"""Semantic dedup for an UPDATE (after the observation was rewritten + re-embedded).
An UPDATE rewrites an observation's text and re-embeds it, so its vector can drift to
within threshold of a DIFFERENT existing observation. The create-time guard never sees
this (it only runs on CREATE), so without this the two persist as a near-duplicate pair —
the measured residual-duplicate source. Probe the updated observation's new embedding
against the others (excluding itself); on "merge", fold the just-updated observation's
sources into the twin, persist the merged text, and DELETE the updated row. Unlike the
CREATE path the row already exists, so reconciliation is a fold-and-delete, not a skip.
"""
outcome = await _dedup_adjudicate(
conn,
memory_engine,
bank_id,
config,
dedup_llm_config,
updated_text,
updated_emb_str,
tags,
exclude_id=updated_id,
)
if not outcome.should_merge or outcome.best_id is None:
return
# Fold the updated observation's sources into the twin (keeping the twin's embedding, as in
# the create path) then delete the now-redundant updated row. The all_strict/any tag match
# guarantees twin and updated share scope, so dropping the updated row's tags loses no
# visibility. Temporal fields follow the surviving twin (minimal scope; matches create).
await conn.execute(
f"""
UPDATE {fq_table("memory_units")} t
SET text = $1,
source_memory_ids = (
SELECT array_agg(DISTINCT e) FROM unnest(t.source_memory_ids || u.source_memory_ids) e
),
proof_count = (
SELECT count(DISTINCT e) FROM unnest(t.source_memory_ids || u.source_memory_ids) e
),
updated_at = now()
FROM {fq_table("memory_units")} u
WHERE t.id = $2::uuid AND u.id = $3::uuid
""",
outcome.merged_text,
uuid.UUID(outcome.best_id),
uuid.UUID(updated_id),
)
await _execute_delete_action(conn, bank_id, updated_id)
logger.info(
"[CONSOLIDATION] dedup-merged updated observation %s into %s (cosine>=%.2f)",
updated_id[:8],
outcome.best_id[:8],
config.consolidation_dedup_threshold,
)
@dataclass
class _BatchDeltas:
"""Per-LLM-batch deltas, merged into the job's running stats after dispatch.
@@ -167,6 +426,9 @@ async def _filter_live_source_memories(
class _CreateAction(BaseModel):
text: str
source_fact_ids: list[str] # memory UUIDs from the NEW FACTS list
# One-sentence justification from the LLM (why CREATE vs UPDATE). Diagnostic
# only — surfaced in the consolidation trace to explain duplicate creates.
reason: str = ""
@field_validator("text", mode="before")
@classmethod
@@ -178,6 +440,7 @@ class _UpdateAction(BaseModel):
text: str
observation_id: str # UUID of the existing observation to update
source_fact_ids: list[str] # memory UUIDs from the NEW FACTS list
reason: str = "" # LLM's one-sentence justification (diagnostic only)
@field_validator("text", mode="before")
@classmethod
@@ -187,6 +450,7 @@ class _UpdateAction(BaseModel):
class _DeleteAction(BaseModel):
observation_id: str # UUID of the observation to remove
reason: str = "" # LLM's one-sentence justification (diagnostic only)
class _ConsolidationBatchResponse(BaseModel):
@@ -361,8 +625,36 @@ async def run_consolidation_job(
# Build a configured LLM wrapper that applies per-bank settings (e.g. safety settings)
# to every call without leaking across operations.
llm_config = memory_engine._consolidation_llm_config.with_config(config)
llm_config = memory_engine._consolidation_llm_config.with_config(config, bank_id=bank_id, operation="consolidation")
# Bind the operation trace context for the whole run so the create/update DB
# sites (deep inside _process_memory_batch) can accumulate the observations
# this consolidation produced and the source memories it consumed onto the
# trace — flushed onto every trace row on exit by attach_memory_ids.
trace_ctx = trace_context_of(llm_config)
trace_token = set_trace_context(trace_ctx) if trace_ctx is not None else None
try:
return await _run_consolidation_job(
memory_engine, bank_id, request_context, config, llm_config, operation_id, observation_scopes
)
finally:
if trace_token is not None:
reset_trace_context(trace_token)
# Fire-and-forget: patched on a background task, off the consolidation
# critical path.
memory_engine._llm_recorder.attach_memory_ids(trace_ctx)
async def _run_consolidation_job(
memory_engine: "MemoryEngine",
bank_id: str,
request_context: "RequestContext",
config: Any,
llm_config: Any,
operation_id: str | None = None,
observation_scopes: list[list[str]] | None = None,
) -> dict[str, Any]:
"""Core consolidation flow. See ``run_consolidation_job`` for the public entrypoint."""
perf = ConsolidationPerfLog(bank_id)
max_memories_per_batch = config.consolidation_batch_size
max_memories_per_round = config.consolidation_max_memories_per_round
@@ -426,6 +718,44 @@ async def run_consolidation_job(
logger.info(f"[CONSOLIDATION] bank={bank_id} total_unconsolidated={total_count}")
perf.log(f"[1] Found {total_count} pending memories to consolidate")
# Initial durable progress snapshot so an operator polling the operation status
# API sees the job has started and how much work it found, before the first batch
# of LLM work completes (which can take minutes on a dense bank). Uses the same
# "consolidating" stage as the per-batch heartbeat so the operator sees a single
# phase advancing 0/N -> N/N rather than an opaque "scanning" -> "processing" hop.
set_stage("consolidation.consolidating")
await memory_engine._write_operation_progress(operation_id, stage="consolidating", processed=0, total=total_count)
async def _count_unconsolidated() -> int:
"""Re-count memories still pending consolidation in this job's scope.
``total_count`` is a point-in-time estimate from job start; memories retained
while consolidation runs get picked up by later fetches, so processed can pass
it. When that happens we re-count to report a real total (processed + remaining)
instead of pinning the bar at 100%."""
async with acquire_with_retry(pool) as count_conn:
pending = await count_conn.fetchval(
f"""
SELECT COUNT(*)
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')
{scope_clause}
""",
*scope_params,
)
return pending or 0
async def _progress_total(processed: int) -> int:
# Cheap path: while we're still within the start-of-job estimate it's exact, so
# no extra query. Only re-count once the estimate is exhausted (≈the final batch
# normally, or repeatedly only if memories keep arriving mid-run).
if processed < total_count:
return total_count
return processed + await _count_unconsolidated()
# Process each memory with individual commits for crash recovery
stats: dict[str, int] = {
"memories_processed": 0,
@@ -446,10 +776,18 @@ async def run_consolidation_job(
hit_round_limit = False
llm_batch_num = 0
# Cumulative count of memories processed across the whole job, shared by
# the per-batch log so it can still report processed/total under parallelism.
# Mutable container so the inner closure can update without a `nonlocal`.
cumulative_progress = {"processed": 0}
# Cumulative counters across the whole job, shared by the per-batch log and the
# durable progress snapshot so both report processed/total (and observation
# tallies) under parallelism. Mutable container so the inner closure can update
# without a `nonlocal`.
cumulative_progress = {
"processed": 0,
"observations_created": 0,
"observations_updated": 0,
"observations_merged": 0,
"observations_deleted": 0,
"memories_failed": 0,
}
while True:
# Cap fetch size by remaining round budget
fetch_limit = (
@@ -670,12 +1008,18 @@ async def run_consolidation_job(
local_stats["memories_failed"] += 1
# Maintain the cumulative-progress indicator under parallelism:
# increment a shared counter and snapshot under the same statement
# so the snapshot includes this batch. No await between the read
# and write, so single-threaded asyncio gives us atomicity for free
# no lock needed.
# increment shared counters and snapshot under the same statements so
# the snapshot includes this batch. No await between the reads and
# writes, so single-threaded asyncio gives us atomicity for free
# no lock needed.
cumulative_progress["processed"] += local_stats["memories_processed"]
cumulative_progress["observations_created"] += local_stats["observations_created"]
cumulative_progress["observations_updated"] += local_stats["observations_updated"]
cumulative_progress["observations_merged"] += local_stats["observations_merged"]
cumulative_progress["observations_deleted"] += local_stats["observations_deleted"]
cumulative_progress["memories_failed"] += local_stats["memories_failed"]
cum_processed = cumulative_progress["processed"]
cum_snapshot = dict(cumulative_progress)
# Per-batch log uses batch_perf so timings/llm-calls/tokens reflect
# only this batch's own work, even when other batches are running
@@ -703,6 +1047,27 @@ async def run_consolidation_job(
f" | avg={llm_batch_time / max(1, len(llm_batch_local)):.3f}s/memory"
)
# Durable progress snapshot per LLM batch — this is the heartbeat an
# operator polls. The whole fetched batch is processed inside one outer
# round, so a round-boundary write would sit at the pre-round count for
# the entire (often minutes-long) LLM phase; writing here advances
# processed/total as each batch commits. set_stage mirrors it for the
# live worker log.
set_stage(f"consolidation.llm_batch.{batch_num_local}")
await memory_engine._write_operation_progress(
operation_id,
stage="consolidating",
processed=cum_processed,
total=await _progress_total(cum_processed),
detail={
"observations_created": cum_snapshot["observations_created"],
"observations_updated": cum_snapshot["observations_updated"],
"observations_merged": cum_snapshot["observations_merged"],
"observations_deleted": cum_snapshot["observations_deleted"],
"memories_failed": cum_snapshot["memories_failed"],
},
)
# Fold batch counters into the job-level perf so the final summary
# (perf.flush) totals every batch correctly. Safe without a lock —
# ConsolidationPerfLog.merge_from is a series of += on Python ints
@@ -841,6 +1206,13 @@ async def run_consolidation_job(
stats["mental_models_refreshed"] = 0
logger.info(f"[CONSOLIDATION] bank={bank_id} skipping mental model refresh (round limit hit, re-queued)")
else:
set_stage("consolidation.refreshing_mental_models")
await memory_engine._write_operation_progress(
operation_id,
stage="refreshing_mental_models",
processed=stats["memories_processed"],
total=await _progress_total(stats["memories_processed"]),
)
# SECURITY: Only refresh mental models with matching tags (or all if no tags were consolidated)
mental_models_refreshed = await _trigger_mental_model_refreshes(
memory_engine=memory_engine,
@@ -984,6 +1356,9 @@ async def _process_memory_batch(
"""
import asyncio
# Map the source memories this batch consumes onto the consolidation trace.
record_source_memory_ids([str(m["id"]) for m in memories])
# 1. Parallel recalls — one per fact
# When obs_tags_override is set, use it as the observation scope for all facts.
t0 = time.time()
@@ -1063,6 +1438,20 @@ async def _process_memory_batch(
mem_by_id = {str(m["id"]): m for m in memories}
# Semantic dedup: when enabled, an observation that is >= the threshold cosine to a DIFFERENT
# existing observation is reconciled by a focused 1-by-1 LLM merge (anchored on the observation
# text, not the source fact). It runs on both CREATE (a near-dup emitted despite the twin being
# in context — weak-model failure mode) and UPDATE (a rewrite+re-embed that drifts an existing
# observation into a twin — the create-time guard can't see this). The trace operation/scope is
# "consolidation_dedup" (routes through the consolidation concurrency bucket via llm_wrapper's
# "consolidation" prefix; recorded distinctly in llm_requests).
dedup_enabled = _dedup_active(config)
dedup_llm_config = (
memory_engine._consolidation_llm_config.with_config(config, bank_id=bank_id, operation="consolidation_dedup")
if dedup_enabled
else None
)
# Execute deletes first to free observation slots before creates consume them
deleted_count = 0
for delete in llm_result.deletes:
@@ -1087,7 +1476,7 @@ async def _process_memory_batch(
)
continue
agg = _aggregate_source_fields(source_mems, tags=fact_tags)
await _execute_update_action(
updated_emb_str = await _execute_update_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
@@ -1103,17 +1492,76 @@ async def _process_memory_batch(
)
for m in source_mems:
per_memory_updated.add(str(m["id"]))
# Reconcile the rewritten observation against its neighbours: the re-embed may have
# drifted it into a near-twin of another existing observation (the residual-duplicate
# source). updated_emb_str is None when the update was skipped — nothing to reconcile.
if dedup_enabled and updated_emb_str is not None:
await _dedup_reconcile_update(
conn,
memory_engine,
bank_id,
config,
dedup_llm_config,
update.observation_id,
update.text,
updated_emb_str,
agg.tags,
)
# Deterministic dedup guard: map the observations the LLM was SHOWN by their
# normalised text. The model intermittently emits a CREATE whose text is identical
# to an observation already in its context (over-aggregation / incoherence — it even
# UPDATEs the twin and creates a sibling). When that happens we drop the duplicate
# CREATE instead of inserting a redundant row. No extra LLM/embedding cost — the
# match is exact text against the in-memory set.
shown_obs_by_text = {_norm_obs_text(o.text): o for o in union_observations}
# Also collapse a CREATE that reproduces the text of an UPDATE issued in the SAME
# response (the model occasionally UPDATEs the twin to text X and also CREATEs X).
update_texts = {_norm_obs_text(u.text) for u in llm_result.updates if u.text}
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
agg = _aggregate_source_fields(source_mems, tags=fact_tags)
create_source_ids = [m["id"] for m in source_mems]
# Reconcile against observations shown to the LLM: an exact-text match means
# this CREATE reproduces verbatim an observation the model already had in context.
# Since that observation already carries this exact text, drop the duplicate CREATE
# — no row is inserted, nothing is lost. We deliberately do NOT also UPDATE the twin
# here: the LLM frequently UPDATEd it earlier in this same batch, and a second update
# would run off the pre-LLM snapshot and clobber that change (see _dedupe_updates).
duplicate_of = _duplicate_create_target(create.text, shown_obs_by_text, update_texts)
if duplicate_of is not None:
logger.warning(
"[CONSOLIDATION] dropped duplicate observation CREATE — verbatim match of %s; llm_reason=%r",
duplicate_of,
create.reason or "(none given)",
)
continue
# Semantic near-duplicate reconciliation: merge this CREATE into an existing
# near-identical observation (LLM-adjudicated, 1-by-1) instead of inserting a dup.
if dedup_enabled:
merged_into = await _dedup_reconcile_create(
conn, memory_engine, bank_id, config, dedup_llm_config, create.text, create_source_ids, agg.tags
)
if merged_into is not None:
logger.info(
"[CONSOLIDATION] dedup-merged observation CREATE into %s (cosine>=%.2f)",
merged_into[:8],
config.consolidation_dedup_threshold,
)
for m in source_mems:
per_memory_created.add(str(m["id"]))
continue
await _execute_create_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
source_memory_ids=[m["id"] for m in source_mems],
source_memory_ids=create_source_ids,
text=create.text,
source_fact_tags=agg.tags,
event_date=agg.event_date,
@@ -1153,6 +1601,64 @@ def _max_date(dates: "Any") -> "datetime | None":
return max((d for d in dates if d is not None), default=None)
@dataclass(frozen=True)
class _ObservationHistorySnapshot:
"""Pre-update state of an observation, persisted as the ``content`` JSON blob
of one observation_history row.
Temporal fields are the ISO strings carried on MemoryFact; new_source_memory_ids
are the ids added by the update.
"""
previous_text: str | None
previous_tags: list[str]
previous_occurred_start: str | None
previous_occurred_end: str | None
previous_mentioned_at: str | None
new_source_memory_ids: list[str]
async def _append_observation_history(
conn: "Connection",
bank_id: str,
observation_id: str,
snapshot: _ObservationHistorySnapshot,
max_entries: int,
) -> None:
"""Insert one pre-update snapshot into ``observation_history``, then delete the
oldest rows beyond ``max_entries`` for this observation.
The snapshot is stored as a single JSONB ``content`` blob (per-row, so it stays
small). Bounding by row count keeps a frequently-reinforced observation's
history from growing without bound.
"""
obs_uuid = uuid.UUID(observation_id)
await conn.execute(
f"""
INSERT INTO {fq_table("observation_history")} (observation_id, bank_id, content, changed_at)
VALUES ($1, $2, $3::jsonb, now())
""",
obs_uuid,
bank_id,
json.dumps(asdict(snapshot)),
)
if max_entries and max_entries > 0:
await conn.execute(
f"""
DELETE FROM {fq_table("observation_history")}
WHERE observation_id = $1
AND id NOT IN (
SELECT id FROM {fq_table("observation_history")}
WHERE observation_id = $1
ORDER BY changed_at DESC, id DESC
LIMIT $2
)
""",
obs_uuid,
max_entries,
)
async def _execute_update_action(
conn: "Connection",
memory_engine: "MemoryEngine",
@@ -1166,12 +1672,15 @@ async def _execute_update_action(
source_occurred_end: datetime | None = None,
source_mentioned_at: datetime | None = None,
perf: ConsolidationPerfLog | None = None,
) -> None:
) -> str | None:
"""
Update an existing observation.
Extends source_memory_ids with all contributing memories, updates temporal fields
(LEAST for occurred_start, GREATEST for occurred_end / mentioned_at), and merges tags.
Returns the observation's freshly-computed embedding (pgvector literal) so the caller can
run UPDATE-path dedup without re-embedding, or None when the update was skipped.
"""
model = next((m for m in observations if str(m.id) == observation_id), None)
if not model:
@@ -1189,15 +1698,14 @@ async def _execute_update_action(
from ...config import get_config
history_entry = {
"previous_text": model.text,
"previous_tags": list(model.tags or []),
"previous_occurred_start": model.occurred_start,
"previous_occurred_end": model.occurred_end,
"previous_mentioned_at": model.mentioned_at,
"changed_at": datetime.now(timezone.utc).isoformat(),
"new_source_memory_ids": [str(mid) for mid in source_memory_ids],
}
history_entry = _ObservationHistorySnapshot(
previous_text=model.text,
previous_tags=list(model.tags or []),
previous_occurred_start=model.occurred_start,
previous_occurred_end=model.occurred_end,
previous_mentioned_at=model.mentioned_at,
new_source_memory_ids=[str(mid) for mid in source_memory_ids],
)
source_ids = list(model.source_fact_ids or []) + source_memory_ids
@@ -1213,9 +1721,6 @@ async def _execute_update_action(
perf.record_timing("embedding", time.time() - t0)
config = get_config()
history_clause = (
"history = COALESCE(history, '[]'::jsonb) || $3::jsonb," if config.enable_observation_history else ""
)
t0 = time.time()
await conn.execute(
@@ -1223,19 +1728,17 @@ async def _execute_update_action(
UPDATE {fq_table("memory_units")}
SET text = $1,
embedding = $2::vector,
{history_clause}
source_memory_ids = $4,
proof_count = $5,
tags = $10,
source_memory_ids = $3,
proof_count = $4,
tags = $9,
updated_at = now(),
occurred_start = LEAST(occurred_start, COALESCE($7, occurred_start)),
occurred_end = GREATEST(occurred_end, COALESCE($8, occurred_end)),
mentioned_at = GREATEST(mentioned_at, COALESCE($9, mentioned_at))
WHERE id = $6
occurred_start = LEAST(occurred_start, COALESCE($6, occurred_start)),
occurred_end = GREATEST(occurred_end, COALESCE($7, occurred_end)),
mentioned_at = GREATEST(mentioned_at, COALESCE($8, mentioned_at))
WHERE id = $5
""",
new_text,
embedding_str,
json.dumps([history_entry]),
source_ids,
len(source_ids),
uuid.UUID(observation_id),
@@ -1245,6 +1748,15 @@ async def _execute_update_action(
merged_tags,
)
# Record the pre-update snapshot in the dedicated observation_history table
# (one row per change), then trim to the configured cap. History lived in a
# single unbounded JSONB column before; an often-reinforced observation grew
# it until it crossed Postgres's 256MB jsonb limit and got stuck.
if config.enable_observation_history:
await _append_observation_history(
conn, bank_id, observation_id, history_entry, config.observation_history_max_entries
)
# Sync observation_sources junction table (Oracle only — PG uses native array ops).
if memory_engine._backend.ops.uses_observation_sources_table:
obs_uuid = uuid.UUID(observation_id)
@@ -1265,7 +1777,10 @@ async def _execute_update_action(
if perf:
perf.record_timing("db_write", time.time() - t0)
# Map the updated observation onto the consolidation trace as a produced memory.
record_created_memory_ids([observation_id])
logger.debug(f"Updated observation {observation_id} from {len(source_memory_ids)} source memories")
return embedding_str
async def _execute_create_action(
@@ -1287,7 +1802,7 @@ async def _execute_create_action(
Tags are inherited from the source facts (determined algorithmically, not by LLM)
to maintain visibility scope.
"""
await _create_observation_directly(
created = await _create_observation_directly(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
@@ -1300,6 +1815,10 @@ async def _execute_create_action(
mentioned_at=mentioned_at,
perf=perf,
)
# Map the new observation onto the consolidation trace as a produced memory.
new_id = created.get("observation_id")
if new_id:
record_created_memory_ids([new_id])
logger.debug(f"Created observation from {len(source_memory_ids)} source memories")
@@ -1398,6 +1917,13 @@ async def _find_related_observations(
include_source_facts=True, # Embed source facts so we avoid a separate DB fetch
max_source_facts_tokens=config.consolidation_source_facts_max_tokens,
max_source_facts_tokens_per_observation=config.consolidation_source_facts_max_tokens_per_observation,
# Round-robin interleave fusion (no cross-encoder): consolidation is looking
# for an existing near-identical observation to merge into. Both the
# cross-encoder (semantic #1 -> reranked #37) and RRF (semantic #1 -> outside
# the 512-token budget) were measured to bury that twin; interleave guarantees
# each retrieval arm's top hits a slot, so the semantic-#1 twin is always shown
# to the LLM, which then UPDATEs instead of creating a duplicate.
reranking="interleave",
_quiet=True, # Suppress logging
)
finally:
@@ -1532,16 +2058,38 @@ async def _consolidate_batch_with_llm(
f"(out of {max_observations_per_scope}). Prefer UPDATE over CREATE when possible."
)
prompt_template = build_batch_consolidation_prompt(
config.observations_mission,
observation_capacity_note,
# Split the prompt: a bank-agnostic system instruction (rules + input format +
# decision guide + output format) that is byte-identical across batches AND
# across banks, and a per-batch user message (mission + capacity note + facts +
# existing observations). The split lets the system prefix be served from a
# single Gemini context cache shared by every bank — the bank mission, capacity
# note, and response_schema (all bank/batch-variable) are kept OUT of the
# cached prefix so one cache serves all and it never busts within a run.
system_prompt = build_consolidation_system_prompt(
llm_output_language=getattr(config, "llm_output_language", None),
)
prompt = prompt_template.format(
user_content = build_consolidation_input(
facts_text=facts_lines,
observations_text=observations_text,
observations_mission=config.observations_mission,
observation_capacity_note=observation_capacity_note,
)
# Opt into context caching of the stable system prefix when the provider
# supports it (gemini/vertexai with the flag on). response_schema is NOT
# passed to the fingerprint: it varies per batch (max_creates) but is not
# part of the cached prefix, so keying on it would needlessly bust the cache.
cached_prefix_name: str | None = None
provider_impl = getattr(llm_config, "_provider_impl", None)
if provider_impl is not None and provider_impl.supports_prompt_caching():
try:
cached_prefix_name = await provider_impl.get_or_create_cached_prefix(
system_instruction=system_prompt,
)
except Exception:
logger.exception("Consolidation cache prefix lookup failed; falling back to uncached call")
cached_prefix_name = None
# Use a constrained response model when observation limit is active
response_model = _build_response_model(max_creates=remaining_observation_slots)
@@ -1561,12 +2109,23 @@ async def _consolidate_batch_with_llm(
for attempt in range(1, max_attempts + 1):
try:
call_kwargs: dict[str, Any] = {
"messages": [{"role": "user", "content": prompt}],
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
],
"response_format": response_model,
"scope": "consolidation",
}
# Only request an explicit output budget when configured. Left unset by default the key is
# omitted, so each provider keeps its implicit default (backwards compatible). Operators on
# providers with a low hidden cap (notably Bedrock imported models, which truncate structured
# consolidation JSON) set HINDSIGHT_API_CONSOLIDATION_MAX_COMPLETION_TOKENS to fix it.
if config.consolidation_max_completion_tokens is not None:
call_kwargs["max_completion_tokens"] = config.consolidation_max_completion_tokens
if inner_max_retries is not None:
call_kwargs["max_retries"] = inner_max_retries
if cached_prefix_name is not None:
call_kwargs["cached_prefix"] = cached_prefix_name
response: _ConsolidationBatchResponse = await llm_config.call(**call_kwargs)
# Defensive truncation: some LLM providers may not enforce JSON schema max_length
creates = response.creates
@@ -1583,7 +2142,7 @@ async def _consolidate_batch_with_llm(
updates=updates,
deletes=response.deletes,
obs_count=len(union_observations),
prompt_chars=len(prompt),
prompt_chars=len(system_prompt) + len(user_content),
)
except Exception as exc:
last_exc = exc
@@ -1595,7 +2154,9 @@ async def _consolidate_batch_with_llm(
f"[CONSOLIDATION] LLM batch call failed after {max_attempts} attempts for {batch_label}, "
f"skipping batch. Last error: {last_exc}"
)
return _BatchLLMResult(obs_count=len(union_observations), prompt_chars=len(prompt), failed=True)
return _BatchLLMResult(
obs_count=len(union_observations), prompt_chars=len(system_prompt) + len(user_content), failed=True
)
async def _create_observation_directly(
@@ -1642,10 +2203,10 @@ async def _create_observation_directly(
# VectorChord: manually tokenize and insert search_vector
query = f"""
INSERT INTO {fq_table("memory_units")} (
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids, history,
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids,
tags, event_date, occurred_start, occurred_end, mentioned_at, search_vector
)
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8, $9, $10,
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, $6, $7, $8, $9, $10,
tokenize($3, 'llmlingua2')::bm25_catalog.bm25vector)
RETURNING id
"""
@@ -1661,10 +2222,10 @@ async def _create_observation_directly(
# re-ingested. Tracking a separate fix for that gap.
query = f"""
INSERT INTO {fq_table("memory_units")} (
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids, history,
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids,
tags, event_date, occurred_start, occurred_end, mentioned_at
)
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8, $9, $10)
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, $6, $7, $8, $9, $10)
RETURNING id
"""
@@ -37,6 +37,33 @@ _PROCESSING_RULES = """## PROCESSING RULES
9. KEEP DISTINCT TOPICS DISTINCT: do not merge observations about different people, entities, or unrelated topics. Merging is for the same canonical fact recurring — not for related-but-distinct claims."""
# Stable description of the input shape. For the cached split path this lives in
# the system prefix (build_consolidation_system_prompt) so it is not re-sent on
# every batch; the per-batch user message then carries only the actual data.
_INPUT_FORMAT_NOTE = """## INPUT FORMAT
Each request provides new facts and existing observations:
- New facts: one per line, each prefixed with its `[uuid]`, followed by the fact text and optional temporal fields.
- Existing observations: a JSON array pooled from recalls across the new facts. Each entry has:
- `id`: unique identifier — copy this exactly when issuing an UPDATE or DELETE
- `text`: the observation content
- `proof_count`: number of supporting memories
- `occurred_start` / `occurred_end`: temporal range of source facts
- `source_memories`: array of supporting facts with their text and dates"""
# Per-batch data section for the cached split path — the stable format
# explanation above is omitted here (it lives in the cached prefix); only the
# variable facts/observations remain. Placeholders substituted at call time.
_SPLIT_INPUT_SECTION = """## INPUT
### New facts
{facts_text}
### Existing observations
{observations_text}"""
# Data section — format placeholders {facts_text} and {observations_text} are substituted at call time
_INPUT_SECTION = """## INPUT
@@ -65,7 +92,7 @@ _DECISION_GUIDE = """## DECISION GUIDE
# Output format — JSON braces escaped as {{ }} so .format() leaves them literal
_OUTPUT_SECTION = """## OUTPUT FORMAT
Return a JSON object with three arrays: `creates`, `updates`, `deletes`.
Return a JSON object with three arrays: `creates`, `updates`, `deletes`. Every entry must include a `reason`.
### Example 1 — Merging recurring claims into an existing observation
@@ -79,7 +106,7 @@ Existing observation:
Expected output (one UPDATE, no creates — both new facts are additional evidence for the same canonical decision):
{{"creates": [],
"updates": [{{"text": "Donald named Athena's sovereignty as a foundational principle of the Janus architecture.", "observation_id": "11111111-1111-1111-1111-111111111111", "source_fact_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "b2c3d4e5-f6a7-8901-bcde-f12345678901"]}}],
"updates": [{{"text": "Donald named Athena's sovereignty as a foundational principle of the Janus architecture.", "observation_id": "11111111-1111-1111-1111-111111111111", "source_fact_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "b2c3d4e5-f6a7-8901-bcde-f12345678901"], "reason": "Both new facts restate the same sovereignty decision already captured by obs 1111 — merged as evidence rather than creating siblings."}}],
"deletes": []}}
### Example 2 — State change updates one observation; unrelated fact creates a new one
@@ -93,8 +120,8 @@ Existing observation:
Expected output (UPDATE for the state change; CREATE for the unrelated work-hours facet):
{{"creates": [{{"text": "Alice works long hours, often past midnight.", "source_fact_ids": ["d4e5f6a7-b8c9-0123-defa-234567890123"]}}],
"updates": [{{"text": "Alice owned a 2019 Honda Civic; sold it on March 15, 2025.", "observation_id": "22222222-2222-2222-2222-222222222222", "source_fact_ids": ["c3d4e5f6-a7b8-9012-cdef-123456789012"]}}],
{{"creates": [{{"text": "Alice works long hours, often past midnight.", "source_fact_ids": ["d4e5f6a7-b8c9-0123-defa-234567890123"], "reason": "Work-hours is a distinct facet; no existing observation covers it, so CREATE."}}],
"updates": [{{"text": "Alice owned a 2019 Honda Civic; sold it on March 15, 2025.", "observation_id": "22222222-2222-2222-2222-222222222222", "source_fact_ids": ["c3d4e5f6-a7b8-9012-cdef-123456789012"], "reason": "State change to the existing Honda Civic observation 2222 — UPDATE, not a new sibling."}}],
"deletes": []}}
### Observation text rules
@@ -110,6 +137,7 @@ Expected output (UPDATE for the state change; CREATE for the unrelated work-hour
- One create or update may reference multiple facts when they jointly support the observation.
- **AT MOST ONE UPDATE PER `observation_id`**: if several new facts all update the same existing observation, emit a single `updates` entry that lists all contributing `source_fact_ids` and a single consolidated `text`. Never emit two `updates` entries with the same `observation_id` in one response — they would silently overwrite each other.
- `deletes`: only when an observation is directly superseded or contradicted by new facts.
- `reason`: REQUIRED on every create/update/delete — one sentence explaining the choice. For a CREATE, state which existing observation(s) you considered and why none matched (a near-identical existing observation means you should UPDATE, not CREATE). This is audited to catch duplicate creates.
- Do NOT include `tags` — handled automatically.
- Return `{{"creates": [], "updates": [], "deletes": []}}` if nothing durable is found."""
@@ -145,3 +173,55 @@ def build_batch_consolidation_prompt(
f"{_DECISION_GUIDE}\n\n"
f"{_OUTPUT_SECTION}" + output_language_directive(llm_output_language)
)
def build_consolidation_system_prompt(
llm_output_language: str | None = None,
) -> str:
"""Bank-agnostic, cacheable system instruction for batch consolidation.
Holds only what is constant across banks: processing rules, input format,
decision guide, and output format. The bank's MISSION is deliberately NOT
here — baking it in would make the prefix bank-specific and force a separate
Gemini context cache per mission. The mission, the per-batch INPUT, and any
capacity constraint all ride in the user message (see
:func:`build_consolidation_input`), so this prefix is identical for every
bank and a single CachedContent serves them all. Returns final text
(brace-escaped examples already unescaped) for verbatim use as system message
and cached prefix.
"""
template = (
"You are a memory consolidation system. Synthesize new facts into "
"observations, merging with existing observations when appropriate.\n\n"
f"{_MISSION_PRIORITY_NOTE}\n\n"
f"{_PROCESSING_RULES}\n\n"
f"{_INPUT_FORMAT_NOTE}\n\n"
f"{_DECISION_GUIDE}\n\n"
f"{_OUTPUT_SECTION}" + output_language_directive(llm_output_language)
)
# No {facts_text}/{observations_text} placeholders here — the only braces are
# the doubled {{ }} in the OUTPUT examples, which .format() unescapes.
return template.format()
def build_consolidation_input(
facts_text: str,
observations_text: str,
observations_mission: str | None = None,
observation_capacity_note: str | None = None,
) -> str:
"""Per-batch user message: MISSION + INPUT data + any capacity constraint.
The MISSION lives here (not in the cached system prefix) so the prefix stays
bank-agnostic and one CachedContent serves every bank. The capacity note also
lives here since it varies as observation slots fill.
"""
mission = escape_for_prompt(observations_mission or _DEFAULT_MISSION)
mission_section = f"## MISSION\n\n{mission}\n\n"
capacity_section = ""
if observation_capacity_note:
capacity_section = f"## CAPACITY CONSTRAINT\n\n{escape_for_prompt(observation_capacity_note)}\n\n"
# _SPLIT_INPUT_SECTION omits the stable observation-format explanation (now in
# the cached system prefix) — only the variable facts/observations remain.
template = mission_section + capacity_section + _SPLIT_INPUT_SECTION
return template.format(facts_text=facts_text, observations_text=observations_text)
@@ -46,7 +46,6 @@ from ..config import (
ENV_RERANKER_FLASHRANK_CPU_MEM_ARENA,
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,
@@ -1199,7 +1198,7 @@ class LiteLLMSDKCrossEncoder(CrossEncoderModel):
def __init__(
self,
api_key: str,
api_key: str | None = None,
model: str = DEFAULT_RERANKER_LITELLM_SDK_MODEL,
api_base: str | None = None,
timeout: float = 60.0,
@@ -1209,7 +1208,8 @@ class LiteLLMSDKCrossEncoder(CrossEncoderModel):
Initialize LiteLLM SDK cross-encoder client.
Args:
api_key: API key for the reranking provider
api_key: API key for the reranking provider (optional — omit for
providers that use ambient credentials, e.g. AWS Bedrock with IAM)
model: Model name with provider prefix (e.g., "deepinfra/Qwen3-reranker-8B")
api_base: Custom base URL for API (optional)
timeout: Request timeout in seconds (default: 60.0)
@@ -1284,8 +1284,9 @@ class LiteLLMSDKCrossEncoder(CrossEncoderModel):
"model": self.model,
"query": query,
"documents": texts,
"api_key": self.api_key,
}
if self.api_key:
rerank_kwargs["api_key"] = self.api_key
if self.api_base:
rerank_kwargs["api_base"] = self.api_base
@@ -1697,13 +1698,8 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
timeout=config.reranker_litellm_timeout,
)
elif provider == "litellm-sdk":
api_key = config.reranker_litellm_sdk_api_key
if not api_key:
raise ValueError(
f"{ENV_RERANKER_LITELLM_SDK_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'litellm-sdk'"
)
return LiteLLMSDKCrossEncoder(
api_key=api_key,
api_key=config.reranker_litellm_sdk_api_key or None,
model=config.reranker_litellm_sdk_model,
api_base=config.reranker_litellm_sdk_api_base,
max_tokens_per_doc=config.reranker_litellm_max_tokens_per_doc,
@@ -72,6 +72,30 @@ class DataAccessOps(ABC):
"""
...
@abstractmethod
async def lock_document_for_write(
self,
conn: DatabaseConnection,
table: str,
doc_id: str,
bank_id: str,
) -> str | None:
"""Ensure the document row exists, take a row lock on it, and return its
pre-existing ``content_hash``.
This serializes all concurrent writers for ``doc_id`` at the DB level
(so interleaved same-document retains can't corrupt each other), while
creating the row on first write. The returned hash is ``'__pending__'``
for a freshly inserted row, the stored hash for an existing one, or
``None`` if the row could not be read back.
PG does this in a single statement (``INSERT ... ON CONFLICT DO UPDATE
... RETURNING``), which always takes the row lock as part of the upsert.
Oracle can't (``MERGE`` doesn't support ``RETURNING``), so it splits the
work into an idempotent insert plus a ``SELECT ... FOR UPDATE``.
"""
...
@abstractmethod
async def insert_facts_batch(
self,
@@ -47,6 +47,37 @@ class OracleOps(DataAccessOps):
column_types=["text[]", "text[]", "text[]", "text[]", "integer[]", "text[]"],
)
async def lock_document_for_write(
self,
conn: DatabaseConnection,
table: str,
doc_id: str,
bank_id: str,
) -> str | None:
# Oracle can't express the PG "INSERT ... ON CONFLICT DO UPDATE ...
# RETURNING" upsert in one statement — MERGE doesn't support RETURNING,
# so the single-statement form rewrites to a MERGE that returns no rows
# (DPY-1003). Split it into two statements instead:
# 1. Idempotent insert that silently skips an existing row. The
# IGNORE_ROW_ON_DUPKEY_INDEX hint suppresses ORA-00001 server-side;
# a concurrent uncommitted insert of the same key blocks here until
# the other writer commits, so writers still serialize.
# 2. SELECT ... FOR UPDATE to take the row lock and read the hash
# ('__pending__' for a row we just inserted, the stored hash for an
# existing one).
await conn.execute(
f"INSERT /*+ IGNORE_ROW_ON_DUPKEY_INDEX({table}, pk_documents) */ "
f"INTO {table} (id, bank_id, original_text, content_hash) "
f"VALUES ($1, $2, '', '__pending__')",
doc_id,
bank_id,
)
return await conn.fetchval(
f"SELECT content_hash FROM {table} WHERE id = $1 AND bank_id = $2 FOR UPDATE",
doc_id,
bank_id,
)
async def insert_facts_batch(
self,
conn: DatabaseConnection,
@@ -49,6 +49,30 @@ class PostgreSQLOps(DataAccessOps):
content_hashes,
)
async def lock_document_for_write(
self,
conn: DatabaseConnection,
table: str,
doc_id: str,
bank_id: str,
) -> str | None:
# Single upsert that both creates the row (if absent) and locks it (if
# present) atomically. ON CONFLICT DO UPDATE always takes the row lock as
# part of the statement, so all concurrent same-document writers serialize
# on the document row in one consistent step (the earlier two-step form —
# DO NOTHING + a separate SELECT FOR UPDATE — could deadlock because
# DO NOTHING takes no lock on an existing row). The SET is a no-op
# self-assignment used only to acquire the lock; RETURNING yields the
# pre-existing hash (or '__pending__' for a freshly inserted row).
return await conn.fetchval(
f"INSERT INTO {table} (id, bank_id, original_text, content_hash) "
f"VALUES ($1, $2, '', '__pending__') "
f"ON CONFLICT (id, bank_id) DO UPDATE SET content_hash = {table}.content_hash "
f"RETURNING content_hash",
doc_id,
bank_id,
)
async def insert_facts_batch(
self,
conn: DatabaseConnection,
@@ -14,6 +14,7 @@ Supports multi-tenant schema isolation via ALTER SESSION SET CURRENT_SCHEMA.
"""
import datetime
import inspect
import json
import logging
import re
@@ -155,6 +156,23 @@ _JSON_COL_NAMES = {
"task_payload",
"history",
}
# NOTE: the history tables' JSON payload column is named ``content`` — deliberately
# NOT added here, because ``mental_models.content`` is plain text (adding "content"
# would corrupt those reads). The history read paths json.loads ``content`` directly.
# Columns backed by CLOB in Oracle (large text or JSON). When such a column is
# returned via a ``RETURNING`` clause it must be bound as DB_TYPE_CLOB; binding
# it as VARCHAR raises ORA-22835 ("buffer too small for CLOB to CHAR") once the
# value exceeds 4000 bytes. Union of the JSON-CLOB columns above and the
# large-text CLOB columns.
_CLOB_RETURNING_COLS = _JSON_COL_NAMES | {
"content",
"text",
"context",
"structured_content",
"text_signals",
"search_vector",
}
def _is_uuid_column(col: str) -> bool:
@@ -685,6 +703,11 @@ class OracleConnection(DatabaseConnection):
params[f"ret_{i}"] = cursor.var(oracledb.DB_TYPE_TIMESTAMP_TZ, arraysize=1)
elif clean in _NUMERIC_COLS:
params[f"ret_{i}"] = cursor.var(oracledb.DB_TYPE_NUMBER, arraysize=1)
elif clean in _CLOB_RETURNING_COLS:
# CLOB-backed column: a VARCHAR out-bind caps at 4000 bytes and
# raises ORA-22835 for larger values. Read back as a LOB in
# _read_returning_values.
params[f"ret_{i}"] = cursor.var(oracledb.DB_TYPE_CLOB, arraysize=1)
else:
params[f"ret_{i}"] = cursor.var(oracledb.DB_TYPE_VARCHAR, arraysize=1)
@@ -862,7 +885,7 @@ class OracleConnection(DatabaseConnection):
return query, params
def _read_returning_values(self, returning_cols: list[str], params: dict[str, Any]) -> dict[str, Any] | None:
async def _read_returning_values(self, returning_cols: list[str], params: dict[str, Any]) -> dict[str, Any] | None:
"""Read values from RETURNING INTO output variables after execute."""
row: dict[str, Any] = {}
for i, col in enumerate(returning_cols):
@@ -872,6 +895,14 @@ class OracleConnection(DatabaseConnection):
return None
val = values[0] if isinstance(values, list) else values
# CLOB-bound columns return a LOB handle; read it to a string. The
# async pool yields AsyncLOB whose read() is a coroutine.
if val is not None and not isinstance(val, (str, bytes, int, float)) and hasattr(val, "read"):
data = val.read()
if inspect.isawaitable(data):
data = await data
val = data
# Clean alias: "LOWER(canonical_name) AS name_lower" → "name_lower"
clean_col = col.strip()
upper = clean_col.upper()
@@ -1059,7 +1090,7 @@ class OracleConnection(DatabaseConnection):
raise
if ret_cols is not None:
row_dict = self._read_returning_values(ret_cols, params)
row_dict = await self._read_returning_values(ret_cols, params)
return [ResultRow(row_dict)] if row_dict else []
columns = [col[0].lower() for col in cursor.description or []]
@@ -1097,7 +1128,7 @@ class OracleConnection(DatabaseConnection):
raise
if ret_cols is not None:
row_dict = self._read_returning_values(ret_cols, params)
row_dict = await self._read_returning_values(ret_cols, params)
return ResultRow(row_dict) if row_dict else None
columns = [col[0].lower() for col in cursor.description or []]
@@ -1130,7 +1161,7 @@ class OracleConnection(DatabaseConnection):
await cursor.execute(query, params)
if ret_cols is not None:
row_dict = self._read_returning_values(ret_cols, params)
row_dict = await self._read_returning_values(ret_cols, params)
if row_dict is None:
return None
vals = list(row_dict.values())
@@ -43,6 +43,10 @@ from ..config import (
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
ENV_EMBEDDINGS_LOCAL_MODEL,
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
ENV_EMBEDDINGS_ONNX_DIMENSIONS,
ENV_EMBEDDINGS_ONNX_MODEL_ID,
ENV_EMBEDDINGS_ONNX_MODEL_PATH,
ENV_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH,
ENV_EMBEDDINGS_OPENAI_API_KEY,
ENV_EMBEDDINGS_OPENAI_BASE_URL,
ENV_EMBEDDINGS_OPENAI_MODEL,
@@ -252,6 +256,172 @@ class LocalSTEmbeddings(Embeddings):
return [emb.tolist() for emb in embeddings]
class OnnxEmbeddings(Embeddings):
"""Local ONNX Runtime embeddings provider.
This provider runs transformer embedding models in-process with ONNX Runtime,
avoiding a sidecar Ollama/TEI server or a remote embeddings API. It supports
sentence-transformer style mean pooling and E5-style asymmetric prefixes.
"""
def __init__(
self,
model_id: str,
model_path: str | None = None,
tokenizer_name_or_path: str | None = None,
onnx_file: str = "onnx/model.onnx",
dimensions: int | None = None,
max_tokens: int = 512,
pooling: str = "mean",
normalize: bool = True,
query_prefix: str = "query: ",
passage_prefix: str = "passage: ",
output_name: str | None = None,
):
self.model_id = model_id
self.model_path = model_path
if model_path and tokenizer_name_or_path is None:
logger.warning(
"Embeddings: ONNX model_path is set without tokenizer_name_or_path; "
"falling back to tokenizer from model_id %s. Set "
"HINDSIGHT_API_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH when using local ONNX artifacts.",
model_id,
)
self.tokenizer_name_or_path = tokenizer_name_or_path or model_id
self.onnx_file = onnx_file
self.configured_dimensions = dimensions
self.max_tokens = max_tokens
self.pooling = pooling.lower()
if self.pooling not in {"mean", "cls"}:
raise ValueError("ONNX embeddings pooling must be 'mean' or 'cls'")
self.normalize = normalize
self.query_prefix = query_prefix
self.passage_prefix = passage_prefix
self.output_name = output_name
self._session = None
self._tokenizer = None
self._dimension: int | None = dimensions
@property
def provider_name(self) -> str:
return "onnx"
@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:
if self._session is not None and self._tokenizer is not None:
return
try:
import onnxruntime as ort
from transformers import AutoTokenizer
except ImportError as exc:
raise ImportError(
"onnxruntime and transformers are required for OnnxEmbeddings. "
"Install with: pip install 'hindsight-api-slim[local-onnx]'"
) from exc
model_path = self.model_path
if not model_path:
try:
from huggingface_hub import snapshot_download
except ImportError as exc:
raise ImportError(
"huggingface-hub is required to download ONNX embedding models. "
"Set HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_PATH or install local-onnx."
) from exc
# Some large ONNX exports, for example BAAI/bge-m3, store weights in
# an external sidecar file next to model.onnx. Download both the
# requested graph and its conventional *_data sidecar when present.
snapshot_dir = snapshot_download(
repo_id=self.model_id,
allow_patterns=[self.onnx_file, f"{self.onnx_file}_data"],
)
model_path = os.path.join(snapshot_dir, self.onnx_file)
logger.info(
"Embeddings: initializing ONNX provider with model %s (%s)",
self.model_id,
model_path,
)
logger.info(
"Embeddings: ONNX query_prefix=%r passage_prefix=%r pooling=%s normalize=%s",
self.query_prefix,
self.passage_prefix,
self.pooling,
self.normalize,
)
self._tokenizer = AutoTokenizer.from_pretrained(self.tokenizer_name_or_path)
self._session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
detected = len(self.encode(["test"])[0])
if self.configured_dimensions is not None and detected != self.configured_dimensions:
raise ValueError(
f"Configured ONNX embedding dimension {self.configured_dimensions} does not match model output {detected}"
)
self._dimension = detected
logger.info("Embeddings: ONNX provider initialized (dim: %s)", self._dimension)
def _encode_prefixed(self, texts: list[str], prefix: str) -> list[list[float]]:
if prefix:
return self.encode([f"{prefix}{text}" for text in texts])
return self.encode(texts)
def encode_query(self, texts: list[str]) -> list[list[float]]:
return self._encode_prefixed(texts, self.query_prefix)
def encode_documents(self, texts: list[str]) -> list[list[float]]:
return self._encode_prefixed(texts, self.passage_prefix)
def encode(self, texts: list[str]) -> list[list[float]]:
if self._session is None or self._tokenizer is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
if not texts:
return []
import numpy as np
encoded = self._tokenizer(
texts,
padding=True,
truncation=True,
max_length=self.max_tokens,
return_tensors="np",
)
input_names = {inp.name for inp in self._session.get_inputs()}
ort_inputs = {name: value for name, value in encoded.items() if name in input_names}
if "token_type_ids" in input_names and "token_type_ids" not in ort_inputs:
ort_inputs["token_type_ids"] = np.zeros_like(encoded["input_ids"])
outputs = self._session.run([self.output_name] if self.output_name else None, ort_inputs)
token_embeddings = outputs[0]
# Some exported models expose a pooled 2-D embedding as their first output.
if getattr(token_embeddings, "ndim", 0) == 2:
embeddings = token_embeddings
elif self.pooling == "cls":
embeddings = token_embeddings[:, 0]
else:
attention_mask = encoded.get("attention_mask")
if attention_mask is None:
attention_mask = np.ones(token_embeddings.shape[:2], dtype=np.float32)
mask = attention_mask[..., None].astype(np.float32)
summed = (token_embeddings * mask).sum(axis=1)
counts = np.clip(mask.sum(axis=1), a_min=1e-9, a_max=None)
embeddings = summed / counts
if self.normalize:
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
norms[norms == 0] = 1
embeddings = embeddings / norms
return embeddings.astype(float).tolist()
class RemoteTEIEmbeddings(Embeddings):
"""
Remote embeddings implementation using HuggingFace Text Embeddings Inference (TEI) HTTP API.
@@ -1391,6 +1561,20 @@ def create_embeddings_from_env() -> Embeddings:
force_cpu=config.embeddings_local_force_cpu,
trust_remote_code=config.embeddings_local_trust_remote_code,
)
elif provider == "onnx":
return OnnxEmbeddings(
model_id=config.embeddings_onnx_model_id,
model_path=config.embeddings_onnx_model_path,
tokenizer_name_or_path=config.embeddings_onnx_tokenizer_name_or_path,
onnx_file=config.embeddings_onnx_file,
dimensions=config.embeddings_onnx_dimensions,
max_tokens=config.embeddings_onnx_max_tokens,
pooling=config.embeddings_onnx_pooling,
normalize=config.embeddings_onnx_normalize,
query_prefix=config.embeddings_onnx_query_prefix,
passage_prefix=config.embeddings_onnx_passage_prefix,
output_name=config.embeddings_onnx_output_name,
)
elif provider == "openai":
# Use dedicated embeddings API key, or fall back to LLM API key
api_key = os.environ.get(ENV_EMBEDDINGS_OPENAI_API_KEY) or os.environ.get(ENV_LLM_API_KEY)
@@ -1492,6 +1676,6 @@ def create_embeddings_from_env() -> Embeddings:
else:
raise ValueError(
f"Unknown embeddings provider: {provider}. "
f"Supported: 'local', 'tei', 'openai', 'openai-codex', 'openrouter', 'cohere', 'google', "
f"Supported: 'local', 'onnx', 'tei', 'openai', 'openai-codex', 'openrouter', 'cohere', 'google', "
f"'zeroentropy', 'litellm', 'litellm-sdk'"
)
@@ -458,8 +458,28 @@ class MemoryEngineInterface(ABC):
request_context: Request context for authentication.
Returns:
Dict with node_counts, link_counts, link_counts_by_fact_type,
link_breakdown, and operations stats.
Dict with node_counts, link_counts, link_counts_by_fact_type
(deprecated, returns empty), link_breakdown (deprecated, returns
empty), and operations stats.
"""
...
@abstractmethod
async def get_bank_freshness(
self,
bank_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Get consolidation freshness for a bank.
Cheap alternative to get_bank_stats when callers only need
last_consolidated_at / pending_consolidation / failed_consolidation.
Returns:
Dict with last_consolidated_at (ISO-8601 string or None),
pending_consolidation (int), and failed_consolidation (int).
"""
...
@@ -69,6 +69,7 @@ class LLMInterface(ABC):
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
cached_prefix: str | None = None,
) -> Any:
"""
Make an LLM API call with retry logic.
@@ -83,8 +84,13 @@ class LLMInterface(ABC):
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
skip_validation: Return raw JSON without Pydantic validation.
strict_schema: Use strict JSON schema enforcement (OpenAI only).
strict_schema: Grammar-enforce structured output via json_schema strict
(OpenAI-compatible, LiteLLM) instead of the soft json_object path. Gemini
enforces its response_schema natively; providers without a strict mode ignore it.
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
cached_prefix: Opaque handle from ``get_or_create_cached_prefix`` for the
cacheable system prefix, or None. Providers without explicit prompt
caching ignore it (and the wrapper only forwards it when set).
Returns:
If return_usage=False: Parsed response if response_format is provided, otherwise text content.
@@ -108,6 +114,7 @@ class LLMInterface(ABC):
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
cached_prefix: str | None = None,
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support.
@@ -137,6 +144,46 @@ class LLMInterface(ABC):
"""
return False
# ── Prompt prefix caching (optional, per-provider) ─────────────────────────
def supports_prompt_caching(self) -> bool:
"""Whether this provider can cache a reusable prompt prefix.
Default False. Providers that return True must implement
``get_or_create_cached_prefix`` and honour the ``cached_prefix`` argument
of ``call`` / ``call_with_tools``.
"""
return False
async def get_or_create_cached_prefix(
self,
*,
system_instruction: str,
response_schema: Any | None = None,
tools: list[dict[str, Any]] | None = None,
) -> str | None:
"""Cache a reusable prompt prefix and return an opaque handle, or None.
The engine has already decided WHAT is cacheable: it puts the stable,
bank-agnostic instructions in ``system_instruction`` (plus ``tools``) and
keeps all per-request / per-bank data (documents, facts, the bank mission)
in the user message. A provider only chooses HOW to cache that prefix:
- Explicit-cache providers (e.g. Gemini ``CachedContent``): create the
cache, return its handle; the engine passes the handle back via
``call(cached_prefix=...)`` and the provider then drops the prefix from
the request, billing it at the cached rate.
- Automatic-cache providers (e.g. OpenAI): no handle needed — caching is
transparent as long as the prefix is a stable leading block, which it
already is. They can keep this default (return None) and still benefit.
- Inline-marker providers (e.g. Anthropic ``cache_control``): mark the
prefix block inside ``call`` instead; may also keep this default.
Returns None when caching is disabled/unsupported or the prefix is too
small; callers MUST fall back to an uncached call in that case.
"""
return None
async def submit_batch(
self,
requests: list[dict[str, Any]],
@@ -0,0 +1,540 @@
"""Per-bank LLM request tracing.
Opt-in, fire-and-forget recording of every LLM call Hindsight makes (both
successes and failures) into the ``llm_requests`` table, per bank. Each row
captures the input messages, the model output, token usage (input / output /
cached / total), finish reason, and caller metadata. Disabled by default —
controlled by ``HINDSIGHT_API_LLM_TRACE_ENABLED``.
This plugs into the OpenTelemetry **GenAI** recording pattern: providers already
call ``tracing.get_span_recorder().record_llm_call(...)`` on success, so the DB
tracer is registered as one of those recorders (alongside the OTLP span
exporter) rather than hooking the call path with custom code. Failures, which
providers don't report to the recorder, are forwarded from the LLM wrapper.
Bank/operation attribution is carried via a ContextVar set by
``ConfiguredLLMProvider`` (see ``llm_wrapper.py``); outside a traced context
``bank_id`` is recorded as NULL.
"""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from collections.abc import Callable, Iterable
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any
from pydantic import BaseModel
from .db_utils import acquire_with_retry
logger = logging.getLogger(__name__)
# ── bank/operation attribution (carried across the async call chain) ──────────
@dataclass
class LLMTraceContext:
"""Attribution for in-flight LLM calls, bound by ``ConfiguredLLMProvider``.
``trace_id`` and ``operation_span_id`` are generated once per operation
invocation (one ``with_config`` call), so every LLM call of a single
reflect/retain/consolidation run shares them — reproducing the OTel
parent (operation span) → children (LLM calls) hierarchy in the DB.
"""
bank_id: str | None = None
operation: str | None = None # "retain" | "reflect" | "consolidation" | ...
metadata: dict[str, Any] = field(default_factory=dict)
trace_id: str | None = None
operation_span_id: str | None = None
# Memory_units this operation produced/consumed, accumulated at the DB-write
# sites and flushed onto every row of the trace at operation end (see
# LLMTraceRecorder.attach_memory_ids). Lets a retain/consolidation trace map
# to the memories it created (outputs) and consumed (source inputs).
created_memory_ids: list[str] = field(default_factory=list)
source_memory_ids: list[str] = field(default_factory=list)
_trace_ctx: ContextVar[LLMTraceContext | None] = ContextVar("hindsight_llm_trace_ctx", default=None)
# Per-call requested parameters (max_completion_tokens, temperature, response
# schema, tool_choice). Set by ``LLMProvider.call`` around the provider
# delegation so the recorder can attach them even though success is reported by
# the provider. Only includes values the caller actually set — never nulls.
_request_ctx: ContextVar[dict[str, Any] | None] = ContextVar("hindsight_llm_request_ctx", default=None)
# Per-call caller metadata (e.g. document_id for retain extraction). Set by
# engine code around a specific LLM call; merged into the row's metadata on top
# of the operation-level LLMTraceContext.metadata.
_call_metadata_ctx: ContextVar[dict[str, Any] | None] = ContextVar("hindsight_llm_call_metadata_ctx", default=None)
def set_trace_context(ctx: LLMTraceContext | None) -> Token:
"""Bind trace attribution to the current context. Returns a reset token."""
return _trace_ctx.set(ctx)
def reset_trace_context(token: Token) -> None:
"""Unwind a binding made by :func:`set_trace_context`."""
_trace_ctx.reset(token)
def set_request_context(params: dict[str, Any] | None) -> Token:
"""Bind the current LLM call's requested parameters. Returns a reset token."""
return _request_ctx.set(params)
def reset_request_context(token: Token) -> None:
"""Unwind a binding made by :func:`set_request_context`."""
_request_ctx.reset(token)
def current_request_context() -> dict[str, Any] | None:
"""Return the active call's requested parameters, or None."""
return _request_ctx.get()
def set_call_metadata(metadata: dict[str, Any] | None) -> Token:
"""Bind per-call caller metadata (e.g. ``{"document_id": ...}``)."""
return _call_metadata_ctx.set(metadata)
def reset_call_metadata(token: Token) -> None:
"""Unwind a binding made by :func:`set_call_metadata`."""
_call_metadata_ctx.reset(token)
def current_call_metadata() -> dict[str, Any] | None:
"""Return the active call's caller metadata, or None."""
return _call_metadata_ctx.get()
def current_trace_context() -> LLMTraceContext | None:
"""Return the active trace attribution, or None outside a traced context."""
return _trace_ctx.get()
def trace_context_of(llm_config: Any) -> LLMTraceContext | None:
"""Return a configured provider's operation trace context, or None.
Real providers expose ``trace_context()`` (``ConfiguredLLMProvider``); test
or mock substitutes may not, so this degrades gracefully rather than raising
— tracing is best-effort and must never break an operation.
"""
getter = getattr(llm_config, "trace_context", None)
return getter() if callable(getter) else None
def record_created_memory_ids(ids: Iterable[str]) -> None:
"""Accumulate output memory_units onto the active operation trace.
No-op outside a traced operation context (e.g. tracing disabled). Child
asyncio tasks inherit the same ``LLMTraceContext`` object, so appends from
parallel consolidation batches land on one shared list.
"""
ctx = _trace_ctx.get()
if ctx is not None:
ctx.created_memory_ids.extend(str(i) for i in ids)
def record_source_memory_ids(ids: Iterable[str]) -> None:
"""Accumulate consumed/source memory_units onto the active operation trace.
No-op outside a traced operation context.
"""
ctx = _trace_ctx.get()
if ctx is not None:
ctx.source_memory_ids.extend(str(i) for i in ids)
# ── serialization helpers ─────────────────────────────────────────────────────
def _json_default(obj: Any) -> Any:
"""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)
model_dump = getattr(obj, "model_dump", None)
if callable(model_dump):
try:
return model_dump(mode="json")
except Exception:
return str(obj)
return str(obj)
def _safe_json(data: Any, max_chars: int) -> str | None:
"""Serialize ``data`` to a JSON string, truncating beyond ``max_chars``.
Returns None on total failure. Truncation preserves valid JSON by wrapping
the oversized payload in a marker object with a preview.
"""
if data is None:
return None
try:
serialized = json.dumps(data, default=_json_default)
except Exception:
logger.debug("Failed to serialize llm trace data", exc_info=True)
try:
serialized = json.dumps(str(data))
except Exception:
return None
if max_chars and max_chars > 0 and len(serialized) > max_chars:
return json.dumps({"_truncated": True, "_original_chars": len(serialized), "preview": serialized[:max_chars]})
return serialized
# ── record ────────────────────────────────────────────────────────────────────
@dataclass
class LLMRequestRecord:
"""A single LLM request trace row."""
provider: str
model: str | None
scope: str
status: str # "success" | "error"
started_at: datetime
ended_at: datetime
bank_id: str | None = None
operation: str | None = None
trace_id: str | None = None
span_id: str | None = None
parent_span_id: str | None = None
input: Any = None
output: Any = None
error: str | None = None
input_tokens: int | None = None
output_tokens: int | None = None
cached_tokens: int | None = None
total_tokens: int | None = None
llm_info: dict[str, Any] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
@property
def duration_ms(self) -> int:
return int((self.ended_at - self.started_at).total_seconds() * 1000)
# ── read models (returned by MemoryEngine query methods, served by the API) ───
class LLMRequestEntry(BaseModel):
"""A single LLM request trace row, as returned by the read API."""
id: str
bank_id: str | None
operation: str | None
scope: str | None
trace_id: str | None
span_id: str | None
parent_span_id: str | None
provider: str | None
model: str | None
status: str
started_at: str | None
ended_at: str | None
duration_ms: int | None
input_tokens: int | None
output_tokens: int | None
cached_tokens: int | None
total_tokens: int | None
# Arbitrary JSON (message list, string, or object) — open `Any` so the
# OpenAPI schema stays a plain open type the Go SDK generator can model.
input: Any = None
output: Any = None
error: str | None
llm_info: dict[str, Any]
metadata: dict[str, Any]
class LLMRequestListResponse(BaseModel):
"""Paginated list of LLM request traces for a bank."""
bank_id: str
total: int
limit: int
offset: int
items: list[LLMRequestEntry]
class LLMRequestTokenSums(BaseModel):
"""Token totals for a time bucket."""
input: int
output: int
cached: int
total: int
class LLMRequestStatsBucket(BaseModel):
"""A single time bucket in LLM request stats."""
time: str
statuses: dict[str, int]
total: int
tokens: LLMRequestTokenSums
class LLMRequestStatsResponse(BaseModel):
"""LLM request counts and token sums grouped by time bucket."""
bank_id: str
period: str
trunc: str
start: str
buckets: list[LLMRequestStatsBucket]
# ── recorder / writer ─────────────────────────────────────────────────────────
class LLMTraceRecorder:
"""GenAI span recorder that writes per-bank LLM traces to ``llm_requests``.
Implements ``record_llm_call`` so it can be registered with
:func:`hindsight_api.tracing.register_span_recorder`. Writes are
fire-and-forget and never surface errors into the calling path. Retention of
old rows is handled by the background :class:`MaintenanceLoop`.
"""
def __init__(
self,
pool_getter: Callable[[], Any],
schema_getter: Callable[[], str],
enabled: bool,
allowed_scopes: list[str],
max_chars: int = 50000,
) -> None:
self._pool_getter = pool_getter
self._schema_getter = schema_getter
self._enabled = enabled
self._allowed_scopes: frozenset[str] | None = frozenset(allowed_scopes) if allowed_scopes else None
self._max_chars = max_chars
# In-flight fire-and-forget write tasks, bucketed by trace_id so
# attach_memory_ids can await only *its own* operation's writes before the
# post-operation UPDATE (otherwise the UPDATE could race ahead of the
# INSERTs it patches — but it must not block on unrelated operations).
self._pending: dict[str | None, set[asyncio.Task]] = {}
def is_enabled(self, scope: str) -> bool:
"""Whether tracing is active for the given call scope."""
if not self._enabled:
return False
if self._allowed_scopes is not None:
return scope in self._allowed_scopes
return True
# ── GenAI recorder interface ──────────────────────────────────────────────
def record_llm_call(
self,
provider: str,
model: str,
scope: str,
messages: list[dict[str, Any]],
response_content: Any = None,
input_tokens: int = 0,
output_tokens: int = 0,
duration: float = 0.0,
finish_reason: str | None = None,
error: BaseException | None = None,
tool_calls: list[dict[str, Any]] | None = None,
cached_tokens: int = 0,
**_extra: Any,
) -> None:
"""Build a trace record from a GenAI call and schedule a DB write."""
if not self.is_enabled(scope):
return
ctx = current_trace_context()
ended_at = datetime.now(timezone.utc)
started_at = ended_at - timedelta(seconds=max(0.0, duration))
# Operation-level metadata + any per-call metadata (e.g. document_id).
metadata = dict(ctx.metadata) if ctx else {}
call_metadata = current_call_metadata()
if call_metadata:
metadata.update(call_metadata)
llm_info: dict[str, Any] = {}
request_params = current_request_context()
if request_params:
llm_info["request"] = dict(request_params)
if finish_reason:
llm_info["finish_reason"] = finish_reason
if tool_calls:
llm_info["tool_calls"] = [tc.get("name", "") for tc in tool_calls]
record = LLMRequestRecord(
provider=provider,
model=model,
scope=scope,
status="error" if error is not None else "success",
started_at=started_at,
ended_at=ended_at,
bank_id=ctx.bank_id if ctx else None,
operation=ctx.operation if ctx else None,
# OTel-style hierarchy: all calls of one operation invocation share
# the context's trace_id and point at its operation span; this call
# gets its own span_id.
trace_id=ctx.trace_id if ctx else None,
span_id=str(uuid.uuid4()),
parent_span_id=ctx.operation_span_id if ctx else None,
input=messages,
output=None if error is not None else response_content,
error=f"{type(error).__name__}: {error}" if error is not None else None,
input_tokens=input_tokens or None,
output_tokens=output_tokens or None,
cached_tokens=cached_tokens or None,
total_tokens=(input_tokens + output_tokens) or None,
llm_info=llm_info,
metadata=metadata,
)
self._record_fire_and_forget(record)
def _record_fire_and_forget(self, record: LLMRequestRecord) -> None:
"""Schedule a trace write as a background task."""
try:
task = asyncio.create_task(self._safe_write(record))
except RuntimeError:
# No running event loop (e.g. during shutdown)
logger.debug("Cannot schedule llm trace write: no running event loop")
return
key = record.trace_id
self._pending.setdefault(key, set()).add(task)
task.add_done_callback(lambda t, k=key: self._discard_pending(k, t))
def _discard_pending(self, key: str | None, task: asyncio.Task) -> None:
bucket = self._pending.get(key)
if bucket is not None:
bucket.discard(task)
if not bucket:
self._pending.pop(key, None)
async def _safe_write(self, record: LLMRequestRecord) -> None:
"""Write a trace row. Errors are logged, never raised."""
pool = self._pool_getter()
if pool is None:
logger.debug("LLM trace skipped: pool not available")
return
try:
schema = self._schema_getter()
table = f"{schema}.llm_requests"
async with acquire_with_retry(pool, max_retries=1) as conn:
await conn.execute(
f"""
INSERT INTO {table}
(id, bank_id, operation, scope, trace_id, span_id, parent_span_id,
provider, model, status,
started_at, ended_at, duration_ms,
input_tokens, output_tokens, cached_tokens, total_tokens,
input, output, error, llm_info, metadata)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17,
$18::jsonb, $19::jsonb, $20, $21::jsonb, $22::jsonb)
""",
uuid.uuid4(),
record.bank_id,
record.operation,
record.scope,
record.trace_id,
record.span_id,
record.parent_span_id,
record.provider,
record.model,
record.status,
record.started_at,
record.ended_at,
record.duration_ms,
record.input_tokens,
record.output_tokens,
record.cached_tokens,
record.total_tokens,
_safe_json(record.input, self._max_chars),
_safe_json(record.output, self._max_chars),
record.error,
_safe_json(record.llm_info, self._max_chars) or "{}",
_safe_json(record.metadata, self._max_chars) or "{}",
)
except Exception as e:
logger.warning(f"LLM trace write failed for scope={record.scope}: {e}")
async def _flush_pending(self, trace_id: str) -> None:
"""Await this trace's in-flight writes so its rows exist before an UPDATE."""
pending = [t for t in self._pending.get(trace_id, ()) if not t.done()]
if pending:
await asyncio.gather(*pending, return_exceptions=True)
def attach_memory_ids(
self,
trace_ctx: LLMTraceContext | None,
*,
created: list[str] | None = None,
source: list[str] | None = None,
) -> None:
"""Map a finished operation's memory_units onto every row of its trace.
Merges the explicitly passed ids with any accumulated on the context
(``record_created_memory_ids`` / ``record_source_memory_ids``), de-dupes
preserving order, and patches ``metadata.memory_ids`` (outputs created)
and ``metadata.source_memory_ids`` (inputs consumed) on all rows sharing
the trace_id. No-op when tracing is off or nothing was produced.
Fire-and-forget: the snapshotted patch is applied on a background task so
the retain/consolidation operation never waits on the trace write. The
ids are snapshotted synchronously here because the caller may reset the
context immediately after.
"""
if not self._enabled or trace_ctx is None or not trace_ctx.trace_id:
return
created_ids = list(dict.fromkeys([*(created or []), *trace_ctx.created_memory_ids]))
source_ids = list(dict.fromkeys([*(source or []), *trace_ctx.source_memory_ids]))
patch: dict[str, Any] = {}
if created_ids:
patch["memory_ids"] = created_ids
if source_ids:
patch["source_memory_ids"] = source_ids
if not patch:
return
try:
asyncio.create_task(self._attach_memory_ids(trace_ctx.bank_id, trace_ctx.trace_id, patch))
except RuntimeError:
logger.debug("Cannot schedule llm trace memory_id attach: no running event loop")
async def _attach_memory_ids(self, bank_id: str | None, trace_id: str, patch: dict[str, Any]) -> None:
"""Background worker: flush this trace's writes, then patch its rows."""
# The trace-row INSERTs are fire-and-forget; flush *this trace's* writes
# so the UPDATE patches rows that already exist rather than racing ahead
# of them (without blocking on unrelated operations' pending writes).
await self._flush_pending(trace_id)
pool = self._pool_getter()
if pool is None:
return
try:
schema = self._schema_getter()
table = f"{schema}.llm_requests"
async with acquire_with_retry(pool, max_retries=1) as conn:
await conn.execute(
f"UPDATE {table} SET metadata = metadata || $3::jsonb WHERE bank_id = $1 AND trace_id = $2",
bank_id,
trace_id,
json.dumps(patch),
)
except Exception as e:
logger.warning(f"LLM trace memory_id attach failed for trace={trace_id}: {e}")
@@ -114,6 +114,32 @@ def _semaphores_for_scope(scope: str) -> list[asyncio.Semaphore]:
return [per_op, _global_llm_semaphore]
def _request_params(
*,
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str | None = None,
response_format: Any | None = None,
tool_choice: str | dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""Build the requested-params bag for tracing — only values the caller set.
Omitting unset values avoids the misleading nulls we used to record (e.g.
consolidation, which passes no token cap), while surfacing the real cap for
callers that do set one (e.g. retain's ``retain_max_completion_tokens``).
"""
params: dict[str, Any] = {}
if max_completion_tokens is not None:
params["max_completion_tokens"] = max_completion_tokens
if temperature is not None:
params["temperature"] = temperature
if response_format is not None:
params["response_schema"] = getattr(response_format, "__name__", None) or "structured"
if tool_choice is not None and tool_choice != "auto":
params["tool_choice"] = tool_choice if isinstance(tool_choice, str) else "named"
return params or None
def sanitize_text(text: str | None) -> str | None:
"""
Sanitize text by removing characters that break downstream systems.
@@ -229,6 +255,7 @@ def create_llm_provider(
vertexai_region: str | None = None,
vertexai_credentials: Any = None,
gemini_safety_settings: list | None = None,
prompt_cache_enabled: bool = False,
litellmrouter_config: dict[str, Any] | None = None,
) -> Any: # Returns LLMInterface
"""
@@ -242,7 +269,11 @@ 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.
extra_body: Extra request-body params merged into the provider's native
call. Threaded into OpenAI-compatible, Fireworks, Anthropic, Gemini/
VertexAI and LiteLLM providers (each merges them in its own parameter
space). Keys must use each provider's native names (e.g. ``max_tokens``
for OpenAI/Anthropic vs ``max_output_tokens`` for Gemini).
default_headers: Custom headers passed as ``default_headers`` to provider SDK clients
(used by operators routing through proxies / request-tracing middleware). Currently
wired into the Anthropic provider; other providers may opt in as needed.
@@ -317,6 +348,8 @@ def create_llm_provider(
vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials,
gemini_safety_settings=gemini_safety_settings,
prompt_cache_enabled=prompt_cache_enabled,
extra_body=extra_body,
)
elif provider_lower == "anthropic":
@@ -327,6 +360,7 @@ def create_llm_provider(
model=model,
reasoning_effort=reasoning_effort,
default_headers=default_headers,
extra_body=extra_body,
)
elif provider_lower == "litellm":
@@ -336,6 +370,7 @@ def create_llm_provider(
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
)
elif provider_lower == "litellmrouter":
@@ -353,6 +388,7 @@ def create_llm_provider(
model=model,
config=litellmrouter_config,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
)
elif provider_lower == "bedrock":
@@ -364,6 +400,7 @@ def create_llm_provider(
base_url=base_url,
model=bedrock_model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
)
elif provider_lower == "llamacpp":
@@ -442,6 +479,7 @@ class LLMProvider:
groq_service_tier: str | None = None,
openai_service_tier: str | None = None,
gemini_safety_settings: list | None = None,
prompt_cache_enabled: bool = False,
extra_body: dict[str, Any] | None = None,
default_headers: dict[str, str] | None = None,
litellmrouter_config: dict[str, Any] | None = None,
@@ -458,7 +496,8 @@ 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.
extra_body: Extra request-body params merged into the provider's native call
(OpenAI-compatible, Fireworks, Anthropic, Gemini/VertexAI, LiteLLM).
default_headers: Custom headers passed as ``default_headers`` to provider SDK clients.
Used by operators routing through proxies / request-tracing middleware. Falls
back to ``HindsightConfig.llm_default_headers`` (env: ``HINDSIGHT_API_LLM_DEFAULT_HEADERS``)
@@ -480,6 +519,11 @@ 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
# Gemini prompt caching: when True, retain extraction (and any future
# caller that opts in) will reuse a CachedContent prefix to cut
# input-token cost. Off by default so the change is observable behind
# a flip rather than a silent behaviour change on upgrade.
self.prompt_cache_enabled = prompt_cache_enabled
# Extra body params for OpenAI-compatible providers (e.g. chat_template_kwargs)
self.extra_body = extra_body
# Default headers passed to provider SDK clients (e.g. proxy auth, request tracing).
@@ -598,6 +642,21 @@ class LLMProvider:
except Exception:
pass # Config may not be initialized in test environments
# Prompt-prefix caching is a provider-agnostic toggle (default on): resolve
# it from the static server config for every provider when the caller didn't
# pass an explicit override. Providers that don't support caching ignore the
# value; only those that implement get_or_create_cached_prefix act on it.
if not self.prompt_cache_enabled:
from ..config import DEFAULT_LLM_PROMPT_CACHE_ENABLED, _get_raw_config
try:
raw_config = _get_raw_config()
self.prompt_cache_enabled = bool(
getattr(raw_config, "llm_prompt_cache_enabled", DEFAULT_LLM_PROMPT_CACHE_ENABLED)
)
except Exception:
pass # Config may not be initialized in test environments
# For litellmrouter: prefer an explicit chain from the caller (per-op
# construction in MemoryEngine threads the right chain through). If the caller
# didn't supply one, fall back to the global ``llm_litellmrouter_config`` so
@@ -626,6 +685,7 @@ class LLMProvider:
vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials,
gemini_safety_settings=self.gemini_safety_settings,
prompt_cache_enabled=self.prompt_cache_enabled,
litellmrouter_config=router_config,
)
@@ -689,6 +749,7 @@ class LLMProvider:
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
cached_prefix: str | None = None,
) -> Any:
"""
Make an LLM API call with retry logic.
@@ -703,7 +764,10 @@ class LLMProvider:
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
skip_validation: Return raw JSON without Pydantic validation.
strict_schema: Use strict JSON schema enforcement (OpenAI only). Guarantees all required fields.
strict_schema: Per-call override requesting grammar-enforced (json_schema strict)
structured output instead of the soft json_object path. The server-level
HINDSIGHT_API_LLM_STRICT_SCHEMA flag is OR-ed in here so it applies to every call;
providers without a strict mode ignore it.
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
Returns:
@@ -723,33 +787,83 @@ class LLMProvider:
structured = "+structured" if response_format is not None else ""
set_stage(f"llm.{self.provider}.{scope}{structured}")
async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
# Resolve strict-schema once, here, rather than in each provider: the
# per-call argument OR the server-level HINDSIGHT_API_LLM_STRICT_SCHEMA
# flag. Providers with a json_schema response_format (OpenAI-compatible,
# LiteLLM) then grammar-enforce structured output instead of the fragile
# soft json_object path; Gemini already enforces its native response_schema,
# and providers without a strict mode simply ignore the flag.
from ..config import get_config
# Delegate to provider implementation
result = await self._provider_impl.call(
messages=messages,
response_format=response_format,
strict_schema = strict_schema or get_config().llm_strict_schema
# LLM call observability flows through the OTel GenAI recorder
# (tracing.get_span_recorder().record_llm_call). Provider implementations
# record successful calls; we forward failures here since they don't.
# The requested params are stashed in a contextvar (only what the caller
# actually set) so the recorder can attach them to either path.
from ..tracing import get_span_recorder
from .llm_trace import reset_request_context, set_request_context
call_start = time.monotonic()
request_token = set_request_context(
_request_params(
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,
response_format=response_format,
)
)
try:
async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
# Backward compatibility: Update mock call tracking for mock provider
# This allows existing tests using LLMProvider._mock_calls to continue working
if self.provider == "mock":
from .providers.mock_llm import MockLLM
# cached_prefix is only set for providers that returned a handle
# from get_or_create_cached_prefix() (e.g. Gemini); it's None for
# the rest. Forward it only when present so providers that don't
# implement caching keep their call() signature untouched.
cache_kwarg = {"cached_prefix": cached_prefix} if cached_prefix is not None else {}
try:
# Delegate to provider implementation
result = await self._provider_impl.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,
**cache_kwarg,
)
except Exception as e:
get_span_recorder().record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=None,
input_tokens=0,
output_tokens=0,
duration=time.monotonic() - call_start,
error=e,
)
raise
if isinstance(self._provider_impl, MockLLM):
# Sync the mock calls from provider implementation to wrapper
self._mock_calls = self._provider_impl.get_mock_calls()
# Backward compatibility: Update mock call tracking for mock provider
# This allows existing tests using LLMProvider._mock_calls to continue working
if self.provider == "mock":
from .providers.mock_llm import MockLLM
if isinstance(self._provider_impl, MockLLM):
# Sync the mock calls from provider implementation to wrapper
self._mock_calls = self._provider_impl.get_mock_calls()
finally:
reset_request_context(request_token)
return result
@@ -764,6 +878,7 @@ class LLMProvider:
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
cached_prefix: str | None = None,
) -> "LLMToolCallResult":
"""
Make an LLM API call with tool/function calling support.
@@ -786,31 +901,66 @@ class LLMProvider:
set_stage(f"llm.{self.provider}.{scope}+tools")
async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
# Failures forwarded to the GenAI recorder; successes recorded by providers.
from ..tracing import get_span_recorder
from .llm_trace import reset_request_context, set_request_context
# Delegate to provider implementation
result = await self._provider_impl.call_with_tools(
messages=messages,
tools=tools,
call_start = time.monotonic()
request_token = set_request_context(
_request_params(
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,
)
)
try:
async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
# Backward compatibility: Update mock call tracking for mock provider
# This allows existing tests using LLMProvider._mock_calls to continue working
if self.provider == "mock":
from .providers.mock_llm import MockLLM
# cached_prefix is only set for providers that returned a handle
# from get_or_create_cached_prefix(); forward it only when present
# so non-caching providers keep their signature (same as call()).
cache_kwarg = {"cached_prefix": cached_prefix} if cached_prefix is not None else {}
try:
# Delegate to provider implementation
result = await self._provider_impl.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,
**cache_kwarg,
)
except Exception as e:
get_span_recorder().record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=None,
input_tokens=0,
output_tokens=0,
duration=time.monotonic() - call_start,
error=e,
)
raise
if isinstance(self._provider_impl, MockLLM):
# Sync the mock calls from provider implementation to wrapper
self._mock_calls = self._provider_impl.get_mock_calls()
# Backward compatibility: Update mock call tracking for mock provider
# This allows existing tests using LLMProvider._mock_calls to continue working
if self.provider == "mock":
from .providers.mock_llm import MockLLM
if isinstance(self._provider_impl, MockLLM):
# Sync the mock calls from provider implementation to wrapper
self._mock_calls = self._provider_impl.get_mock_calls()
finally:
reset_request_context(request_token)
return result
@@ -914,7 +1064,14 @@ class LLMProvider:
# SDK will automatically check for authentication when first used
# No need to verify here - let it fail gracefully on first call with helpful error
def with_config(self, config: Any) -> "ConfiguredLLMProvider":
def with_config(
self,
config: Any,
*,
bank_id: str | None = None,
operation: str | None = None,
metadata: dict[str, Any] | None = None,
) -> "ConfiguredLLMProvider":
"""
Return a configured wrapper for a specific bank operation.
@@ -924,12 +1081,31 @@ class LLMProvider:
Args:
config: Resolved ``HindsightConfig`` for the current bank/request.
bank_id: Bank the operation runs for; attributed to LLM trace rows.
operation: Logical operation label ("retain", "reflect", ...) for
LLM trace rows.
metadata: Optional extra caller metadata stored on trace rows.
Returns:
A ``ConfiguredLLMProvider`` that delegates to this provider with
the supplied config applied.
"""
return ConfiguredLLMProvider(self, config.llm_gemini_safety_settings)
trace_ctx = None
if bank_id is not None or operation is not None or metadata:
from .llm_trace import LLMTraceContext
# One trace + operation span per with_config() call — i.e. per
# operation invocation. Every LLM call made through this wrapper
# shares them, so a reflect/retain/consolidation run groups its
# calls as parent (operation) → children (LLM calls).
trace_ctx = LLMTraceContext(
bank_id=bank_id,
operation=operation,
metadata=dict(metadata or {}),
trace_id=str(uuid.uuid4()),
operation_span_id=str(uuid.uuid4()),
)
return ConfiguredLLMProvider(self, config.llm_gemini_safety_settings, trace_ctx)
async def cleanup(self) -> None:
"""Clean up resources (e.g. stop llamacpp subprocess)."""
@@ -993,10 +1169,16 @@ class ConfiguredLLMProvider:
any changes.
"""
def __init__(self, provider: "LLMProvider", gemini_safety_settings: list | None) -> None:
def __init__(
self,
provider: "LLMProvider",
gemini_safety_settings: list | None,
trace_ctx: Any | None = None,
) -> None:
# Use object.__setattr__ to avoid triggering __getattr__
object.__setattr__(self, "_provider", provider)
object.__setattr__(self, "_gemini_safety_settings", gemini_safety_settings)
object.__setattr__(self, "_trace_ctx", trace_ctx)
# ── attribute passthrough ──────────────────────────────────────────────────
@@ -1009,10 +1191,12 @@ class ConfiguredLLMProvider:
from .providers.gemini_llm import _safety_settings_ctx
token = _safety_settings_ctx.set(object.__getattribute__(self, "_gemini_safety_settings"))
trace_token = self._bind_trace_context()
try:
return await object.__getattribute__(self, "_provider").call(messages=messages, **kwargs)
finally:
_safety_settings_ctx.reset(token)
self._reset_trace_context(trace_token)
async def call_with_tools(
self,
@@ -1023,12 +1207,38 @@ class ConfiguredLLMProvider:
from .providers.gemini_llm import _safety_settings_ctx
token = _safety_settings_ctx.set(object.__getattribute__(self, "_gemini_safety_settings"))
trace_token = self._bind_trace_context()
try:
return await object.__getattribute__(self, "_provider").call_with_tools(
messages=messages, tools=tools, **kwargs
)
finally:
_safety_settings_ctx.reset(token)
self._reset_trace_context(trace_token)
def trace_context(self) -> Any | None:
"""The operation-level LLM trace context (or None when untraced).
Lets the engine attach the operation's produced/consumed memory_ids to
this run's trace rows once they're known (after the LLM calls).
"""
return object.__getattribute__(self, "_trace_ctx")
def _bind_trace_context(self) -> Any | None:
"""Bind bank/operation attribution for the duration of one call."""
trace_ctx = object.__getattribute__(self, "_trace_ctx")
if trace_ctx is None:
return None
from .llm_trace import set_trace_context
return set_trace_context(trace_ctx)
def _reset_trace_context(self, trace_token: Any | None) -> None:
if trace_token is None:
return
from .llm_trace import reset_trace_context
reset_trace_context(trace_token)
# Backwards compatibility alias
@@ -0,0 +1,214 @@
"""Background maintenance loop.
A single periodic loop that drives all of Hindsight's recurring housekeeping
from one place, so we don't spawn a separate ``asyncio`` task per concern:
- **Retention sweeps** (hourly): delete ``audit_log`` and ``llm_requests`` rows
older than their configured retention, across *all* tenant schemas.
- **Consolidation reconcile** (configurable, default 5 min): re-schedule
consolidation for banks that have eligible-but-unscheduled facts and no
in-flight consolidation. This recovers facts that were stranded when a
consolidation operation failed terminally and left them with
``consolidated_at IS NULL AND consolidation_failed_at IS NULL`` and nothing to
re-trigger them.
The loop wakes on a short fixed tick and runs each job when its own
``last_run + interval`` is due (run-at-start, then on interval), so adding jobs
with different cadences doesn't burst CPU. Cross-tenant discovery goes through
server-side PL/pgSQL routines (``public.schemas_with_expired_rows`` and
``public.banks_needing_consolidation``) — one round-trip each — instead of a
per-schema query storm, which matters at thousands of tenants.
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import TYPE_CHECKING
from ..config import HindsightConfig, get_config
from ..models import RequestContext
from .db_utils import acquire_with_retry
from .schema import _is_oracle
if TYPE_CHECKING:
from .memory_engine import MemoryEngine
logger = logging.getLogger(__name__)
# Short tick so jobs with different cadences share one loop without per-job tasks.
_TICK_SECONDS = 60
# Retention sweeps are not time-sensitive; hourly matches the previous per-sweep cadence.
_RETENTION_INTERVAL_SECONDS = 3600
class MaintenanceLoop:
"""Owns the single periodic maintenance task for a :class:`MemoryEngine`."""
def __init__(self, engine: "MemoryEngine") -> None:
self._engine = engine
self._task: asyncio.Task | None = None
self._stop = asyncio.Event()
# Monotonic timestamps of the last run per job, keyed by job name.
self._last_run: dict[str, float] = {}
# ── lifecycle ──────────────────────────────────────────────────────────
def start(self) -> None:
"""Start the loop if any maintenance job is enabled. Idempotent."""
if self._task and not self._task.done():
return
# PostgreSQL-only: the retention sweeps target PG-only tables (audit_log,
# llm_requests) and the reconcile relies on PG-only PL/pgSQL routines
# installed by the maintenance-routines migration. Oracle support is
# intentionally absent (mirrors that PG-only migration).
if _is_oracle():
logger.debug("Maintenance loop not started: PostgreSQL-only")
return
if not self._any_job_enabled():
logger.debug("Maintenance loop not started: no jobs enabled")
return
self._stop.clear()
try:
self._task = asyncio.create_task(self._run())
except RuntimeError:
logger.debug("Cannot start maintenance loop: no running event loop")
async def stop(self) -> None:
"""Stop the loop and wait for the current tick to finish."""
self._stop.set()
if self._task and not self._task.done():
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
@staticmethod
def _any_job_enabled() -> bool:
cfg = get_config()
reconcile_on = cfg.consolidation_reconcile_interval_seconds > 0
audit_on = cfg.audit_log_enabled and cfg.audit_log_retention_days > 0
llm_on = cfg.llm_trace_enabled and cfg.llm_trace_retention_days > 0
return reconcile_on or audit_on or llm_on
# ── loop ───────────────────────────────────────────────────────────────
async def _run(self) -> None:
while not self._stop.is_set():
try:
await self._tick()
except Exception:
logger.exception("Maintenance tick failed")
try:
await asyncio.wait_for(self._stop.wait(), timeout=_TICK_SECONDS)
except asyncio.TimeoutError:
pass
def _is_due(self, job: str, interval_seconds: int) -> bool:
"""True if ``job`` has never run or its interval has elapsed; marks it run now."""
now = time.monotonic()
last = self._last_run.get(job)
if last is not None and (now - last) < interval_seconds:
return False
self._last_run[job] = now
return True
async def _tick(self) -> None:
cfg = get_config()
if self._is_due("retention", _RETENTION_INTERVAL_SECONDS):
await self._run_retention(cfg)
interval = cfg.consolidation_reconcile_interval_seconds
if interval > 0 and self._is_due("reconcile", interval):
await self._run_reconcile()
# ── retention ──────────────────────────────────────────────────────────
async def _run_retention(self, cfg: HindsightConfig) -> None:
# Retention days are static server-level config, so one global cutoff
# applies to every tenant schema (the routine sweeps them all).
if cfg.audit_log_enabled and cfg.audit_log_retention_days > 0:
await self._purge_expired("audit_log", "started_at", cfg.audit_log_retention_days)
if cfg.llm_trace_enabled and cfg.llm_trace_retention_days > 0:
await self._purge_expired("llm_requests", "started_at", cfg.llm_trace_retention_days)
async def _purge_expired(self, table: str, ts_col: str, days: int) -> None:
"""Delete rows older than ``days`` from ``table`` across every tenant schema."""
backend = self._engine._backend
try:
async with acquire_with_retry(backend, max_retries=1) as conn:
rows = await conn.fetch(
"SELECT * FROM public.schemas_with_expired_rows($1, $2, $3)", table, ts_col, days
)
for row in rows:
schema = row[0]
# schema names come from pg_class; quote defensively all the same.
qschema = '"' + schema.replace('"', '""') + '"'
result = await conn.execute(
f"DELETE FROM {qschema}.{table} WHERE {ts_col} < NOW() - make_interval(days => $1)",
days,
)
if result and result != "DELETE 0":
logger.info(f"Retention sweep {schema}.{table}: {result}")
except Exception as e:
logger.warning(f"Retention sweep failed for {table}: {e}")
# ── consolidation reconcile ──────────────────────────────────────────────
async def _run_reconcile(self) -> None:
"""Re-schedule consolidation for banks with eligible-but-unscheduled facts."""
engine = self._engine
try:
async with acquire_with_retry(engine._backend, max_retries=1) as conn:
rows = await conn.fetch("SELECT schema_name, bank_id FROM public.banks_needing_consolidation()")
except Exception as e:
logger.warning(f"Consolidation reconcile discovery failed: {e}")
return
if not rows:
return
# Only enqueue into schemas the worker actually polls (tenant discovery),
# otherwise the op would never be claimed and would block future reconciles
# for that bank. The tenant_id (when the extension provides one) lets
# config resolution honor tenant-level overrides.
try:
tenants = await engine._tenant_extension.list_tenants()
except Exception as e:
logger.warning(f"Consolidation reconcile tenant discovery failed: {e}")
return
tenant_by_schema = {t.schema: t for t in tenants}
default_schema = get_config().database_schema
from .memory_engine import _current_schema
submitted = 0
skipped_unknown = 0
for row in rows:
schema = row["schema_name"]
bank_id = row["bank_id"]
tenant = tenant_by_schema.get(schema)
if tenant is None and schema != default_schema:
skipped_unknown += 1
continue
tenant_id = tenant.tenant_id if tenant else None
token = _current_schema.set(schema)
try:
context = RequestContext(internal=True, tenant_id=tenant_id)
resolved = await engine._config_resolver.resolve_full_config(bank_id, context)
# Mirror the retain-time auto-consolidation gate (memory_engine): both
# observations and auto-consolidation must be enabled for this bank.
if not (resolved.enable_observations and resolved.enable_auto_consolidation):
continue
await engine.submit_async_consolidation(bank_id=bank_id, request_context=context)
submitted += 1
except Exception as e:
logger.warning(f"Consolidation reconcile failed for bank {bank_id} in {schema}: {e}")
finally:
_current_schema.reset(token)
if submitted or skipped_unknown:
logger.info(
f"Consolidation reconcile: scheduled {submitted} bank(s)"
+ (f", skipped {skipped_unknown} in unrecognized schema(s)" if skipped_unknown else "")
)
File diff suppressed because it is too large Load Diff
@@ -5,8 +5,10 @@ These dataclasses define the structure of result_metadata for different operatio
The metadata is exposed in the API for debugging purposes and may change without notice.
"""
from dataclasses import asdict, dataclass
from typing import Any
from dataclasses import asdict, dataclass, field
from typing import Any, Mapping
MAX_EXTRACTION_ERROR_SAMPLES = 5
@dataclass
@@ -48,6 +50,79 @@ class RetainMetadata:
return asdict(self)
@dataclass
class RetainExtractionErrors:
"""Non-fatal fact extraction failures observed inside one retain operation."""
count: int = 0
sample: list[str] = field(default_factory=list)
def add(self, message: str) -> None:
"""Record one extraction error while keeping the stored sample bounded."""
self.count += 1
if len(self.sample) < MAX_EXTRACTION_ERROR_SAMPLES:
self.sample.append(message[:500])
def merge_metadata(self, metadata: Mapping[str, Any]) -> None:
"""Merge errors already present on an operation result_metadata object."""
self.count += int(metadata.get("extraction_errors_count") or 0)
sample = metadata.get("extraction_errors_sample") or []
if isinstance(sample, str):
sample = [sample]
if isinstance(sample, list):
for entry in sample:
if isinstance(entry, str) and len(self.sample) < MAX_EXTRACTION_ERROR_SAMPLES:
self.sample.append(entry[:500])
def to_dict(self) -> dict[str, Any]:
"""Convert to the public result_metadata field shape."""
data: dict[str, Any] = {"extraction_errors_count": self.count}
if self.sample:
data["extraction_errors_sample"] = self.sample
return data
@dataclass
class RetainOutcomeMetadata:
"""Machine-readable outcome metadata for a completed retain operation."""
unit_ids_count: int
extraction_errors_count: int = 0
extraction_errors_sample: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
"""Convert to dict for JSON serialization, omitting empty optional samples."""
data: dict[str, Any] = {
"unit_ids_count": self.unit_ids_count,
"extraction_errors_count": self.extraction_errors_count,
}
if self.extraction_errors_sample:
data["extraction_errors_sample"] = self.extraction_errors_sample[:MAX_EXTRACTION_ERROR_SAMPLES]
return data
@dataclass
class RetainOutcomeAggregate:
"""Aggregate retain outcome metadata from child retain operations."""
unit_ids_count: int = 0
extraction_errors: RetainExtractionErrors = field(default_factory=RetainExtractionErrors)
def add_metadata(self, metadata: Mapping[str, Any]) -> None:
"""Fold one child operation's result_metadata into the aggregate."""
self.unit_ids_count += int(metadata.get("unit_ids_count") or 0)
self.extraction_errors.merge_metadata(metadata)
def to_outcome_metadata(self) -> RetainOutcomeMetadata:
"""Return the aggregate in the public result_metadata field shape."""
return RetainOutcomeMetadata(
unit_ids_count=self.unit_ids_count,
extraction_errors_count=self.extraction_errors.count,
extraction_errors_sample=self.extraction_errors.sample,
)
@dataclass
class ConsolidationMetadata:
"""Metadata for consolidation operations."""
@@ -38,6 +38,7 @@ class AnthropicLLM(LLMInterface):
reasoning_effort: str = "low",
timeout: float = 300.0,
default_headers: dict[str, str] | None = None,
extra_body: dict[str, Any] | None = None,
**kwargs: Any,
):
"""
@@ -54,6 +55,10 @@ class AnthropicLLM(LLMInterface):
the Anthropic SDK client. Used by operators routing through proxies
or request-tracing middleware. Sourced from ``llm_default_headers`` in
``HindsightConfig`` (env: ``HINDSIGHT_API_LLM_DEFAULT_HEADERS``).
extra_body: Extra request-body params (e.g. ``{"temperature": 0.2,
"top_p": 0.9, "top_k": 40}``) passed via the Anthropic SDK's
``extra_body`` so they merge into the JSON sent to the Messages API.
Sourced from ``llm_extra_body`` (env: ``HINDSIGHT_API_LLM_EXTRA_BODY``).
**kwargs: Additional provider-specific parameters.
"""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
@@ -61,6 +66,9 @@ class AnthropicLLM(LLMInterface):
if not self.api_key:
raise ValueError("API key is required for Anthropic provider")
# User-configured extra body params (merged into every Messages API call)
self._extra_body = extra_body or {}
# Import and initialize Anthropic client
try:
from anthropic import AsyncAnthropic
@@ -178,6 +186,9 @@ class AnthropicLLM(LLMInterface):
if system_prompt:
call_params["system"] = system_prompt
if self._extra_body:
call_params["extra_body"] = self._extra_body
last_exception = None
for attempt in range(max_retries + 1):
@@ -216,6 +227,7 @@ class AnthropicLLM(LLMInterface):
input_tokens = response.usage.input_tokens or 0 if response.usage else 0
output_tokens = response.usage.output_tokens or 0 if response.usage else 0
total_tokens = input_tokens + output_tokens
cached_tokens = getattr(response.usage, "cache_read_input_tokens", 0) or 0 if response.usage else 0
# Record LLM metrics
metrics = get_metrics_collector()
@@ -245,6 +257,7 @@ class AnthropicLLM(LLMInterface):
duration=duration,
finish_reason=finish_reason,
error=None,
cached_tokens=cached_tokens,
)
# Log slow calls
@@ -260,6 +273,7 @@ class AnthropicLLM(LLMInterface):
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
cached_tokens=cached_tokens,
)
return result, token_usage
return result
@@ -394,6 +408,9 @@ class AnthropicLLM(LLMInterface):
if system_prompt:
call_params["system"] = system_prompt
if self._extra_body:
call_params["extra_body"] = self._extra_body
last_exception = None
for attempt in range(max_retries + 1):
try:
@@ -0,0 +1,316 @@
"""Gemini context-cache manager.
Wraps the ``google-genai`` SDK's CachedContent API to let callers reuse a
stable system_instruction + response_schema prefix across many requests.
Cached input tokens are billed at ~10× lower than fresh input tokens
(check the current Gemini pricing for the exact ratio per model), so for
workloads that repeatedly send a large fixed prefix with a small variable
user message — fact extraction, structured tagging, classification — the
input-cost savings are substantial.
This module owns only the create/refresh/lookup lifecycle. It is up to
the caller to (a) decide that the prefix is stable enough to cache, and
(b) pass the returned cache name to ``GeminiLLM.call()``. When the
returned name is ``None`` (because Gemini rejected the create — most
commonly because the prefix is smaller than the model's minimum), the
caller MUST fall back to a non-cached call.
Cardinality
-----------
The intended cache count per process is small (≲100 entries). Each
entry corresponds to one combination of (model, system_instruction,
response_schema). If a caller sees the cache grow unboundedly it
indicates the system_instruction contains per-request data that should
move into the user message instead.
TTL
---
Gemini's CachedContent has a TTL bounded by the model (currently 1h
for most generally-available models). This manager refreshes proactively
at ``ttl_safety_margin`` before expiry. If a cached entry has expired
between refreshes the next call will recreate it transparently.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import time
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
# Default TTL: 55 minutes. Gemini's hard max for CachedContent is 1 hour
# for most models; we refresh 5 minutes early so a request landing right
# at the boundary doesn't race against expiry.
_DEFAULT_TTL_SECONDS = 55 * 60
_DEFAULT_REFRESH_MARGIN_SECONDS = 5 * 60
# Cap on the cache-create network call. It runs while holding the manager lock, so
# a hung create would block every concurrent caller (e.g. all chunks of a 10-chunk
# retain batch waiting on the cold-start create). On timeout the create soft-fails
# to None and callers proceed uncached, rather than stalling the whole batch.
_DEFAULT_CREATE_TIMEOUT_SECONDS = 30.0
@dataclass
class _CacheEntry:
name: str # The CachedContent resource name returned by Gemini.
created_at: float
ttl_seconds: int
class GeminiCacheManager:
"""Per-process map of (prefix fingerprint) → CachedContent name.
Thread-safe across asyncio tasks via a single ``asyncio.Lock``. The
create/refresh calls are serialised; this is fine because cache
creation is a one-shot warm-up per fingerprint (subsequent reads are
pure dict lookups outside the lock).
Not shared across pods — each worker / api replica builds its own
cache. The cost of cold-starting one extra full-price call per pod
per fingerprint per hour is negligible compared to the steady-state
savings.
"""
def __init__(
self,
client: Any,
*,
ttl_seconds: int = _DEFAULT_TTL_SECONDS,
refresh_margin_seconds: int = _DEFAULT_REFRESH_MARGIN_SECONDS,
create_timeout_seconds: float = _DEFAULT_CREATE_TIMEOUT_SECONDS,
) -> None:
self._client = client
self._ttl_seconds = ttl_seconds
self._refresh_margin_seconds = refresh_margin_seconds
self._create_timeout_seconds = create_timeout_seconds
self._entries: dict[str, _CacheEntry] = {}
self._lock = asyncio.Lock()
@staticmethod
def fingerprint(
model: str,
system_instruction: str,
response_schema: Any | None = None,
tools: list[dict[str, Any]] | None = None,
) -> str:
"""Stable hash of the cacheable surface.
``response_schema`` may be a Pydantic class, a dict, or ``None``.
Pydantic schemas are normalised by serialising via
``model_json_schema()`` and stripping the auto-generated
``"title"`` fields so two dynamically-built models with the same
shape but different class names hash identically. This matters
for callers (e.g. fact extraction) that rebuild the schema
class on every request via a builder helper — without the
normalisation the cache would never hit.
``tools`` is the OpenAI-style tools list (each entry has a
``"function"`` dict with name/description/parameters). When
supplied, the tool definitions become part of the cache key so a
loop that adds or renames a tool gets a fresh cache and doesn't
silently use a stale schema. Tools are serialised with
``sort_keys=True`` to neutralise dict-ordering drift.
"""
hasher = hashlib.sha256()
hasher.update(model.encode("utf-8"))
hasher.update(b"\x00")
hasher.update(system_instruction.encode("utf-8"))
hasher.update(b"\x00")
if response_schema is None:
hasher.update(b"none")
elif hasattr(response_schema, "model_json_schema"):
try:
schema = response_schema.model_json_schema()
_strip_titles(schema)
hasher.update(json.dumps(schema, sort_keys=True).encode("utf-8"))
except Exception:
# Fall back to class identity if the schema can't be serialised.
hasher.update(repr(response_schema).encode("utf-8"))
else:
try:
hasher.update(json.dumps(response_schema, sort_keys=True).encode("utf-8"))
except (TypeError, ValueError):
hasher.update(repr(response_schema).encode("utf-8"))
hasher.update(b"\x00")
if tools:
try:
hasher.update(json.dumps(tools, sort_keys=True).encode("utf-8"))
except (TypeError, ValueError):
hasher.update(repr(tools).encode("utf-8"))
else:
hasher.update(b"no-tools")
return hasher.hexdigest()
async def get_or_create(
self,
*,
model: str,
system_instruction: str,
response_schema: Any | None = None,
tools: list[dict[str, Any]] | None = None,
) -> str | None:
"""Return a CachedContent resource name for the given prefix, or
``None`` if Gemini rejects the create (prefix too small, model
does not support caching, etc.).
``tools`` is the OpenAI-style tools list. When supplied, the tool
definitions are baked into the CachedContent so the caller's
``call_with_tools`` doesn't need to resend them on every
iteration. Pass ``None`` for non-tool calls.
``None`` return is a normal, expected value — the caller falls
back to an uncached call and the system continues to work.
"""
key = self.fingerprint(model, system_instruction, response_schema, tools)
async with self._lock:
entry = self._entries.get(key)
if entry is not None and self._is_fresh(entry):
return entry.name
# Need to (re)create. Pop the stale entry first so a failed
# create doesn't leave a name we'd return on the next call.
self._entries.pop(key, None)
try:
cache_name = await self._create_cache(
model=model,
system_instruction=system_instruction,
tools=tools,
)
except _CacheNotEligible as e:
logger.debug(
"GeminiCacheManager: prefix not eligible for caching (model=%s, reason=%s) — caller will fall back",
model,
e,
)
return None
except Exception:
logger.exception(
"GeminiCacheManager: failed to create cached content "
"(model=%s); caller will fall back to uncached call",
model,
)
return None
if cache_name is None:
return None
self._entries[key] = _CacheEntry(
name=cache_name,
created_at=time.monotonic(),
ttl_seconds=self._ttl_seconds,
)
return cache_name
def _is_fresh(self, entry: _CacheEntry) -> bool:
"""An entry is fresh if it's young enough that the next request
won't race against the TTL expiry."""
age = time.monotonic() - entry.created_at
return age < (entry.ttl_seconds - self._refresh_margin_seconds)
def invalidate(self, name: str) -> None:
"""Forget a cache name that the server rejected (expired/deleted/invalid).
Called by the provider when a generate request using this CachedContent
fails, so the next ``get_or_create`` recreates it instead of handing back
the dead name again. Best-effort and sync — drops the matching entry from
the in-process map; the orphaned server-side cache (if any) ages out on
its own TTL.
"""
for key, entry in list(self._entries.items()):
if entry.name == name:
self._entries.pop(key, None)
async def _create_cache(
self,
*,
model: str,
system_instruction: str,
tools: list[dict[str, Any]] | None = None,
) -> str | None:
"""Wrap ``client.aio.caches.create`` with the config we want.
The SDK surface differs slightly across google-genai versions;
this implementation targets the >=1.0.0 line where caches live
under ``client.aio.caches``.
"""
# Lazy import so this module doesn't require the SDK at import time.
from google.genai import types as genai_types
# A CachedContent only holds reusable *input* — system_instruction,
# contents, tools, ttl. ``response_schema``/``response_mime_type`` are
# generation-time output constraints and the SDK rejects them here
# (``CreateCachedContentConfig`` forbids those fields). They are applied
# per-request on the GenerateContentConfig instead — see the call sites,
# which set them alongside ``cached_content``. ``response_schema`` is
# still part of the fingerprint so a schema change keys a fresh cache.
config_kwargs: dict[str, Any] = {
"system_instruction": system_instruction,
"ttl": f"{self._ttl_seconds}s",
}
if tools:
# OpenAI-style {"function": {...}} entries must be converted to
# Gemini's Tool/FunctionDeclaration shape before caching.
gemini_tools = []
for tool in tools:
func = tool.get("function", {})
gemini_tools.append(
genai_types.Tool(
function_declarations=[
genai_types.FunctionDeclaration(
name=func.get("name", ""),
description=func.get("description", ""),
parameters=func.get("parameters"),
)
]
)
)
config_kwargs["tools"] = gemini_tools
try:
cached = await asyncio.wait_for(
self._client.aio.caches.create(
model=model,
config=genai_types.CreateCachedContentConfig(**config_kwargs),
),
timeout=self._create_timeout_seconds,
)
except Exception as e:
# Gemini returns a 400 with a "minimum token count" message
# when the prefix is too small. We treat this as a soft
# "not eligible" signal rather than a real error so callers
# silently fall back to non-cached.
msg = str(e).lower()
if "minimum" in msg or "too small" in msg or "too short" in msg:
raise _CacheNotEligible(str(e)) from e
raise
return getattr(cached, "name", None)
class _CacheNotEligible(Exception):
"""Raised when Gemini rejects the cache create because the prefix
is below the model's minimum cacheable size. Treated as a soft
fallback by the caller, not an error."""
def _strip_titles(node: Any) -> None:
"""Recursively remove auto-generated ``"title"`` keys from a JSON
Schema-like dict tree, in place. Pydantic seeds these from the
Python class name, which means structurally-identical schemas built
from differently-named classes look distinct to a naive hash."""
if isinstance(node, dict):
node.pop("title", None)
for v in node.values():
_strip_titles(v)
elif isinstance(node, list):
for item in node:
_strip_titles(item)
@@ -70,6 +70,22 @@ class GeminiLLM(LLMInterface):
# Safety settings: None means use Gemini's defaults
self._safety_settings: list | None = kwargs.get("gemini_safety_settings")
# User-configured extra params merged into the GenerateContentConfig of
# every call. Gemini's request body nests generation params, so we expose
# them in the SDK's native config space rather than as a raw body merge:
# keys must be GenerateContentConfig fields (e.g. temperature, top_p,
# top_k, max_output_tokens, seed). Sourced from llm_extra_body
# (env: HINDSIGHT_API_LLM_EXTRA_BODY).
self._extra_body: dict[str, Any] = kwargs.get("extra_body") or {}
# Context-cache manager. Lazy-initialized on first cache lookup so
# nothing happens for models/workloads that never reach it. The instance
# default here is off (a directly-constructed GeminiLLM doesn't cache); the
# server-level default is on and flows in via the prompt_cache_enabled kwarg
# resolved from config in LLMProvider.
self._cache_manager: Any | None = None
self._prompt_cache_enabled: bool = bool(kwargs.get("prompt_cache_enabled", False))
if self._is_vertexai:
self._init_vertexai(**kwargs)
else:
@@ -168,6 +184,7 @@ class GeminiLLM(LLMInterface):
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
cached_prefix: str | None = None,
) -> Any:
"""
Make a Gemini/VertexAI API call with retry logic.
@@ -182,8 +199,17 @@ class GeminiLLM(LLMInterface):
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
skip_validation: Return raw JSON without Pydantic validation.
strict_schema: Use strict JSON schema enforcement (not supported by Gemini).
strict_schema: Ignored — Gemini always grammar-enforces structured output via its
native response_schema, so it is strict regardless of this flag.
return_usage: If True, return tuple (result, TokenUsage).
cached_prefix: Optional CachedContent resource name (from
``GeminiCacheManager.get_or_create``). When set, the
system_instruction is assumed to live in the cache; this call
skips resending it and the cached prefix is billed at the
cached-input rate instead of the standard input rate. The
response_schema is still sent per-request (it is not cacheable).
Pass ``None`` to use the
normal uncached path.
Returns:
If return_usage=False: Parsed response if response_format provided, else text.
@@ -191,9 +217,14 @@ class GeminiLLM(LLMInterface):
"""
start_time = time.time()
# Convert OpenAI-style messages to Gemini format
# Convert OpenAI-style messages to Gemini format. We ALWAYS build
# system_instruction (even when a cache is in use): the config builder
# below omits it from the request while the cache carries the prefix, but
# it must be available so the cached-call-failed safety net can re-send it
# inline. Whether it's actually sent is decided in _build_generation_config.
system_instruction = None
gemini_contents = []
using_cache = cached_prefix is not None
for msg in messages:
role = msg.get("role", "user")
@@ -209,7 +240,9 @@ class GeminiLLM(LLMInterface):
else:
gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)]))
# Add JSON schema instruction if response_format is provided
# Add the JSON schema as a textual hint in the system_instruction (matching
# the normal uncached path). Structured output is still enforced via
# response_schema regardless; this is just guidance text.
if response_format is not None and hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
@@ -218,32 +251,44 @@ class GeminiLLM(LLMInterface):
else:
system_instruction = schema_msg
# Build generation config
config_kwargs: dict[str, Any] = {}
if system_instruction:
config_kwargs["system_instruction"] = system_instruction
if response_format is not None:
config_kwargs["response_mime_type"] = "application/json"
config_kwargs["response_schema"] = response_format
if temperature is not None:
config_kwargs["temperature"] = temperature
# Gemini's equivalent of OpenAI-style max_completion_tokens is max_output_tokens.
# Without it the model can produce arbitrarily long responses, ignoring the
# caller's intended cap (e.g. mental_models max_tokens during refresh).
if max_completion_tokens is not None:
config_kwargs["max_output_tokens"] = max_completion_tokens
# Apply safety settings: context var (per-request bank override) takes precedence over instance default
effective_safety_settings = _safety_settings_ctx.get()
if effective_safety_settings is None:
effective_safety_settings = self._safety_settings
if effective_safety_settings is not None:
config_kwargs["safety_settings"] = [
genai_types.SafetySetting(category=s["category"], threshold=s["threshold"])
for s in effective_safety_settings
]
generation_config = genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None
# Build generation config. ``cached_content`` and ``system_instruction``
# are mutually exclusive (the cache IS the prefix; the SDK rejects
# re-sending it). ``response_schema``/``response_mime_type`` are
# request-level output constraints — NOT cacheable — so they're set on
# every structured call, including cached ones where they ride alongside
# ``cached_content``. Built as a closure so we can rebuild it WITHOUT the
# cache and retry inline if a stale/invalid CachedContent makes the call fail.
def _build_generation_config(use_cache: bool) -> "genai_types.GenerateContentConfig | None":
# Seed with user-configured extra params; explicit settings below win.
config_kwargs: dict[str, Any] = dict(self._extra_body)
if use_cache:
config_kwargs["cached_content"] = cached_prefix
elif system_instruction:
config_kwargs["system_instruction"] = system_instruction
if response_format is not None:
config_kwargs["response_mime_type"] = "application/json"
config_kwargs["response_schema"] = response_format
if temperature is not None:
config_kwargs["temperature"] = temperature
# Gemini's equivalent of OpenAI-style max_completion_tokens is max_output_tokens.
# Without it the model can produce arbitrarily long responses, ignoring the
# caller's intended cap (e.g. mental_models max_tokens during refresh).
if max_completion_tokens is not None:
config_kwargs["max_output_tokens"] = max_completion_tokens
if effective_safety_settings is not None:
config_kwargs["safety_settings"] = [
genai_types.SafetySetting(category=s["category"], threshold=s["threshold"])
for s in effective_safety_settings
]
return genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None
cache_active = using_cache
generation_config = _build_generation_config(cache_active)
last_exception = None
@@ -288,13 +333,24 @@ class GeminiLLM(LLMInterface):
else:
result = content
# Extract token usage
# Extract token usage. ``cached_content_token_count`` and
# ``thoughts_token_count`` are populated on the Gemini 2.5+
# family; treat missing fields as 0 so older models still
# record sensible metrics.
input_tokens = 0
output_tokens = 0
cached_input_tokens = 0
thoughts_tokens = 0
cached_tokens = 0
if hasattr(response, "usage_metadata") and response.usage_metadata:
usage = response.usage_metadata
input_tokens = usage.prompt_token_count or 0
output_tokens = usage.candidates_token_count or 0
cached_input_tokens = getattr(usage, "cached_content_token_count", 0) or 0
thoughts_tokens = getattr(usage, "thoughts_token_count", 0) or 0
# Tracing/TokenUsage consume ``cached_tokens``; metrics consume
# ``cached_input_tokens`` — same value, two downstream names.
cached_tokens = cached_input_tokens
# Record metrics
duration = time.time() - start_time
@@ -307,6 +363,8 @@ class GeminiLLM(LLMInterface):
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
cached_input_tokens=cached_input_tokens,
thoughts_tokens=thoughts_tokens,
)
# Record trace span
@@ -330,6 +388,7 @@ class GeminiLLM(LLMInterface):
duration=duration,
finish_reason=finish_reason,
error=None,
cached_tokens=cached_tokens,
)
# Log slow calls
@@ -345,6 +404,7 @@ class GeminiLLM(LLMInterface):
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=input_tokens + output_tokens,
cached_tokens=cached_tokens,
)
return result, token_usage
return result
@@ -366,6 +426,20 @@ class GeminiLLM(LLMInterface):
logger.error(f"Gemini auth error (HTTP {e.code}), not retrying: {str(e)}")
raise
# Cached-request safety net: a stale/invalid/expired CachedContent
# (or an incompatibility like cache + tool_config) surfaces as a 400.
# Retrying the same cached request can't recover, so on the first
# such failure drop the cache, invalidate it so later operations
# recreate it, and retry THIS call inline with the prefix inlined.
# Caching must never break a request.
if cache_active and e.code == 400:
logger.warning(f"Gemini cached call failed (400); retrying uncached. Reason: {str(e)}")
if self._cache_manager is not None and cached_prefix is not None:
self._cache_manager.invalidate(cached_prefix)
cache_active = False
generation_config = _build_generation_config(cache_active)
continue
# Retry on retryable errors (rate limits, server errors, client errors)
if e.code in (400, 429, 500, 502, 503, 504) or (e.code and e.code >= 500):
last_exception = e
@@ -399,6 +473,7 @@ class GeminiLLM(LLMInterface):
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
cached_prefix: str | None = None,
) -> LLMToolCallResult:
"""
Make a Gemini/VertexAI API call with tool/function calling support.
@@ -413,27 +488,39 @@ class GeminiLLM(LLMInterface):
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools (Gemini uses "auto" only).
cached_prefix: Optional CachedContent resource name (from
``GeminiCacheManager.get_or_create`` with ``tools=...``). When
set, the system_instruction and tool definitions are assumed
to live in the cache; this call will skip resending them and
the cached prefix is billed at the cached-input rate. The
``tools`` argument is still required (the caller may pass
an empty list when the cache holds them) so existing call
sites don't break.
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
start_time = time.time()
using_cache = cached_prefix is not None
# Convert tools to Gemini format
# Convert tools to Gemini format. When the cache is in use, the
# tool definitions are baked into the CachedContent at create time
# and the SDK rejects re-sending them alongside ``cached_content``.
gemini_tools = []
for tool in tools:
func = tool.get("function", {})
gemini_tools.append(
genai_types.Tool(
function_declarations=[
genai_types.FunctionDeclaration(
name=func.get("name", ""),
description=func.get("description", ""),
parameters=func.get("parameters"),
)
]
if not using_cache:
for tool in tools:
func = tool.get("function", {})
gemini_tools.append(
genai_types.Tool(
function_declarations=[
genai_types.FunctionDeclaration(
name=func.get("name", ""),
description=func.get("description", ""),
parameters=func.get("parameters"),
)
]
)
)
)
# Convert messages
system_instruction = None
@@ -446,6 +533,10 @@ class GeminiLLM(LLMInterface):
content = msg.get("content", "")
if role == "system":
# Always capture system_instruction. _build_tools_config omits it
# (and tools) from the request while the cache carries the prefix,
# but it must be available so the cached-call-failed safety net can
# re-send the prefix + tools inline.
system_instruction = (system_instruction + "\n\n" + content) if system_instruction else content
i += 1
elif role == "tool":
@@ -493,49 +584,63 @@ class GeminiLLM(LLMInterface):
gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)]))
i += 1
config_kwargs: dict[str, Any] = {"tools": gemini_tools}
if system_instruction:
config_kwargs["system_instruction"] = system_instruction
if temperature is not None:
config_kwargs["temperature"] = temperature
# See note in `call`: Gemini's max_output_tokens is the equivalent of
# OpenAI-style max_completion_tokens.
if max_completion_tokens is not None:
config_kwargs["max_output_tokens"] = max_completion_tokens
# Map OpenAI-style tool_choice to Gemini FunctionCallingConfig
if tool_choice == "required":
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(
mode="ANY",
)
)
elif isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
fn_name = tool_choice.get("function", {}).get("name")
if fn_name:
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(
mode="ANY",
allowed_function_names=[fn_name],
)
)
elif tool_choice == "none":
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(mode="NONE")
)
# "auto" is the default (no tool_config needed)
# Apply safety settings: context var (per-request bank override) takes precedence over instance default
effective_safety_settings = _safety_settings_ctx.get()
if effective_safety_settings is None:
effective_safety_settings = self._safety_settings
if effective_safety_settings is not None:
config_kwargs["safety_settings"] = [
genai_types.SafetySetting(category=s["category"], threshold=s["threshold"])
for s in effective_safety_settings
]
config = genai_types.GenerateContentConfig(**config_kwargs)
# When using a cached prefix, the SDK rejects re-sending system_instruction
# or tools alongside ``cached_content`` — the cache IS the prefix.
# tool_config (mode / allowed_function_names) is a per-request decision and
# stays out of the cache. Built as a closure so we can rebuild it WITHOUT
# the cache and retry inline if a stale/invalid cache makes the call fail.
def _build_tools_config(use_cache: bool) -> "genai_types.GenerateContentConfig":
# Seed with user-configured extra params; explicit settings below win.
config_kwargs: dict[str, Any] = dict(self._extra_body)
if use_cache:
config_kwargs["cached_content"] = cached_prefix
else:
config_kwargs["tools"] = gemini_tools
if system_instruction:
config_kwargs["system_instruction"] = system_instruction
if temperature is not None:
config_kwargs["temperature"] = temperature
# See note in `call`: Gemini's max_output_tokens is the equivalent of
# OpenAI-style max_completion_tokens.
if max_completion_tokens is not None:
config_kwargs["max_output_tokens"] = max_completion_tokens
# Map OpenAI-style tool_choice to Gemini FunctionCallingConfig
if tool_choice == "required":
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(
mode="ANY",
)
)
elif isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
fn_name = tool_choice.get("function", {}).get("name")
if fn_name:
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(
mode="ANY",
allowed_function_names=[fn_name],
)
)
elif tool_choice == "none":
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(mode="NONE")
)
# "auto" is the default (no tool_config needed)
if effective_safety_settings is not None:
config_kwargs["safety_settings"] = [
genai_types.SafetySetting(category=s["category"], threshold=s["threshold"])
for s in effective_safety_settings
]
return genai_types.GenerateContentConfig(**config_kwargs)
cache_active = using_cache
config = _build_tools_config(cache_active)
last_exception = None
for attempt in range(max_retries + 1):
@@ -578,12 +683,18 @@ class GeminiLLM(LLMInterface):
finish_reason = "tool_calls" if tool_calls else "stop"
# Extract token usage
# Extract token usage. ``cached_content_token_count`` and
# ``thoughts_token_count`` are populated on the Gemini 2.5+
# family; absent fields are treated as 0.
input_tokens = 0
output_tokens = 0
cached_input_tokens = 0
thoughts_tokens = 0
if response.usage_metadata:
input_tokens = response.usage_metadata.prompt_token_count or 0
output_tokens = response.usage_metadata.candidates_token_count or 0
cached_input_tokens = getattr(response.usage_metadata, "cached_content_token_count", 0) or 0
thoughts_tokens = getattr(response.usage_metadata, "thoughts_token_count", 0) or 0
# Record metrics
duration = time.time() - start_time
@@ -596,6 +707,8 @@ class GeminiLLM(LLMInterface):
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
cached_input_tokens=cached_input_tokens,
thoughts_tokens=thoughts_tokens,
)
# Record OpenTelemetry span
@@ -620,6 +733,7 @@ class GeminiLLM(LLMInterface):
finish_reason=finish_reason,
error=None,
tool_calls=tool_calls_dict,
cached_tokens=cached_input_tokens,
)
return LLMToolCallResult(
@@ -636,6 +750,18 @@ class GeminiLLM(LLMInterface):
logger.error(f"Gemini auth error (HTTP {e.code}), not retrying: {str(e)}")
raise
# Cached-request safety net (see ``call``): a stale/invalid cache or
# a cache+tool_config conflict surfaces as a 400. Drop the cache,
# invalidate it for later operations, and retry THIS call inline
# with the prefix + tools re-sent. Caching must never break a call.
if cache_active and e.code == 400:
logger.warning(f"Gemini cached tool call failed (400); retrying uncached. Reason: {str(e)}")
if self._cache_manager is not None and cached_prefix is not None:
self._cache_manager.invalidate(cached_prefix)
cache_active = False
config = _build_tools_config(cache_active)
continue
# Retry on retryable errors
last_exception = e
if attempt < max_retries:
@@ -652,6 +778,54 @@ class GeminiLLM(LLMInterface):
raise last_exception
raise RuntimeError("Gemini tool call failed")
def supports_prompt_caching(self) -> bool:
"""True when explicit Gemini context caching is enabled for this instance.
Reflects the opt-in flag so callers skip the cache lookup entirely when
it's off; ``get_or_create_cached_prefix`` also returns None in that case.
"""
return self._prompt_cache_enabled
async def get_or_create_cached_prefix(
self,
*,
system_instruction: str,
response_schema: Any | None = None,
tools: list[dict[str, Any]] | None = None,
) -> str | None:
"""Return a CachedContent resource name for the given prefix, or
``None`` if context caching is disabled, the provider doesn't
support it, or Gemini rejects the create (prefix too small, etc.).
``tools`` is the OpenAI-style tools list; pass it when caching a
prefix that will be used by ``call_with_tools()``. The fingerprint
includes the tool definitions so a loop that swaps a tool gets a
fresh cache automatically.
Callers pass the returned name to ``call(cached_prefix=...)``
or ``call_with_tools(cached_prefix=...)`` and treat ``None``
as "cache unavailable — use the normal path". That fallback is
essential: the system must continue to work if caching is disabled,
if Gemini's caching API has an outage, or if the prefix is below
the model's minimum cacheable size.
"""
if not self._prompt_cache_enabled:
return None
if self._client is None:
return None
if self._cache_manager is None:
# Lazy import so the cache module is only loaded when caching
# is actually used.
from hindsight_api.engine.providers.gemini_cache import GeminiCacheManager
self._cache_manager = GeminiCacheManager(self._client)
return await self._cache_manager.get_or_create(
model=self.model,
system_instruction=system_instruction,
response_schema=response_schema,
tools=tools,
)
async def cleanup(self) -> None:
"""Clean up resources (close connections, etc.)."""
# Gemini client doesn't require explicit cleanup
@@ -48,11 +48,18 @@ class LiteLLMLLM(LLMInterface):
model: str,
reasoning_effort: str = "low",
timeout: float = 300.0,
extra_body: dict[str, Any] | None = None,
**kwargs: Any,
):
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
self.timeout = timeout
self._litellm: Any = None
# User-configured extra params merged as top-level kwargs into every
# completion call so LiteLLM normalizes them per-provider (e.g. maps
# temperature/top_p/max_tokens across OpenAI, Anthropic, Bedrock, …) and
# drops any the target model rejects (litellm.drop_params=True below).
# Sourced from llm_extra_body (env: HINDSIGHT_API_LLM_EXTRA_BODY).
self._extra_body: dict[str, Any] = extra_body or {}
try:
import litellm
@@ -107,6 +114,11 @@ class LiteLLMLLM(LLMInterface):
if temperature is not None:
kwargs["temperature"] = temperature
# User-configured extras fill in only where the caller didn't set a value,
# so explicit per-call params (model, messages, temperature, …) always win.
for key, value in self._extra_body.items():
kwargs.setdefault(key, value)
return kwargs
# ── per-model output-tokens cap (shared with Router subclass) ────────────
@@ -162,6 +162,12 @@ class MockLLM(LLMInterface):
# Consolidation: produce a single observation from the input facts
# so the full pipeline (retain → consolidation → observation → recall) works.
result = self._build_mock_consolidation(messages, response_format)
elif scope == "consolidation_dedup" and response_format is not None:
# Observation dedup adjudication. Default to "keep" so mock-LLM consolidation never
# spuriously merges observations — this preserves the pre-dedup behaviour that
# deterministic consolidation tests assert (the generic branch below can't construct
# the model because its "action" field is required and has no default).
result = response_format(action="keep", reason="mock")
elif scope == "memory_think":
# Reflect: return a plausible text answer
result = "Based on the available information, the answer is related to the context provided."
@@ -7,7 +7,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.7 models with 1M context window
- MiniMax: MiniMax-M3 / MiniMax-M2.7 models with 1M context window
- DeepSeek: deepseek-v4-flash / deepseek-v4-pro / deepseek-chat / deepseek-reasoner via api.deepseek.com
- Opencode Go: deepseek-v4-flash via https://opencode.ai/zen/go/v1
@@ -44,6 +44,16 @@ logger = logging.getLogger(__name__)
DEFAULT_LLM_SEED = 4242
JSON_MODE_USER_HINT = "Return valid json only."
# Self-hosted OpenAI-compatible servers that advertise tool_choice="required"
# but silently ignore it: instead of forcing a tool call they return
# finish_reason "stop"/"tool_calls" with an EMPTY tool_calls array and no error.
# Reflect's agent loop then sees no tool call, runs synthesis with no retrieval,
# and answers "I don't have information" even when the bank holds the answer.
# See issues #1563 (LM Studio), #1179 (LM Studio + Qwen), #1877 (vLLM with
# --enable-auto-tool-choice). llama-server (the "llamacpp" provider) honors
# "required" correctly and is intentionally excluded (#1179).
_TOOL_CHOICE_REQUIRED_UNSUPPORTED_PROVIDERS = frozenset({"lmstudio", "ollama"})
class ProviderResponseError(RuntimeError):
"""Raised when a provider returns a success response without usable content."""
@@ -232,7 +242,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.7 models via OpenAI-compatible API (https://api.minimax.io/v1)
- MiniMax: MiniMax-M3 / MiniMax-M2.7 models via OpenAI-compatible API (https://api.minimax.io/v1)
- DeepSeek: deepseek-v4-flash / deepseek-v4-pro / deepseek-chat / deepseek-reasoner via https://api.deepseek.com
- opencode-go: deepseek-v4-flash via https://opencode.ai/zen/go/v1
"""
@@ -360,6 +370,21 @@ class OpenAICompatibleLLM(LLMInterface):
f"base_url={self.base_url or 'default'}"
)
def _drops_tool_choice_required(self) -> bool:
"""Whether this endpoint silently ignores ``tool_choice="required"``.
True for self-hosted OpenAI-compatible servers known to return an empty
tool_calls array for "required" instead of forcing a call (#1563/#1179/
#1877). Covers LM Studio / Ollama directly, plus any server reached via
the generic "openai" provider with a custom ``base_url`` (e.g. a local
vLLM endpoint). The real OpenAI API (no base_url override) honors
"required", and cloud providers keep their own default base_urls, so both
are left untouched.
"""
if self.provider in _TOOL_CHOICE_REQUIRED_UNSUPPORTED_PROVIDERS:
return True
return self.provider == "openai" and bool(self.base_url)
async def verify_connection(self) -> None:
"""
Verify that the provider is configured correctly by making a simple test call.
@@ -460,7 +485,9 @@ class OpenAICompatibleLLM(LLMInterface):
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
skip_validation: Return raw JSON without Pydantic validation.
strict_schema: Use strict JSON schema enforcement (OpenAI only).
strict_schema: Use strict json_schema (grammar-enforced) response_format instead of
the soft json_object path. Supported by OpenAI and schema-capable self-hosted
backends (llama.cpp, vLLM). Server-wide via HINDSIGHT_API_LLM_STRICT_SCHEMA.
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
Returns:
@@ -650,6 +677,9 @@ class OpenAICompatibleLLM(LLMInterface):
input_tokens = usage.prompt_tokens or 0 if usage else 0
output_tokens = usage.completion_tokens or 0 if usage else 0
total_tokens = usage.total_tokens or 0 if usage else 0
cached_tokens = 0
if usage and getattr(usage, "prompt_tokens_details", None):
cached_tokens = getattr(usage.prompt_tokens_details, "cached_tokens", 0) or 0
# Record LLM metrics
metrics = get_metrics_collector()
@@ -679,14 +709,12 @@ class OpenAICompatibleLLM(LLMInterface):
duration=duration,
finish_reason=finish_reason,
error=None,
cached_tokens=cached_tokens,
)
# Log slow calls
if duration > 10.0 and usage:
ratio = max(1, output_tokens) / max(1, input_tokens)
cached_tokens = 0
if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details:
cached_tokens = getattr(usage.prompt_tokens_details, "cached_tokens", 0) or 0
cache_info = f", cached_tokens={cached_tokens}" if cached_tokens > 0 else ""
logger.info(
f"slow llm call: scope={scope}, model={self.provider}/{self.model}, "
@@ -699,6 +727,7 @@ class OpenAICompatibleLLM(LLMInterface):
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
cached_tokens=cached_tokens,
)
return result, token_usage
return result
@@ -867,6 +896,16 @@ class OpenAICompatibleLLM(LLMInterface):
if request_tool_choice == "auto":
request_tool_choice = None
# vLLM (--enable-auto-tool-choice), LM Studio, Ollama and similar
# self-hosted servers silently drop tool_choice="required", returning an
# empty tool_calls array instead of forcing a call (#1563/#1179/#1877).
# Downgrade to auto (None) so the model still gets to call a tool. Named
# tool_choice dicts were already normalized to "required" + a single
# filtered tool above, so the call stays practically forced even under
# auto. The real OpenAI API honors "required" and is left untouched.
if request_tool_choice == "required" and self._drops_tool_choice_required():
request_tool_choice = None
# DeepSeek tool-call replies can carry provider-specific reasoning_content.
# The normalized tool result does not retain it, but replaying assistant
# tool_calls without the field can trigger a 400. DeepSeek accepts an
@@ -302,6 +302,24 @@ def _is_context_overflow_error(exc: Exception) -> bool:
)
def _all_mental_models_are_usable_and_fresh(tool_output: dict[str, Any]) -> bool:
"""Return whether every retrieved mental model is explicitly fresh and has answerable content.
Used to decide — without an extra LLM call — whether a forced
``search_mental_models`` result is trustworthy enough to hand control back
to the agent. A model is usable only when it is explicitly ``is_stale ==
False`` (an unknown/missing staleness flag is treated as unsafe) and has
non-empty content.
"""
models = tool_output.get("mental_models") or []
for model in models:
if model.get("is_stale") is not False:
return False
if not str(model.get("content") or "").strip():
return False
return True
async def run_reflect_agent(
llm_config: "LLMProvider",
bank_id: str,
@@ -382,6 +400,28 @@ async def run_reflect_agent(
{"role": "user", "content": query},
]
# Opt into context caching for the agentic tool loop. The system
# prompt and tool definitions are stable for the duration of this
# reflect call (and across reflects against the same bank), so
# caching them once and reusing across every iteration of the loop
# collapses the dominant input cost — the prefix repeated on every
# turn. ``get_or_create_cached_prefix`` returns None when caching is
# disabled, unsupported, or the prefix is too small; the
# ``call_with_tools`` invocation below transparently falls back to
# the uncached path in that case.
cached_prefix_name: str | None = None
provider_impl = getattr(llm_config, "_provider_impl", None)
if provider_impl is not None and provider_impl.supports_prompt_caching():
try:
cached_prefix_name = await provider_impl.get_or_create_cached_prefix(
system_instruction=system_prompt,
tools=tools,
)
except Exception:
# Caching is a soft optimisation; never let a cache-side
# error block a reflect.
cached_prefix_name = None
# Tracking
total_tools_called = 0
tool_trace: list[ToolCall] = []
@@ -442,6 +482,11 @@ async def run_reflect_agent(
)
consecutive_errors = 0
# When a forced ``search_mental_models`` returns fresh, usable models on a
# low/mid-budget call, we stop forcing the lower retrieval layers from this
# iteration onward and let the agent answer (or retrieve deeper itself)
# under ``auto`` tool choice. None means the full forced path still applies.
stop_forcing_from_iteration: int | None = None
for iteration in range(max_iterations):
is_last = iteration == max_iterations - 1
@@ -570,18 +615,31 @@ async def run_reflect_agent(
if include_recall:
forced_sequence.append("recall")
if iteration < len(forced_sequence):
iter_tool_choice: str | dict = {"type": "function", "function": {"name": forced_sequence[iteration]}}
if stop_forcing_from_iteration is not None and iteration >= stop_forcing_from_iteration:
# A fresh mental model already short-circuited the forced path.
iter_tool_choice: str | dict = "auto"
elif iteration < len(forced_sequence):
iter_tool_choice = {"type": "function", "function": {"name": forced_sequence[iteration]}}
else:
iter_tool_choice = "auto"
try:
result = await llm_config.call_with_tools(
ct_kwargs: dict[str, Any] = dict(
messages=messages,
tools=tools,
scope="reflect_tool_call",
tool_choice=iter_tool_choice,
)
# Gemini rejects ``cached_content`` alongside a per-request
# ``tool_config`` (forced tool choice): "CachedContent can not be used
# with GenerateContent request setting system_instruction, tools or
# tool_config." The forced-sequence iterations set tool_config, so only
# the ``auto`` iterations can reference the cache; forced iterations send
# the prefix inline. The cache (tools + system prompt) is identical
# either way, so this just limits *which* iterations are billed cached.
if cached_prefix_name is not None and iter_tool_choice == "auto":
ct_kwargs["cached_prefix"] = cached_prefix_name
result = await llm_config.call_with_tools(**ct_kwargs)
llm_duration = int((time.time() - llm_start) * 1000)
consecutive_errors = 0
total_input_tokens += result.input_tokens
@@ -924,6 +982,25 @@ async def run_reflect_agent(
for mm in output["mental_models"]:
if "id" in mm:
available_mental_model_ids.add(mm["id"])
# Deterministic short-circuit (no extra LLM call): on a
# low/mid-budget call, if every retrieved mental model is
# fresh and has usable content, stop forcing the lower
# retrieval layers. The next iteration runs under ``auto``
# tool choice, so the agent can answer directly when the
# mental model suffices, or — having just read it — issue a
# targeted ``search_observations``/``recall`` itself. Stale,
# empty, or missing mental models keep the full forced path.
if (
stop_forcing_from_iteration is None
and (budget or "low").lower() != "high"
and output.get("mental_models")
and _all_mental_models_are_usable_and_fresh(output)
):
stop_forcing_from_iteration = iteration + 1
logger.info(
f"[REFLECT {reflect_id}] Fresh mental models sufficient on iteration {iteration + 1}; "
"releasing forced lower-level retrieval to auto."
)
if (
normalized_tool_name == "search_observations"
@@ -93,6 +93,7 @@ class TokenUsage(BaseModel):
input_tokens: int = Field(default=0, description="Number of input/prompt tokens consumed")
output_tokens: int = Field(default=0, description="Number of output/completion tokens generated")
total_tokens: int = Field(default=0, description="Total tokens (input + output)")
cached_tokens: int = Field(default=0, description="Cached/cache-read prompt tokens, when reported by the provider")
def __add__(self, other: "TokenUsage") -> "TokenUsage":
"""Allow aggregating token usage from multiple calls."""
@@ -100,6 +101,7 @@ class TokenUsage(BaseModel):
input_tokens=self.input_tokens + other.input_tokens,
output_tokens=self.output_tokens + other.output_tokens,
total_tokens=self.total_tokens + other.total_tokens,
cached_tokens=self.cached_tokens + other.cached_tokens,
)
@@ -6,6 +6,7 @@ import json
import logging
import re
import uuid
from dataclasses import dataclass
from typing import TypedDict
from pydantic import BaseModel, Field
@@ -105,6 +106,18 @@ class BankProfile(TypedDict):
mission: str
@dataclass
class BankProfileResult:
"""Result of a get-or-create bank lookup.
``created`` is True when the bank row was freshly inserted on this call,
which callers use to drive the one-time HINDSIGHT_API_DEFAULT_BANK_TEMPLATE hook.
"""
profile: BankProfile
created: bool
class MissionMergeResponse(BaseModel):
"""LLM response for mission merge."""
@@ -123,8 +136,8 @@ 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
result = await get_or_create_bank_profile(pool, bank_id)
return result.profile
async def get_bank_profile_if_exists(pool, bank_id: str) -> BankProfile | None:
@@ -162,70 +175,89 @@ async def get_bank_profile_if_exists(pool, bank_id: str) -> BankProfile | None:
)
async def get_or_create_bank_profile(pool, bank_id: str) -> tuple[BankProfile, bool]:
async def get_or_create_bank_profile(pool, bank_id: str) -> BankProfileResult:
"""
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.
Same as get_bank_profile, but also reports whether the bank was freshly
created on this call (``BankProfileResult.created``). 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.
Acquires its own connection. When the caller already holds a connection and
wants the bank row to share its transaction (so the lazy bank-create commits
or rolls back atomically with the caller's write), use
``get_or_create_bank_profile_on_conn`` instead.
"""
async with acquire_with_retry(pool) as conn:
# Try to get existing bank
row = await conn.fetchrow(
f"""
SELECT name, disposition, mission
FROM {fq_table("banks")} WHERE bank_id = $1
""",
bank_id,
return await get_or_create_bank_profile_on_conn(conn, bank_id, ops=pool.ops)
async def get_or_create_bank_profile_on_conn(conn, bank_id: str, *, ops) -> BankProfileResult:
"""
Connection-bound variant of ``get_or_create_bank_profile``.
Runs the SELECT, the ``INSERT ... ON CONFLICT DO NOTHING`` and the per-bank
vector index creation on the caller-supplied ``conn``. When ``conn`` is
inside an open transaction, the lazy bank-create therefore commits (or rolls
back) atomically with whatever bank-scoped write the caller performs on the
same connection closing the window where a freshly-created bank could
outlive a write that ultimately failed.
``ops`` is the backend's dialect ops object (``backend.ops``), needed for
per-bank vector index DDL.
"""
# Try to get existing bank
row = await conn.fetchrow(
f"""
SELECT name, disposition, mission
FROM {fq_table("banks")} WHERE bank_id = $1
""",
bank_id,
)
if row:
# asyncpg returns JSONB as a string, so parse it
disposition_data = row["disposition"]
if isinstance(disposition_data, str):
disposition_data = json.loads(disposition_data)
return BankProfileResult(
profile=BankProfile(
name=row["name"],
disposition=DispositionTraits(**disposition_data),
mission=row["mission"] or "",
),
created=False,
)
if row:
# asyncpg returns JSONB as a string, so parse it
disposition_data = row["disposition"]
if isinstance(disposition_data, str):
disposition_data = json.loads(disposition_data)
# Bank doesn't exist, create with defaults.
# Generate internal_id here so we control the value and can use it
# immediately for vector index creation without a RETURNING round-trip.
internal_id = uuid.uuid4()
inserted = await conn.fetchval(
f"""
INSERT INTO {fq_table("banks")} (bank_id, name, disposition, mission, internal_id)
VALUES ($1, $2, $3::jsonb, $4, $5)
ON CONFLICT (bank_id) DO NOTHING
RETURNING bank_id
""",
bank_id,
bank_id, # Default name is the bank_id
json.dumps(DEFAULT_DISPOSITION),
"",
internal_id,
)
return (
BankProfile(
name=row["name"],
disposition=DispositionTraits(**disposition_data),
mission=row["mission"] or "",
),
False,
)
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), ops=ops)
# Bank doesn't exist, create with defaults.
# Generate internal_id here so we control the value and can use it
# immediately for vector index creation without a RETURNING round-trip.
internal_id = uuid.uuid4()
inserted = await conn.fetchval(
f"""
INSERT INTO {fq_table("banks")} (bank_id, name, disposition, mission, internal_id)
VALUES ($1, $2, $3::jsonb, $4, $5)
ON CONFLICT (bank_id) DO NOTHING
RETURNING bank_id
""",
bank_id,
bank_id, # Default name is the bank_id
json.dumps(DEFAULT_DISPOSITION),
"",
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), ops=pool.ops)
return (
BankProfile(name=bank_id, disposition=DispositionTraits(**DEFAULT_DISPOSITION), mission=""),
created,
)
return BankProfileResult(
profile=BankProfile(name=bank_id, disposition=DispositionTraits(**DEFAULT_DISPOSITION), mission=""),
created=created,
)
async def update_bank_disposition(pool, bank_id: str, disposition: dict[str, int]) -> None:
@@ -15,11 +15,23 @@ class EmbeddingsBackend(Protocol):
"""Minimal duck-typed surface used by retain/recall — the concrete `Embeddings`
ABC supplies default implementations that delegate to `encode()`."""
@property
def dimension(self) -> int: ...
def encode_query(self, texts: list[str]) -> list[list[float]]: ...
def encode_documents(self, texts: list[str]) -> list[list[float]]: ...
def _validate_embedding_vector(vector: list[float], *, index: int, expected_dimension: int) -> list[float]:
actual_dimension = len(vector)
if actual_dimension == 0:
raise RuntimeError(f"embedding {index} has dimension 0; expected {expected_dimension}")
if actual_dimension != expected_dimension:
raise RuntimeError(f"embedding {index} has dimension {actual_dimension}; expected {expected_dimension}")
return vector
def generate_embedding(
embeddings_backend: EmbeddingsBackend, text: str, input_type: EmbeddingInputType = "document"
) -> list[float]:
@@ -36,10 +48,19 @@ def generate_embedding(
"""
try:
embeddings = _encode_with_input_type(embeddings_backend, [text], input_type)
return embeddings[0]
except Exception as e:
raise Exception(f"Failed to generate embedding: {str(e)}")
if len(embeddings) != 1:
raise RuntimeError(
f"Embeddings backend returned {len(embeddings)} vectors for 1 input text; expected exact 1:1 alignment"
)
return _validate_embedding_vector(
embeddings[0],
index=0,
expected_dimension=embeddings_backend.dimension,
)
def _encode_with_input_type(
embeddings_backend: EmbeddingsBackend, texts: list[str], input_type: EmbeddingInputType
@@ -81,4 +102,7 @@ async def generate_embeddings_batch(
"expected exact 1:1 alignment"
)
return embeddings
return [
_validate_embedding_vector(embedding, index=index, expected_dimension=embeddings_backend.dimension)
for index, embedding in enumerate(embeddings)
]
@@ -10,12 +10,13 @@ import json
import logging
import re
from datetime import datetime, timedelta
from typing import Literal, cast
from typing import Any, Literal, cast
from pydantic import BaseModel, ConfigDict, Field, create_model, field_validator
from ...config import get_config
from ..llm_wrapper import LLMConfig, OutputTooLongError, sanitize_llm_output
from ..operation_metadata import RetainExtractionErrors
from ..response_models import TokenUsage
from .entity_labels import (
EntityLabelsConfig,
@@ -510,11 +511,11 @@ LANGUAGE: MANDATORY — Detect the language of the input text and produce ALL ou
FACT FORMAT - BE CONCISE
1. **what**: Core fact - concise but complete (1-2 sentences max)
2. **when**: Temporal info if mentioned. "N/A" if none. Use day name when known.
3. **where**: Location if relevant. "N/A" if none.
4. **who**: People involved with relationships. "N/A" if just general info.
5. **why**: Context/significance ONLY if important. "N/A" if obvious.
1. "what": Core fact - concise but complete (1-2 sentences max)
2. "when": Temporal info if mentioned. "N/A" if none. Use day name when known.
3. "where": Location if relevant. "N/A" if none.
4. "who": People involved with relationships. "N/A" if just general info.
5. "why": Context/significance ONLY if important. "N/A" if obvious.
CONCISENESS: Capture the essence, not every word. One good sentence beats three mediocre ones.
@@ -887,20 +888,15 @@ def _build_extraction_prompt_and_schema(config) -> tuple[str, type]:
extraction_mode = config.retain_extraction_mode
extract_causal_links = config.retain_extract_causal_links
# Build retain_mission section if set - injected before the mode-specific guidelines
# Escape braces so user-supplied text survives str.format() on the prompt template.
# The per-bank retain mission is NOT baked into this system prompt: it would
# make the prompt bank-specific and force a separate Gemini context cache per
# mission (one per bank). Instead the prompt is bank-agnostic so a single
# CachedContent serves every bank, and the mission rides in the per-request
# user message via _retain_mission_preamble(). The {retain_mission_section}
# placeholder is kept (templates still reference it) but always empty here.
from hindsight_api.engine.prompt_utils import escape_for_prompt
retain_mission = getattr(config, "retain_mission", None)
if retain_mission:
retain_mission_section = (
f"══════════════════════════════════════════════════════════════════════════\n"
f"FOCUS — What to retain for this bank\n"
f"══════════════════════════════════════════════════════════════════════════\n\n"
f"{escape_for_prompt(retain_mission)}\n\n"
)
else:
retain_mission_section = ""
retain_mission_section = ""
# Select base prompt based on extraction mode
if extraction_mode == "custom":
@@ -997,6 +993,26 @@ def _build_extraction_prompt_and_schema(config) -> tuple[str, type]:
return prompt, response_schema
def _retain_mission_preamble(config) -> str:
"""The bank's retain mission, formatted for the per-request user message.
Kept OUT of the cached system prompt (which must stay bank-agnostic so one
CachedContent serves every bank otherwise each distinct mission spawns its
own cache) and prepended to the user message instead. Returns "" when unset.
No brace-escaping needed: unlike the system template, the user message is
used verbatim, not passed through str.format().
"""
retain_mission = getattr(config, "retain_mission", None)
if not retain_mission:
return ""
return (
"══════════════════════════════════════════════════════════════════════════\n"
"FOCUS — What to retain for this bank (takes priority over the general guidelines)\n"
"══════════════════════════════════════════════════════════════════════════\n\n"
f"{retain_mission}\n\n"
)
def _build_user_message(
chunk: str,
chunk_index: int,
@@ -1005,8 +1021,14 @@ def _build_user_message(
context: str,
metadata: dict[str, str] | None = None,
agent_name: str | None = None,
mission_preamble: str = "",
) -> str:
"""Build user message for fact extraction."""
"""Build user message for fact extraction.
``mission_preamble`` (the bank's retain mission, possibly empty) is prepended
so the bank-specific focus lives in the variable user turn rather than the
cached, bank-agnostic system prompt.
"""
from .orchestrator import parse_datetime_flexible
sanitized_chunk = _sanitize_text(chunk)
@@ -1025,9 +1047,21 @@ def _build_user_message(
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")'
narrator_section = (
f"\nNarrator: {agent_name} (the AI agent whose memory this is). By default, "
f'first-person statements like "I did X" are {agent_name}\'s own actions → classify as '
f'"assistant".'
)
# Only defer to the Context when one was actually provided — otherwise this
# clause points at a "Context: none" line and just adds noise.
if context:
narrator_section += (
" BUT the Context above takes precedence: if it identifies a different "
"first-person speaker (e.g. a user or customer in a transcript), attribute those "
'statements to that speaker and classify them as "world", not "assistant".'
)
return f"""Extract facts from the following text chunk.
return f"""{mission_preamble}Extract facts from the following text chunk.
Chunk: {chunk_index + 1}/{total_chunks}
Event Date: {event_date_str}
@@ -1053,12 +1087,15 @@ def _build_request_body(llm_config, config, prompt: str, user_message: str, resp
if llm_config.provider == "openai" and llm_config._provider_impl.openai_service_tier:
request_body["service_tier"] = llm_config._provider_impl.openai_service_tier
# Add response_format (JSON schema)
# Add response_format (JSON schema). The batch path builds the request body
# directly instead of going through LLMProvider.call(), so honour
# HINDSIGHT_API_LLM_STRICT_SCHEMA here too: strict=True grammar-enforces the
# output on capable backends rather than relying on the model to emit clean JSON.
if hasattr(response_schema, "model_json_schema"):
schema = response_schema.model_json_schema()
request_body["response_format"] = {
"type": "json_schema",
"json_schema": {"name": "facts", "schema": schema},
"json_schema": {"name": "facts", "schema": schema, "strict": config.llm_strict_schema},
}
return request_body
@@ -1094,8 +1131,38 @@ async def _extract_facts_from_chunk(
extraction_mode = config.retain_extraction_mode
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, agent_name)
# Build user message — the bank mission rides here (not in the cached prefix).
user_message = _build_user_message(
chunk,
chunk_index,
total_chunks,
event_date,
context,
metadata,
agent_name,
mission_preamble=_retain_mission_preamble(config),
)
# Opt into context caching when the provider supports it. The prompt and
# response_schema are bank-agnostic (the mission lives in the user message),
# so one cached prefix serves every bank; reusing it across many small-payload
# retain calls dramatically lowers per-call input
# cost. ``get_or_create_cached_prefix`` returns None when caching is
# disabled, unsupported, or the prefix is too small; the LLM call
# transparently falls back to the uncached path in that case.
cached_prefix_name: str | None = None
provider_impl = getattr(llm_config, "_provider_impl", None)
if provider_impl is not None and provider_impl.supports_prompt_caching():
try:
cached_prefix_name = await provider_impl.get_or_create_cached_prefix(
system_instruction=prompt,
response_schema=response_schema,
)
except Exception:
# Caching is a soft optimisation — never let a cache-side
# error block a retain operation.
logger.exception("Cache prefix lookup failed; falling back to uncached call")
cached_prefix_name = None
# Retry logic for JSON validation errors
# Use retain-specific overrides if set, otherwise fall back to global LLM config
@@ -1116,7 +1183,7 @@ async def _extract_facts_from_chunk(
config.retain_llm_max_backoff if config.retain_llm_max_backoff is not None else config.llm_max_backoff
)
extraction_response_json, call_usage = await llm_config.call(
call_kwargs: dict[str, Any] = dict(
messages=[{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
response_format=response_schema,
scope="retain_extract_facts",
@@ -1128,6 +1195,10 @@ async def _extract_facts_from_chunk(
skip_validation=True, # Get raw JSON, we'll validate leniently
return_usage=True,
)
if cached_prefix_name is not None:
call_kwargs["cached_prefix"] = cached_prefix_name
extraction_response_json, call_usage = await llm_config.call(**call_kwargs)
usage = usage + call_usage # Aggregate usage across retries
# Lenient parsing of facts from raw JSON
@@ -1640,6 +1711,39 @@ logger = logging.getLogger(__name__)
SECONDS_PER_FACT = 0.01
async def _write_batch_extraction_errors(
pool: Any,
operation_id: str | None,
schema: str | None,
errors: RetainExtractionErrors,
) -> None:
"""Persist non-fatal Batch API extraction errors into operation result_metadata."""
if not pool or not operation_id or errors.count == 0:
return
from ..db_utils import acquire_with_retry
from ..task_backend import fq_table
# `errors` is the complete set for this extraction run, so overwrite the
# extraction_errors_* keys rather than folding in what's already stored. On
# batch crash recovery the resumed batch reprocesses every result and
# recomputes `errors` from scratch; reading + merging the prior run's
# counters here would double-count them. The SQL `||` merge still preserves
# unrelated keys (e.g. batch_id) already on result_metadata.
table = fq_table("async_operations", schema)
async with acquire_with_retry(pool) as conn:
await conn.execute(
f"""
UPDATE {table}
SET result_metadata = COALESCE(result_metadata, '{{}}'::jsonb) || $2::jsonb,
updated_at = now()
WHERE operation_id = $1
""",
operation_id,
json.dumps(errors.to_dict()),
)
async def extract_facts_from_contents_batch_api(
contents: list[RetainContent],
llm_config,
@@ -1731,6 +1835,7 @@ async def extract_facts_from_contents_batch_api(
item.context,
item.metadata or None,
agent_name,
mission_preamble=_retain_mission_preamble(config),
)
# Build request body using helper function
@@ -1816,6 +1921,7 @@ async def extract_facts_from_contents_batch_api(
all_facts_from_llm = []
chunks_metadata = []
total_usage = TokenUsage()
extraction_errors = RetainExtractionErrors()
for chunk_idx, (chunk_content, content_index, chunk_index_in_content, event_date, context) in enumerate(
all_chunks_info
@@ -1824,7 +1930,9 @@ async def extract_facts_from_contents_batch_api(
result = results_by_id.get(custom_id)
if not result:
logger.warning(f"Missing result for {custom_id}, skipping")
message = f"{custom_id}: missing batch result"
logger.warning(message)
extraction_errors.add(message)
chunks_metadata.append(
ChunkMetadata(
chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx
@@ -1834,7 +1942,9 @@ async def extract_facts_from_contents_batch_api(
# Check for errors
if result.get("error"):
logger.error(f"Error in {custom_id}: {result['error']}")
message = f"{custom_id}: {result['error']}"
logger.error(f"Error in {message}")
extraction_errors.add(message)
chunks_metadata.append(
ChunkMetadata(
chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx
@@ -1847,7 +1957,9 @@ async def extract_facts_from_contents_batch_api(
choices = response_body.get("choices", [])
if not choices:
logger.warning(f"No choices in response for {custom_id}")
message = f"{custom_id}: no choices in response"
logger.warning(message)
extraction_errors.add(message)
chunks_metadata.append(
ChunkMetadata(
chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx
@@ -1862,7 +1974,9 @@ async def extract_facts_from_contents_batch_api(
try:
extraction_response_json = json.loads(content_str)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse JSON for {custom_id}: {e}")
message = f"{custom_id}: failed to parse JSON: {e}"
logger.error(message)
extraction_errors.add(message)
chunks_metadata.append(
ChunkMetadata(
chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx
@@ -2035,7 +2149,9 @@ async def extract_facts_from_contents_batch_api(
fact = Fact(fact=combined_text, fact_type=fact_type, **fact_data)
chunk_facts.append(fact)
except Exception as e:
logger.error(f"Failed to create Fact model for fact {i}: {e}")
message = f"{custom_id}: failed to create Fact model for fact {i}: {e}"
logger.error(message)
extraction_errors.add(message)
continue
all_facts_from_llm.extend(chunk_facts)
@@ -2100,6 +2216,8 @@ async def extract_facts_from_contents_batch_api(
# Step 8: Auto-tag facts from label groups with tag=True
_inject_label_tags(extracted_facts, config)
await _write_batch_extraction_errors(pool, operation_id, schema, extraction_errors)
logger.info(f"Batch API extracted {len(extracted_facts)} facts from {len(all_chunks_info)} chunks")
return extracted_facts, chunks_metadata, total_usage
@@ -147,12 +147,13 @@ async def ensure_bank_exists(conn, bank_id: str, ops=None) -> None:
internal_id = uuid.uuid4()
inserted = await conn.fetchval(
f"""
INSERT INTO {fq_table("banks")} (bank_id, disposition, mission, internal_id)
VALUES ($1, $2::jsonb, $3, $4)
INSERT INTO {fq_table("banks")} (bank_id, name, disposition, mission, internal_id)
VALUES ($1, $2, $3::jsonb, $4, $5)
ON CONFLICT (bank_id) DO NOTHING
RETURNING bank_id
""",
bank_id,
bank_id, # Default name is the bank_id (matches get_or_create_bank_profile)
json.dumps(DEFAULT_DISPOSITION),
"",
internal_id,
@@ -574,12 +574,10 @@ async def compute_semantic_links_ann(
# the transaction end handles both.
rows: list = []
async with conn.transaction():
# Transaction-local ANN tuning. Each supported backend exposes its own
# GUC (hnsw.ef_search on pgvector, vchordrq.probes on vchord); the
# dispatcher returns the right knob for the configured backend with a
# value tuned for top-50 semantic link creation (lower recall but much
# lower latency than the recall-side default). SET LOCAL auto-reverts
# at commit, so we don't pollute the pool for subsequent queries.
# Transaction-local ANN tuning. The dispatcher only returns GUCs that
# are safe to apply at session/transaction scope for the configured
# backend. VectorChord probe values are index-shaped, so vchordrq uses
# index storage fallback parameters instead of a blanket SET LOCAL.
for guc, value in ann_search_tuning_settings(configured_vector_extension(), kind="low_latency"):
await conn.execute(f"SET LOCAL {guc} = {value}")
@@ -599,23 +597,35 @@ async def compute_semantic_links_ann(
t_query = time_mod.time()
seed_count = sum(1 for ft in fact_types if ft == fact_type)
logger.debug(f"[ANN] Querying fact_type={fact_type}: {seed_count} seeds")
# Cast each seed's text embedding to `vector` exactly once in a
# MATERIALIZED CTE. Casting inside the LATERAL (s.emb_text::vector)
# re-parses the ~5KB embedding string for every candidate row the
# probe touches — seeds × bank_units text-parses per batch, which
# dominated the whole job on small banks (see #1919: ~50 seeds over
# ~1k units took 1.5-3.7s, ~25-48x slower than casting once). The
# stable `vector` column also lets the planner consider an HNSW
# index scan, which a cast expression inhibits.
ft_rows = await conn.fetch(
f"""
WITH seeds AS MATERIALIZED (
SELECT unit_id, emb_text::vector AS emb
FROM _ann_seeds
WHERE fact_type = $2
)
SELECT s.unit_id AS from_id,
n.id::text AS to_id,
n.similarity
FROM _ann_seeds s
FROM seeds s
CROSS JOIN LATERAL (
SELECT mu.id,
1 - (mu.embedding <=> s.emb_text::vector) AS similarity
1 - (mu.embedding <=> s.emb) AS similarity
FROM {fq_table("memory_units")} mu
WHERE mu.bank_id = $1
AND mu.fact_type = $2
AND mu.embedding IS NOT NULL
ORDER BY mu.embedding <=> s.emb_text::vector
ORDER BY mu.embedding <=> s.emb
LIMIT $3
) n
WHERE s.fact_type = $2
""",
bank_id,
fact_type,
@@ -624,7 +634,7 @@ async def compute_semantic_links_ann(
logger.debug(f"[ANN] fact_type={fact_type}: {len(ft_rows)} rows in {time_mod.time() - t_query:.3f}s")
rows.extend(ft_rows)
# Transaction commits here. _ann_seeds is dropped (ON COMMIT DROP).
# hnsw.ef_search reverts (SET LOCAL).
# Transaction-local ANN tuning reverts (SET LOCAL).
for row in rows:
sim = float(min(1.0, max(0.0, row["similarity"])))
@@ -11,6 +11,7 @@ import logging
import time
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
@@ -111,6 +112,25 @@ RetainOutboxCallback = Callable[[asyncpg.Connection], Awaitable[None]]
RetainOutboxCallbackFactory = Callable[[list[RetainContentDict]], RetainOutboxCallback | None]
def _resolve_narrator(profile_name: str, bank_id: str) -> str | None:
"""Resolve the narrator (memory owner) used to prime fact extraction.
The narrator is injected as a "Narrator: {name}" line in fact extraction and
is stamped into the who-dimension of every first-person fact and the
observations later consolidated from those facts. That is correct for a named
agent retaining its own logs, but harmful when ``name`` is just the bank_id:
on auto-create the bank ``name`` defaults to ``bank_id``, which is typically a
routing key (e.g. ``my-agent::channel-456::user-789``), not a speaker. Priming
extraction with a routing key embeds that string into stored fact text and
pollutes downstream observations (issue #1680). Suppress it in that case.
Returns the narrator name, or ``None`` to omit the Narrator line entirely.
"""
if profile_name == bank_id:
return None
return profile_name
def _build_retain_params(contents_dicts, document_tags=None, doc_contents=None):
"""Build retain_params and merged_tags from content dicts."""
if doc_contents is not None:
@@ -404,6 +424,7 @@ async def retain_batch(
db_semaphore: "asyncio.Semaphore | None" = None,
document_body_override: str | None = None,
chunk_index_offset: int = 0,
progress_callback: "Callable[..., Awaitable[None]] | None" = None,
) -> tuple[list[list[str]], TokenUsage, int | None]:
"""
Process a batch of content through the retain pipeline.
@@ -439,7 +460,9 @@ async def retain_batch(
# Get bank profile
profile = await bank_utils.get_bank_profile(pool, bank_id)
agent_name = profile["name"]
# Suppress the narrator when name == bank_id (auto-create default) — see
# _resolve_narrator for why a routing-key narrator pollutes extraction (#1680).
agent_name = _resolve_narrator(profile["name"], bank_id)
# Convert dicts to RetainContent objects
contents = _build_contents(contents_dicts, document_tags)
@@ -692,6 +715,7 @@ async def retain_batch(
db_semaphore=db_semaphore,
document_body_override=document_body_override,
chunk_index_offset=chunk_index_offset,
progress_callback=progress_callback,
)
@@ -831,6 +855,7 @@ async def _streaming_retain_batch(
db_semaphore: "asyncio.Semaphore | None" = None,
document_body_override: str | None = None,
chunk_index_offset: int = 0,
progress_callback: "Callable[..., Awaitable[None]] | None" = None,
) -> tuple[list[list[str]], TokenUsage]:
"""
Process a large document in streaming mini-batches to bound memory usage.
@@ -952,19 +977,29 @@ async def _streaming_retain_batch(
tags=source.tags,
observation_scopes=source.observation_scopes,
)
extracted, processed, chunk_meta, usage = await _extract_and_embed(
[content],
llm_config,
agent_name,
config,
embeddings_model,
format_date_fn,
fact_type_override,
log_buffer,
pool,
operation_id,
schema,
)
# Attribute this chunk's extraction LLM call to its document, so the
# trace row carries document_id (a document accrues one such trace
# per retain/re-retain). Per-call: the operation-level trace context
# is shared across a batch's documents.
from ..llm_trace import reset_call_metadata, set_call_metadata
meta_token = set_call_metadata({"document_id": effective_doc_id})
try:
extracted, processed, chunk_meta, usage = await _extract_and_embed(
[content],
llm_config,
agent_name,
config,
embeddings_model,
format_date_fn,
fact_type_override,
log_buffer,
pool,
operation_id,
schema,
)
finally:
reset_call_metadata(meta_token)
await chunk_queue.put((global_idx, content, extracted, processed, chunk_meta, usage))
# Memory: release the chunk text from the shared list now that it's
# been extracted and queued. The queued RetainContent holds its own copy.
@@ -999,6 +1034,25 @@ async def _streaming_retain_batch(
async def _db_consumer() -> None:
batch: list[tuple] = []
consumer_batch_idx = 0
chunks_committed = 0
# Best-effort durable progress: how many chunks of this document have been
# extracted+committed so far. Written per consumer batch so an operator polling
# the retain operation sees "storing 200/1200 chunks" advancing instead of a
# single opaque sub-batch tick. Never lets a heartbeat failure break retain.
async def _emit_chunk_progress() -> None:
if not (progress_callback and operation_id):
return
try:
await progress_callback(
operation_id,
stage="storing",
processed=chunks_committed,
total=total_chunks,
detail={"facts_committed": len(all_unit_ids)},
)
except Exception:
logger.debug("retain chunk-progress write failed", exc_info=True)
while True:
item = await chunk_queue.get()
@@ -1010,6 +1064,8 @@ async def _streaming_retain_batch(
consumer_batch_idx,
is_last=True,
)
chunks_committed += len(batch)
await _emit_chunk_progress()
break
batch.append(item)
@@ -1029,6 +1085,8 @@ async def _streaming_retain_batch(
is_last=False,
)
consumer_batch_idx += 1
chunks_committed += len(batch)
await _emit_chunk_progress()
batch = []
async def _process_db_batch(
@@ -1181,20 +1239,17 @@ async def _streaming_retain_batch(
async with acquire_with_retry(pool) as conn:
async with conn.transaction():
# --- Document ownership gate ---
# Lock the document row to serialize all concurrent writers.
# SELECT ... FOR UPDATE doesn't lock non-existent rows, so we
# first ensure the row exists with a lightweight upsert, THEN lock it.
# The content_hash='__pending__' placeholder is immediately overwritten
# by handle_document_tracking or upsert_document_metadata below.
await conn.execute(
f"INSERT INTO {fq_table('documents')} (id, bank_id, original_text, content_hash) "
f"VALUES ($1, $2, '', '__pending__') "
f"ON CONFLICT (id, bank_id) DO NOTHING",
effective_doc_id,
bank_id,
)
existing_hash = await conn.fetchval(
f"SELECT content_hash FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2 FOR UPDATE",
# Ensure the document row exists, lock it to serialize all
# concurrent same-document writers, and read its pre-existing
# hash. The lock prevents interleaved retains from corrupting
# each other in handle_document_tracking; the returned hash
# ('__pending__' for a freshly inserted row) drives the
# takeover check for later batches below. The PG/Oracle split
# lives in the ops layer because Oracle can't do this upsert +
# RETURNING in a single statement.
existing_hash = await pool.ops.lock_document_for_write(
conn,
fq_table("documents"),
effective_doc_id,
bank_id,
)
@@ -1501,6 +1556,35 @@ async def _streaming_retain_batch(
# ---------------------------------------------------------------------------
@dataclass
class _ChunkDiff:
"""Classification of chunk indices when diffing new content vs stored chunks."""
unchanged: list[int]
changed: list[int]
new: list[int]
removed: list[int]
def _classify_chunk_diff(existing_by_index: dict[int, Any], new_hashes: dict[int, str]) -> _ChunkDiff:
"""Classify chunk indices by comparing freshly computed ``new_hashes``
(index -> content hash) against the currently stored chunks
(``existing_by_index``: index -> chunk row)."""
diff = _ChunkDiff(unchanged=[], changed=[], new=[], removed=[])
for idx, new_hash in new_hashes.items():
existing = existing_by_index.get(idx)
if existing and existing.content_hash == new_hash:
diff.unchanged.append(idx)
elif existing:
diff.changed.append(idx)
else:
diff.new.append(idx)
for idx in existing_by_index:
if idx not in new_hashes:
diff.removed.append(idx)
return diff
async def _try_delta_retain(
pool: Any,
embeddings_model,
@@ -1570,18 +1654,11 @@ async def _try_delta_retain(
existing_by_index = {c.chunk_index: c for c in existing_chunks}
new_hashes = {idx: chunk_storage.compute_chunk_hash(text) for idx, text in new_chunks_with_contents.items()}
unchanged_indices, changed_indices, new_indices, removed_indices = [], [], [], []
for idx, new_hash in new_hashes.items():
existing = existing_by_index.get(idx)
if existing and existing.content_hash == new_hash:
unchanged_indices.append(idx)
elif existing:
changed_indices.append(idx)
else:
new_indices.append(idx)
for idx in existing_by_index:
if idx not in new_hashes:
removed_indices.append(idx)
diff = _classify_chunk_diff(existing_by_index, new_hashes)
unchanged_indices = diff.unchanged
changed_indices = diff.changed
new_indices = diff.new
removed_indices = diff.removed
log_buffer.append(
f"[delta] Chunk diff: {len(unchanged_indices)} unchanged, "
@@ -1628,20 +1705,85 @@ async def _try_delta_retain(
document_body_override=document_body_override,
)
# Extract facts and generate embeddings (shared pipeline)
extracted_facts, processed_facts, new_chunk_metadata, usage = await _extract_and_embed(
delta_contents,
llm_config,
agent_name,
config,
embeddings_model,
format_date_fn,
fact_type_override,
log_buffer,
pool,
operation_id,
schema,
)
# Freshness recheck BEFORE the (expensive) LLM extraction.
#
# We snapshotted the document hash and chunks outside any lock. A concurrent
# retain for the same document may have committed a new version while we were
# chunking and diffing. Re-read the current hash; if it changed, recompute the
# diff against the now-committed chunk state. If the concurrent writer already
# produced content identical to ours, there is nothing left to extract — skip
# the LLM call entirely (metadata-only). If it still differs, fall back to the
# streaming path (which dedups per-chunk and re-locks the document).
#
# This narrows — but cannot fully close — the race window: a writer can still
# commit during our extraction. The post-extraction hash gate inside the write
# transaction remains the correctness backstop; this check exists purely to
# avoid burning LLM tokens on work a concurrent request already did.
async with acquire_with_retry(pool) as conn:
recheck_hash = await conn.fetchval(
f"SELECT content_hash FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2",
effective_doc_id,
bank_id,
)
if recheck_hash is not None and doc_hash_at_load is not None and recheck_hash != doc_hash_at_load:
log_buffer.append(
f"[delta] Document {effective_doc_id} changed before extraction "
f"(concurrent retain) — rechecking diff against current state"
)
async with acquire_with_retry(pool) as conn:
current_chunks = await chunk_storage.load_existing_chunks(conn, bank_id, effective_doc_id)
if not current_chunks or any(c.content_hash is None for c in current_chunks):
log_buffer.append("[delta] Recheck: current chunks unavailable — falling back to full retain")
logger.info("\n" + "\n".join(log_buffer) + "\n")
return None
current_by_index = {c.chunk_index: c for c in current_chunks}
recheck = _classify_chunk_diff(current_by_index, new_hashes)
if not (recheck.changed or recheck.new or recheck.removed):
log_buffer.append(
"[delta] Recheck: concurrent retain already stored identical content — "
"skipping extraction, updating metadata only"
)
return await _delta_metadata_only(
pool,
bank_id,
contents_dicts,
contents,
effective_doc_id,
document_tags,
log_buffer,
start_time,
outbox_callback,
document_body_override=document_body_override,
)
log_buffer.append(
f"[delta] Recheck: {len(recheck.changed) + len(recheck.new) + len(recheck.removed)} chunks still differ — "
f"falling back to full retain"
)
logger.info("\n" + "\n".join(log_buffer) + "\n")
return None
# Extract facts and generate embeddings (shared pipeline). Attribute these
# extraction calls to the document so the delta re-retain's trace also binds
# to it (a document accrues one trace per full/delta retain).
from ..llm_trace import reset_call_metadata, set_call_metadata
meta_token = set_call_metadata({"document_id": effective_doc_id})
try:
extracted_facts, processed_facts, new_chunk_metadata, usage = await _extract_and_embed(
delta_contents,
llm_config,
agent_name,
config,
embeddings_model,
format_date_fn,
fact_type_override,
log_buffer,
pool,
operation_id,
schema,
)
finally:
reset_call_metadata(meta_token)
# Database transaction
result_unit_ids: list[list[str]] = []
@@ -7,6 +7,27 @@ from typing import Any
from .types import MergedCandidate, RetrievalResult
def cap_per_source(results: list[RetrievalResult], cap: int) -> list[RetrievalResult]:
"""Truncate a single retrieval arm to its top-``cap`` results.
Applied per source (semantic, BM25, graph, temporal) before fusion so that
one over-expanding backend cannot crowd out the others when the merged pool
is later trimmed to the reranker's global candidate budget. The caller is
responsible for sorting ``results`` by relevance first; this only slices.
Args:
results: Results for a single source, already sorted best-first.
cap: Maximum results to keep. ``0`` (or negative) disables the cap.
Returns:
The original list when the cap is disabled or not exceeded, otherwise a
truncated copy of the top ``cap`` results.
"""
if cap <= 0 or len(results) <= cap:
return results
return results[:cap]
def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 60) -> list[MergedCandidate]:
"""
Merge multiple ranked result lists using Reciprocal Rank Fusion.
@@ -77,6 +98,66 @@ def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 6
return merged_results
def interleave_fusion(result_lists: list[list[RetrievalResult]]) -> list[MergedCandidate]:
"""Round-robin (interleaved) fusion — an alternative to RRF for dedup-style recall.
RRF scores a doc by the *sum* of its reciprocal ranks across arms, so a result
that is #1 in one arm but absent/low in the others gets averaged down. That is
exactly the consolidation-dedup failure mode: the near-identical existing
observation (the "twin" to merge into) is semantic rank #1, yet shares no
source-fact graph link and little lexical overlap, so RRF drops it below the
recall budget cutoff and the LLM never sees it creates a duplicate.
Interleave instead *guarantees every arm's top hits a slot*: take each arm's
#1, then each arm's #2, … in arm-priority order, de-duplicating, until all
results are placed. The arm priority is the order of ``result_lists``
(semantic, bm25, graph, temporal), so semantic #1 is always first.
``rrf_score`` is assigned strictly decreasing by final interleave position so
downstream order-by-score sorts preserve the interleave order; ``source_ranks``
mirrors the RRF bookkeeping (each doc's rank within every arm it appears in).
"""
source_names = ["semantic", "bm25", "graph", "temporal"]
source_ranks: dict[str, dict[str, int]] = {}
all_retrievals: dict[str, RetrievalResult] = {}
for source_idx, results in enumerate(result_lists):
source_name = source_names[source_idx] if source_idx < len(source_names) else f"source_{source_idx}"
for rank, retrieval in enumerate(results, start=1):
if not isinstance(retrieval, RetrievalResult):
raise TypeError(
f"Expected RetrievalResult but got {type(retrieval).__name__} in {source_name} results at rank {rank}"
)
doc_id = retrieval.id
all_retrievals.setdefault(doc_id, retrieval)
source_ranks.setdefault(doc_id, {})[f"{source_name}_rank"] = rank
# Round-robin pick across arms in priority order: all #1s, then all #2s, ...
ordered_ids: list[str] = []
seen: set[str] = set()
max_len = max((len(r) for r in result_lists), default=0)
for r in range(max_len):
for results in result_lists:
if r < len(results):
doc_id = results[r].id
if doc_id not in seen:
seen.add(doc_id)
ordered_ids.append(doc_id)
n = len(ordered_ids)
return [
MergedCandidate(
retrieval=all_retrievals[doc_id],
# Strictly decreasing by interleave position → sorting desc by rrf_score
# reproduces the interleave order downstream.
rrf_score=float(n - pos),
rrf_rank=pos + 1,
source_ranks=source_ranks[doc_id],
)
for pos, doc_id in enumerate(ordered_ids)
]
def normalize_scores_on_deltas(results: list[dict[str, Any]], score_keys: list[str]) -> list[dict[str, Any]]:
"""
Normalize scores based on deltas (min-max normalization within result set).
@@ -0,0 +1,117 @@
"""Per-strategy recall boosting.
A deployment can prioritise one retrieval arm (semantic, bm25, graph, temporal)
over the others via ``HINDSIGHT_API_RECALL_STRATEGY_BOOSTS``, expressed as a
human priority *level* rather than an opaque number e.g. ``graph:high`` to
strongly favour graph hits.
A level is chosen instead of a raw weight because the boost is applied in two
structurally different places that live on different score scales, so a single
number could not mean the same thing in both. The level maps to a tuned
:class:`BoostWeights` pair:
1. **Before the reranker cap** :func:`boosted_rrf_score` uses ``BoostWeights.rrf``
as a weighted-RRF multiplier on the boosted arm's rank contribution, so its
candidates survive the global reranker candidate budget instead of being
trimmed by raw RRF score. Rank-aware: a candidate ranked #1 in the boosted
arm is protected more than one ranked #200.
2. **After the reranker** :func:`additive_strategy_boost` uses
``BoostWeights.additive`` as a flat bump to the final ranking weight (which
sits in ~[0, 1] after cross-encoder + recency/temporal scoring), nudging the
boosted arm's candidates up the final ordering.
Both functions are no-ops when ``boosts`` is empty, preserving current behaviour.
"""
from dataclasses import dataclass
from .types import MergedCandidate
@dataclass(frozen=True)
class BoostWeights:
"""Per-stage boost magnitudes for one priority level.
The two fields live on different scales on purpose (see module docstring):
``rrf`` multiplies an arm's ``1/(k+rank)`` RRF contribution; ``additive`` is
added directly to the post-rerank weight in ~[0, 1].
"""
rrf: float
additive: float
# Priority level -> per-stage boost magnitudes. Tuned against real recall traces
# (LoCoMo bank, 336 merged candidates → 300-cap, local ms-marco cross-encoder):
#
# Stage 1 (rrf, weighted-RRF multiplier on the arm's 1/(k+rank) contribution).
# The observed 300-cap boundary RRF score was ~0.0055; a graph-only candidate
# falls below it past graph-rank ~120. The multipliers map to that boundary:
# low=1.0 doubles the arm's vote — rescues at-risk candidates from the cut
# (graph-rank 150: 0.0048 → 0.0095) without reshuffling much.
# medium=3.0 promotes them into the middle of the pool (~rank 60).
# high=6.0 makes the boosted arm dominate the top of the candidate pool.
#
# Stage 2 (additive, flat bump to the post-rerank weight in [0, 1]). The local
# cross-encoder is sharply bimodal: strong direct matches score 0.50.999, while
# everything else — including graph hits the CE undervalues, which is exactly
# what we boost — collapses near 0. So the additive lifts a ~0 candidate up the
# weight scale. Levels are calibrated as relevance thresholds it can outrank:
# low=0.05 nudges above the near-0 tail; loses to any real CE match.
# medium=0.2 competes with weak/moderate matches.
# high=0.5 wins over most semantic matches (honouring "prioritise graph over
# semantic"); only a strong direct match (>0.5 normalized) still wins.
#
# The keys are the user-facing contract; config.py validates env input against
# them (kept in sync by a guard test).
BOOST_LEVELS: dict[str, BoostWeights] = {
"low": BoostWeights(rrf=1.0, additive=0.05),
"medium": BoostWeights(rrf=3.0, additive=0.2),
"high": BoostWeights(rrf=6.0, additive=0.5),
}
def boosted_rrf_score(candidate: MergedCandidate, boosts: dict[str, str], k: int = 60) -> float:
"""Return ``candidate``'s RRF score plus a weighted-RRF boost delta.
For each boosted arm the candidate appeared in, adds ``level.rrf * 1/(k+rank)``
i.e. scales that arm's RRF contribution by the level's multiplier. Staying
in RRF units keeps the boost comparable to the base score and rank-aware.
Args:
candidate: Merged candidate carrying ``rrf_score`` and ``source_ranks``.
boosts: Map of strategy name -> priority level. Empty means no boost.
k: RRF constant; must match the value used during fusion.
Returns:
The (possibly) boosted score to sort by. Equal to ``rrf_score`` when no
boosted arm surfaced this candidate.
"""
if not boosts:
return candidate.rrf_score
delta = 0.0
for strategy, level in boosts.items():
rank = candidate.source_ranks.get(f"{strategy}_rank")
if rank is not None:
delta += BOOST_LEVELS[level].rrf * (1.0 / (k + rank))
return candidate.rrf_score + delta
def additive_strategy_boost(source_ranks: dict[str, int], boosts: dict[str, str]) -> float:
"""Return the flat additive boost for a candidate given its source ranks.
Sums the ``additive`` magnitude of every boosted arm that surfaced the
candidate. Flat by design: the bump does not depend on the candidate's rank
within the arm, matching the post-rerank "additive boost" semantics.
Args:
source_ranks: ``{"graph_rank": 3, "semantic_rank": 50, ...}`` from RRF.
boosts: Map of strategy name -> priority level. Empty means no boost.
Returns:
The additive boost (0.0 when no boosted arm surfaced this candidate).
"""
if not boosts:
return 0.0
return sum(BOOST_LEVELS[level].additive for strategy, level in boosts.items() if f"{strategy}_rank" in source_ranks)
@@ -160,13 +160,29 @@ class CrossEncoderReranker:
import asyncio
from hindsight_api.config import ENV_MODEL_INIT_TIMEOUT, get_config
cross_encoder = self.cross_encoder
# For local providers, run in thread pool to avoid blocking event loop
if cross_encoder.provider_name == "local":
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: asyncio.run(cross_encoder.initialize()))
init = loop.run_in_executor(None, lambda: asyncio.run(cross_encoder.initialize()))
else:
await cross_encoder.initialize()
init = cross_encoder.initialize()
# Cap lazy init with the same wall-clock timeout used at startup so a
# hung model download surfaces as a clear error on the request that
# triggered it, rather than hanging the caller forever.
init_timeout = get_config().model_init_timeout
try:
await asyncio.wait_for(init, timeout=init_timeout)
except TimeoutError as e:
raise RuntimeError(
f"Cross-encoder initialization did not complete within {init_timeout:g}s. "
f"The reranker model is likely blocked loading — e.g. an offline model "
f"download. Increase {ENV_MODEL_INIT_TIMEOUT} if the first-time download "
f"legitimately needs more time."
) from e
self._initialized = True
async def rerank(self, query: str, candidates: list[MergedCandidate]) -> list[ScoredResult]:
@@ -137,6 +137,7 @@ async def retrieve_semantic_bm25_combined(
"""
result_dict: dict[str, tuple[list[RetrievalResult], list[RetrievalResult]]] = {ft: ([], []) for ft in fact_types}
config = get_config()
tokens = tokenize_query(query_text)
# Over-fetch for HNSW approximation; semantic results trimmed to limit in Python.
@@ -148,8 +149,6 @@ async def retrieve_semantic_bm25_combined(
)
table = fq_table("memory_units")
config = get_config()
# Use the SQL dialect to build backend-specific query arms, avoiding
# inline if/else branches for each database.
# Use getattr for backward compat: raw asyncpg connections (used in some
@@ -201,6 +200,7 @@ async def retrieve_semantic_bm25_combined(
embedding_param="$1",
bank_id_param="$2",
fetch_limit=hnsw_fetch,
min_similarity=config.semantic_min_similarity,
tags_clause=tags_clause,
groups_clause=groups_clause,
extra_where=created_range_clause,
@@ -226,6 +226,7 @@ async def retrieve_semantic_bm25_combined(
arm_index=i,
text_search_extension=text_ext,
bm25_language=config.text_search_extension_native_language,
bm25_min_score=config.bm25_min_score,
extra_where=created_range_clause,
)
)
@@ -273,6 +274,7 @@ async def retrieve_semantic_bm25_combined(
embedding_param="$1",
bank_id_param="$2",
fetch_limit=hnsw_fetch,
min_similarity=config.semantic_min_similarity,
tags_clause=fb_tags_clause,
groups_clause=fb_groups_clause,
extra_where=fb_created_clause,
@@ -307,6 +309,66 @@ async def retrieve_semantic_bm25_combined(
return result_dict
# Temporal entry-point selection tuning.
_TEMPORAL_POOL_SIZE = 60 # ANN candidates fetched per fact_type before coverage selection
_TEMPORAL_ENTRY_POINTS = 10 # entry points kept per fact_type after coverage selection
_TEMPORAL_COVERAGE_BUCKETS = 8 # time-buckets the window is divided into for coverage
def _coalesce_date(row: Any) -> datetime | None:
"""The unit's effective time — matches COALESCE(occurred_start, mentioned_at, occurred_end)."""
return row["occurred_start"] or row["mentioned_at"] or row["occurred_end"]
def _select_with_temporal_coverage(
pool: list,
start_date: datetime,
end_date: datetime,
limit: int,
n_buckets: int,
) -> list:
"""Pick `limit` entry points from a similarity-ranked pool, spread across the window.
The window [start_date, end_date] is split into `n_buckets` equal time-buckets.
Candidates are taken round-robin across the buckets that contain them the
best-similarity item from each populated bucket first, then the second-best from each,
and so on so every populated slice of the window is represented before any slice
contributes a second item. Within a tier, higher-similarity items lead. When the
in-window dates are degenerate (all in one bucket e.g. a batch stamped with a single
date) this collapses to plain similarity order.
"""
if len(pool) <= limit:
return list(pool)
ranked = sorted(pool, key=lambda r: r["similarity"], reverse=True)
span = (end_date - start_date).total_seconds()
def _bucket(row: Any) -> int:
d = _coalesce_date(row)
if d is None or span <= 0:
return 0
if d.tzinfo is None:
d = d.replace(tzinfo=UTC)
frac = (d - start_date).total_seconds() / span
return max(0, min(int(frac * n_buckets), n_buckets - 1))
buckets: dict[int, list] = {}
for row in ranked: # ranked is similarity-desc, so each bucket list inherits that order
buckets.setdefault(_bucket(row), []).append(row)
selected: list = []
tier = 0
while len(selected) < limit and any(len(b) > tier for b in buckets.values()):
# The tier-th best item from every bucket that still has one, strongest first.
tier_rows = [b[tier] for b in buckets.values() if len(b) > tier]
tier_rows.sort(key=lambda r: r["similarity"], reverse=True)
for row in tier_rows:
if len(selected) < limit:
selected.append(row)
tier += 1
return selected
async def retrieve_temporal_combined(
conn,
query_emb_str: str,
@@ -350,9 +412,12 @@ 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)
tag_groups_param_start = 7 + (1 if tags else 0)
# Entry-point query: fixed params are $1-$5 (emb, bank, start, end, threshold), tags at $6.
# fact_type is inlined as a literal per UNION ALL arm (not a bind) — this avoids `unnest`,
# which has no Oracle equivalent (the `<=>` operator and LIMIT are translated to Oracle by
# the backend on execute, but `unnest` is not). Mirrors retrieve_semantic_bm25_combined.
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)
# created_at time range filter (after tags/groups)
@@ -368,69 +433,88 @@ async def retrieve_temporal_combined(
created_range_clause += f" AND updated_at < ${_next_idx}"
_next_idx += 1
params: list = [query_emb_str, bank_id, fact_types, start_date, end_date, semantic_threshold]
params: list = [query_emb_str, bank_id, start_date, end_date, semantic_threshold]
if tags:
params.append(tags)
params.extend(groups_params)
params.extend(created_range_params)
# Two-phase entry point query:
# Phase 1 (date_ranked): rank by date only — no embedding computation — for all units in
# the temporal window. This lets the planner use date indexes for filtering.
# Phase 2 (sim_ranked): join back to memory_units for only the top-50-per-type candidates
# and compute embedding similarity for that small set (≤ 50 × len(fact_types) rows).
# This avoids computing embedding distances for potentially thousands of date-range rows.
entry_points = await conn.fetch(
f"""
WITH date_ranked AS MATERIALIZED (
SELECT id, fact_type,
ROW_NUMBER() OVER (
PARTITION BY fact_type
ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC NULLS LAST
) AS rn
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND fact_type = ANY($3)
AND embedding IS NOT NULL
AND (
(occurred_start IS NOT NULL AND occurred_end IS NOT NULL
AND occurred_start <= $5 AND occurred_end >= $4)
OR
(mentioned_at IS NOT NULL AND mentioned_at BETWEEN $4 AND $5)
OR
(occurred_start IS NOT NULL AND occurred_start BETWEEN $4 AND $5)
OR
(occurred_end IS NOT NULL AND occurred_end BETWEEN $4 AND $5)
)
{tags_clause}
{groups_clause}
{created_range_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.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
JOIN {fq_table("memory_units")} mu ON mu.id = dr.id
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, proof_count, document_id, chunk_id, tags, metadata, similarity
FROM sim_ranked
WHERE sim_rn <= 10
""",
*params,
)
# Entry-point selection: similarity-gated, window-filtered, then narrowed for coverage.
#
# For each fact_type, ANN-rank the units whose time overlaps the window
# (ORDER BY embedding <=> query) and keep a pool of the most relevant
# (_TEMPORAL_POOL_SIZE). The planner serves this from the per-(bank, fact_type) vector
# index when the window is broad — the dense-metadata case, where the window matches
# most rows — and from the partial date indexes plus an exact sort when the window is
# narrow. Either way the work is bounded; neither path is a scan-and-sort of the whole
# match set.
#
# Selecting by *similarity* (not recency) is deliberate. The earlier form ranked the
# entire match set by COALESCE(occurred_start, mentioned_at, occurred_end) and kept the
# 50 most recent: that biased results toward the end of the window and, on banks with
# dense/near-uniform dates (e.g. a retain batch stamped with one date), the date key was
# degenerate so the "50 most recent" became a near-random sample that could drop the
# single most relevant in-window memory — and it degraded to a full scan + disk-spilling
# sort (30s+ on a 660k-row bank). The pool is then narrowed to _TEMPORAL_ENTRY_POINTS per
# fact_type by _select_with_temporal_coverage so the entry points span the window's range
# rather than clustering in one slice.
if not fact_types:
return {}
if not entry_points:
# One similarity-ranked, window-filtered arm per fact_type, UNION ALL'd — each arm has its
# own ORDER BY ... LIMIT so the per-(bank, fact_type) vector index can serve it. fact_type
# is inlined as a literal (controlled internal enum, never user input), matching
# retrieve_semantic_bm25_combined; this keeps the query free of `unnest`/LATERAL, which the
# Oracle backend cannot translate.
pool_cols = (
"id, text, context, event_date, occurred_start, occurred_end, mentioned_at, "
"fact_type, proof_count, document_id, chunk_id, tags, metadata"
)
table = fq_table("memory_units")
arms = [
f"""(
SELECT {pool_cols}, 1 - (embedding <=> $1::vector) AS similarity
FROM {table}
WHERE bank_id = $2
AND fact_type = '{ft}'
AND embedding IS NOT NULL
AND (
(occurred_start IS NOT NULL AND occurred_end IS NOT NULL
AND occurred_start <= $4 AND occurred_end >= $3)
OR
(mentioned_at IS NOT NULL AND mentioned_at BETWEEN $3 AND $4)
OR
(occurred_start IS NOT NULL AND occurred_start BETWEEN $3 AND $4)
OR
(occurred_end IS NOT NULL AND occurred_end BETWEEN $3 AND $4)
)
AND (1 - (embedding <=> $1::vector)) >= $5
{tags_clause}
{groups_clause}
{created_range_clause}
ORDER BY embedding <=> $1::vector
LIMIT {_TEMPORAL_POOL_SIZE}
)"""
for ft in fact_types
]
pool_rows = await conn.fetch("\nUNION ALL\n".join(arms), *params)
if not pool_rows:
return {ft: [] for ft in fact_types}
# Group entry points by fact type
entries_by_ft: dict[str, list] = {ft: [] for ft in fact_types}
for ep in entry_points:
ft = ep["fact_type"]
if ft in entries_by_ft:
entries_by_ft[ft].append(ep)
# Group the ANN pool by fact type, then narrow each to coverage-spread entry points.
pool_by_ft: dict[str, list] = {ft: [] for ft in fact_types}
for row in pool_rows:
ft = row["fact_type"]
if ft in pool_by_ft:
pool_by_ft[ft].append(row)
entries_by_ft: dict[str, list] = {
ft: _select_with_temporal_coverage(
rows, start_date, end_date, _TEMPORAL_ENTRY_POINTS, _TEMPORAL_COVERAGE_BUCKETS
)
for ft, rows in pool_by_ft.items()
}
# Calculate shared temporal parameters
total_days = (end_date - start_date).total_seconds() / 86400
@@ -498,7 +582,13 @@ async def retrieve_temporal_combined(
tag_groups, spreading_groups_param_start, table_alias="mu."
)
while frontier and budget_remaining > 0 and iteration < max_iterations:
# Multi-hop temporal spreading expands a batch of seed ids with
# ``FROM unnest($2::uuid[])``, which has no Oracle equivalent. On backends
# without unnest, skip the spread: the temporal entry points are still
# returned above, and the semantic/keyword/graph retrievers cover the rest.
supports_unnest = getattr(conn, "backend_type", "postgresql") != "oracle"
while frontier and budget_remaining > 0 and iteration < max_iterations and supports_unnest:
iteration += 1
batch_ids = frontier[:batch_size]
frontier = frontier[batch_size:]
@@ -358,12 +358,15 @@ class SearchTracer:
"""
self.rrf_merged = []
for rank, (doc_id, data, rrf_meta) in enumerate(merged_results, start=1):
source_ranks = rrf_meta.get("source_ranks")
if source_ranks is None:
source_ranks = {key: value for key, value in rrf_meta.items() if key.endswith("_rank")}
self.rrf_merged.append(
RRFMergeResult(
node_id=doc_id,
text=data.get("text", ""),
rrf_score=rrf_meta.get("rrf_score", 0.0),
source_ranks=rrf_meta.get("source_ranks", {}),
source_ranks=source_ranks,
final_rrf_rank=rank,
)
)
@@ -371,6 +371,7 @@ class SQLDialect(ABC):
embedding_param: str,
bank_id_param: str,
fetch_limit: int,
min_similarity: float,
tags_clause: str = "",
groups_clause: str = "",
extra_where: str = "",
@@ -387,6 +388,7 @@ class SQLDialect(ABC):
embedding_param: Parameter placeholder for query embedding.
bank_id_param: Parameter placeholder for bank_id.
fetch_limit: Max rows to fetch (over-fetched for HNSW approximation).
min_similarity: Minimum cosine similarity to include.
tags_clause: Optional WHERE clause fragment for tag filtering.
groups_clause: Optional WHERE clause fragment for tag group filtering.
extra_where: Optional additional WHERE clause fragment (e.g. time range filter).
@@ -408,6 +410,7 @@ class SQLDialect(ABC):
arm_index: int = 0,
text_search_extension: str = "native",
bm25_language: str = "english",
bm25_min_score: float = 0.0,
extra_where: str = "",
) -> str:
"""Build a BM25/full-text search subquery arm.
@@ -430,6 +433,11 @@ class SQLDialect(ABC):
"pg_textsearch", "pgroonga"). Only relevant for PostgreSQL.
bm25_language: PostgreSQL text search dictionary used by the native
backend (e.g. "english", "french"). Ignored by other backends.
bm25_min_score: Minimum BM25 relevance score a row must exceed to be
returned. Gates out non-matching rows on backends whose
operator (e.g. VectorChord) ranks every document instead
of pre-filtering to query-term matches. Backends that
already apply a boolean match gate ignore this.
extra_where: Optional additional WHERE clause fragment (e.g. time range filter).
"""
...
@@ -234,6 +234,7 @@ class OracleDialect(SQLDialect):
embedding_param: str,
bank_id_param: str,
fetch_limit: int,
min_similarity: float,
tags_clause: str = "",
groups_clause: str = "",
extra_where: str = "",
@@ -249,7 +250,7 @@ class OracleDialect(SQLDialect):
f" WHERE bank_id = {bank_id_param}"
f" AND fact_type = '{fact_type}'"
f" AND embedding IS NOT NULL"
f" AND (1 - VECTOR_DISTANCE(embedding, {embedding_param}, COSINE)) >= 0.3"
f" AND (1 - VECTOR_DISTANCE(embedding, {embedding_param}, COSINE)) >= {min_similarity}"
f" {tags_clause}"
f" {groups_clause}"
f" {extra_where}"
@@ -271,6 +272,7 @@ class OracleDialect(SQLDialect):
arm_index: int = 0,
text_search_extension: str = "native",
bm25_language: str = "english",
bm25_min_score: float = 0.0,
extra_where: str = "",
) -> str:
# Oracle Text: CONTAINS() / SCORE() with the CTXSYS.CONTEXT index.
@@ -285,7 +287,9 @@ class OracleDialect(SQLDialect):
f" FROM {table}"
f" WHERE bank_id = {bank_id_param}"
f" AND fact_type = '{fact_type}'"
f" AND CONTAINS(text, {text_param}, {label}) > 0"
# CONTAINS already gates to genuine matches; the configurable floor
# (default 0) keeps the threshold semantics uniform across backends.
f" AND CONTAINS(text, {text_param}, {label}) > {bm25_min_score:g}"
f" {tags_clause}"
f" {groups_clause}"
f" {extra_where}"
@@ -148,6 +148,7 @@ class PostgreSQLDialect(SQLDialect):
embedding_param: str,
bank_id_param: str,
fetch_limit: int,
min_similarity: float,
tags_clause: str = "",
groups_clause: str = "",
extra_where: str = "",
@@ -161,7 +162,7 @@ class PostgreSQLDialect(SQLDialect):
f" WHERE bank_id = {bank_id_param}"
f" AND fact_type = '{fact_type}'"
f" AND embedding IS NOT NULL"
f" AND (1 - (embedding <=> {embedding_param}::vector)) >= 0.3"
f" AND (1 - (embedding <=> {embedding_param}::vector)) >= {min_similarity}"
f" {tags_clause}"
f" {groups_clause}"
f" {extra_where}"
@@ -183,25 +184,32 @@ class PostgreSQLDialect(SQLDialect):
arm_index: int = 0,
text_search_extension: str = "native",
bm25_language: str = "english",
bm25_min_score: float = 0.0,
extra_where: str = "",
) -> str:
if text_search_extension == "vchord":
# <&> returns a distance (lower = more relevant), negate for score
# <&> returns the NEGATIVE BM25 score (lower = more relevant), negate
# for a positive score where higher = more relevant.
bm25_score_expr = f"-(search_vector <&> to_bm25query('idx_memory_units_text_search', tokenize({text_param}, 'llmlingua2')))"
bm25_order_by = f"{bm25_score_expr} DESC"
bm25_where_filter = ""
# Unlike native tsvector (which has a boolean `@@` match gate), the
# VectorChord operator ranks *every* document, so a bare ORDER BY ...
# LIMIT pads the result with zero-score, non-matching rows. Gate on the
# score so only genuine term matches survive into fusion/reranking.
bm25_where_filter = f"AND -(search_vector <&> to_bm25query('idx_memory_units_text_search', tokenize({text_param}, 'llmlingua2'))) > {bm25_min_score:g}"
elif text_search_extension == "pg_textsearch":
bm25_score_expr = f"-({text_param} <@> to_bm25query({text_param}, 'idx_memory_units_text_search'))"
bm25_order_by = f"text <@> to_bm25query({text_param}, 'idx_memory_units_text_search') ASC"
bm25_where_filter = ""
elif text_search_extension == "pgroonga":
# &@~ accepts pgroonga's query syntax (raw query text). pgroonga_score
# returns a non-negative relevance score (higher = better).
# &@~ accepts pgroonga's query syntax. Escape the bind parameter so
# literal memory text containing operators like ">" or "(" is not
# parsed as a malformed query expression.
bm25_score_expr = "pgroonga_score(tableoid, ctid)"
bm25_order_by = f"{bm25_score_expr} DESC"
bm25_where_filter = (
f"AND (COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, '')) "
f"&@~ {text_param}"
f"&@~ pgroonga_query_escape({text_param})"
)
elif text_search_extension == "pg_search":
# ParadeDB pg_search: BM25 index over (id, text, context, text_signals)
@@ -0,0 +1,43 @@
"""Document transfer: export/import documents between banks without re-running the LLM.
An export is a ZIP of already-extracted facts (text, entities by canonical name,
causal relations, chunks) never embeddings or DB ids. An import replays the
deterministic half of the retain pipeline against the target bank: it re-embeds
locally with the target bank's embedding model, re-resolves entities, and
recreates temporal/semantic/causal links relative to the target bank's existing
memories. No LLM fact-extraction is involved.
Consolidated observations (``fact_type='observation'``) are intentionally
excluded from export they are derived by consolidation and are regenerated in
the target bank.
"""
from .export import export_bank, export_documents
from .importer import BankImportResult, ImportResult, import_bank, import_documents
from .schema import (
SCHEMA_VERSION,
TransferCausalRelation,
TransferChunk,
TransferDocument,
TransferFact,
TransferManifest,
TransferObservation,
TransferObservationSource,
)
__all__ = [
"SCHEMA_VERSION",
"BankImportResult",
"ImportResult",
"TransferCausalRelation",
"TransferChunk",
"TransferDocument",
"TransferFact",
"TransferManifest",
"TransferObservation",
"TransferObservationSource",
"export_bank",
"export_documents",
"import_bank",
"import_documents",
]
@@ -0,0 +1,555 @@
"""Export documents (with extracted facts, entities, causal links, chunks) to a ZIP archive.
Reads directly from the database via the backend connection. Embeddings and
database ids are deliberately omitted they are regenerated/re-resolved on
import. Consolidated observations are excluded unless ``include_observations``
is set, in which case they are written to ``observations.json``.
"""
from __future__ import annotations
import base64
import io
import json
import logging
import zipfile
from dataclasses import dataclass, field
from datetime import UTC, date, datetime
from decimal import Decimal
from typing import Any
from uuid import UUID
from ..db_utils import acquire_with_retry
from ..schema import fq_table
from .schema import (
SCHEMA_VERSION,
TransferCausalRelation,
TransferChunk,
TransferDocument,
TransferFact,
TransferManifest,
TransferObservation,
TransferObservationSource,
)
logger = logging.getLogger(__name__)
# Whole-bank export classification. Every bank-scoped table (admin.cli.BACKUP_TABLES)
# must fall into exactly one bucket below; tests/test_document_transfer.py's
# test_export_bank_covers_schema enforces this so a table added by a future
# migration can't be silently dropped from a migration archive.
# NOT written to the archive — rebuilt on import by replaying the document/fact/
# observation payload through the import pipeline:
# * documents / chunks / memory_units carry their *text* in the logical document
# payload (TransferDocument) and are re-embedded with the target model;
# * entities / unit_entities / memory_links / entity_cooccurrences are derived
# data — the pipeline re-resolves entities and rebuilds links/cooccurrence
# stats against the target bank, so they are never exported.
# Listed here only so the coverage guard can assert every table is classified.
_REPLAYED_TABLES = frozenset(
{
"documents",
"chunks",
"memory_units",
"entities",
"unit_entities",
"memory_links",
"entity_cooccurrences",
# observation_history FKs to a memory_units observation, but observations
# are derived: they're regenerated with FRESH ids when consolidation is
# replayed on import (see _EXPORTED_FACT_TYPES — observations are excluded).
# There is no stable observation id to re-attach history to, so it is not
# carried; the target rebuilds observation history as it re-consolidates.
"observation_history",
}
)
# Carried verbatim as JSON rows (bank config + synthesized state). Embedding-bearing
# rows have their vector stripped (see _DERIVED_COLUMNS) and are re-embedded on import.
_BANK_ROW_TABLES = ("banks", "mental_models", "directives", "webhooks")
# Bank-scoped child-history carried verbatim. Unlike observations, mental models
# keep their (id, bank_id) across export/import, so their refresh history can be
# re-attached. The surrogate ``id`` is dropped on dump so the target reassigns it
# (see _dump_history_rows); restored after its parent table (mental_models).
_CARRIED_HISTORY_TABLES = ("mental_model_history",)
# Operational history — only carried with include_history=True.
_HISTORY_TABLES = ("audit_log", "llm_requests")
# Intentionally never exported.
_SKIP_TABLES = frozenset(
{
"async_operations", # in-flight ops; drain on the source before migrating
"graph_maintenance_queue", # transient work queue; regenerated on import
"file_storage", # raw uploads; documents.original_text is already carried
}
)
# Derived columns dropped from carried rows so the target regenerates them with
# its own embedding model / text-search backend.
_DERIVED_COLUMNS = ("embedding", "search_vector")
@dataclass
class _UnitLocation:
"""Where a memory unit's fact lives in the assembled export (document + ordinal)."""
document_id: str
ordinal: int
@dataclass
class _LoadedFacts:
"""Facts grouped by document plus an index from unit id to its location.
``facts_by_doc`` and ``unit_index`` share the same fixed ordering so that
causal ``target_fact_index`` ordinals stay consistent across both.
"""
facts_by_doc: dict[str, list[TransferFact]] = field(default_factory=dict)
unit_index: dict[Any, _UnitLocation] = field(default_factory=dict)
@dataclass
class _LoadedExport:
"""Assembled documents plus the unit-id → location index.
``unit_index`` is retained so observation source unit ids can be resolved to
(document_id, fact_index) references when observations are exported.
"""
documents: list[TransferDocument] = field(default_factory=list)
unit_index: dict[Any, _UnitLocation] = field(default_factory=dict)
# Causal link types that retain persists between facts. Only these travel in the
# archive; temporal/semantic/entity links are regenerated against the target bank.
_CAUSAL_LINK_TYPES = ("caused_by", "causes", "enables", "prevents")
# Facts of these types are exported; observations are derived and excluded.
_EXPORTED_FACT_TYPES = ("world", "experience")
def _as_jsonb(value: Any) -> Any:
"""Coerce an asyncpg JSONB column (str or already-decoded) to a Python object."""
if value is None:
return None
if isinstance(value, str):
return json.loads(value)
return value
def _chunk_index_from_chunk_id(chunk_id: str | None) -> int | None:
"""Recover the chunk ordinal from a ``{bank_id}_{document_id}_{index}`` chunk_id.
The index is always the final underscore-delimited segment, so rsplit is
correct even when bank/document ids themselves contain underscores.
"""
if not chunk_id:
return None
try:
return int(chunk_id.rsplit("_", 1)[1])
except (IndexError, ValueError):
return None
async def export_documents(
backend: Any,
bank_id: str,
document_ids: list[str] | None = None,
*,
include_observations: bool = False,
) -> bytes:
"""Export documents from ``bank_id`` into an in-memory ZIP archive.
Args:
backend: Database backend (provides ``acquire()``).
bank_id: Source bank.
document_ids: Specific document ids to export. ``None`` exports every
document in the bank.
include_observations: Also export consolidated observations (written to
``observations.json``). Only valid for a whole-bank export.
Returns:
The ZIP archive as bytes.
Raises:
ValueError: if ``include_observations`` is combined with ``document_ids``.
"""
# Observations are bank-level and can be derived from facts spanning several
# documents, so they're only coherent when the whole bank is exported. For a
# document subset we'd have to silently drop every cross-document observation
# — reject the combination instead so the caller isn't surprised.
if include_observations and document_ids is not None:
raise ValueError("include_observations is only supported when exporting the whole bank (omit document_id)")
async with acquire_with_retry(backend) as conn:
loaded = await _load_documents(conn, bank_id, document_ids)
documents = loaded.documents
observations = await _load_observations(conn, bank_id, loaded.unit_index) if include_observations else []
archive = io.BytesIO()
fact_total = 0
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
for index, document in enumerate(documents):
fact_total += len(document.facts)
zf.writestr(
f"documents/{index:06d}.json",
document.model_dump_json(indent=2, exclude_none=False),
)
if observations:
payload = "[\n" + ",\n".join(o.model_dump_json(indent=2) for o in observations) + "\n]\n"
zf.writestr("observations.json", payload)
manifest = TransferManifest(
schema_version=SCHEMA_VERSION,
source_bank_id=bank_id,
exported_at=datetime.now(UTC),
document_count=len(documents),
fact_count=fact_total,
observation_count=len(observations),
)
zf.writestr("manifest.json", manifest.model_dump_json(indent=2))
logger.info(
"[transfer] Exported %d document(s), %d fact(s), %d observation(s) from bank %s",
len(documents),
fact_total,
len(observations),
bank_id,
)
return archive.getvalue()
def _row_json_default(obj: Any) -> Any:
"""JSON serializer for the value types asyncpg returns from bank rows."""
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, date):
return obj.isoformat()
if isinstance(obj, UUID):
return str(obj)
if isinstance(obj, Decimal):
# str preserves precision; import casts back to numeric.
return str(obj)
if isinstance(obj, (bytes, bytearray, memoryview)):
return base64.b64encode(bytes(obj)).decode("ascii")
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
async def _dump_bank_rows(conn: Any, table: str, bank_id: str) -> list[dict]:
"""Dump all rows of a bank-scoped table as JSON-ready dicts (derived columns stripped).
Embedding/search-vector columns are omitted so the target instance
regenerates them with its own model/backend on import.
"""
rows = await conn.fetch(f"SELECT * FROM {fq_table(table)} WHERE bank_id = $1", bank_id)
return [{k: v for k, v in dict(row).items() if k not in _DERIVED_COLUMNS} for row in rows]
async def _dump_history_rows(conn: Any, table: str, bank_id: str) -> list[dict]:
"""Dump a bank-scoped child-history table for carrying across instances.
Drops the surrogate ``id`` so the target reassigns it from its own IDENTITY
sequence (carrying explicit ids would leave the sequence un-advanced and
collide with later writes). Ordered oldest-first so the reassigned ids keep
the same chronological tie-break order the read path relies on.
"""
rows = await conn.fetch(
f"SELECT * FROM {fq_table(table)} WHERE bank_id = $1 ORDER BY changed_at, id",
bank_id,
)
return [{k: v for k, v in dict(row).items() if k not in _DERIVED_COLUMNS and k != "id"} for row in rows]
async def export_bank(conn: Any, bank_id: str, *, include_history: bool = False) -> bytes:
"""Export an entire bank into a portable ZIP archive (no embeddings).
Produces a superset of the documents archive: the logical
document/fact/observation export (replayed and re-embedded on import) plus
the bank's config, mental models, directives and webhooks as JSON rows. With
``include_history`` the operational tails (audit_log, llm_requests) are also
carried. Intended for migrating a bank to a new instance configured with a
different embedding model / vector / text-search backend every vector is
regenerated on the target, so nothing here is encoder-specific.
``conn`` is a live connection scoped to the bank's schema (the admin CLI sets
``_current_schema`` and passes its raw connection; the engine acquires one
after tenant auth).
"""
loaded = await _load_documents(conn, bank_id, None)
documents = loaded.documents
# Whole-bank export always carries observations (they're bank-level state).
observations = await _load_observations(conn, bank_id, loaded.unit_index)
bank_rows = {table: await _dump_bank_rows(conn, table, bank_id) for table in _BANK_ROW_TABLES}
for table in _CARRIED_HISTORY_TABLES:
bank_rows[table] = await _dump_history_rows(conn, table, bank_id)
history_rows: dict[str, list[dict]] = {}
if include_history:
history_rows = {table: await _dump_bank_rows(conn, table, bank_id) for table in _HISTORY_TABLES}
archive = io.BytesIO()
fact_total = 0
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
for index, document in enumerate(documents):
fact_total += len(document.facts)
zf.writestr(f"documents/{index:06d}.json", document.model_dump_json(indent=2, exclude_none=False))
if observations:
payload = "[\n" + ",\n".join(o.model_dump_json(indent=2) for o in observations) + "\n]\n"
zf.writestr("observations.json", payload)
for table, rows in bank_rows.items():
zf.writestr(f"{table}.json", json.dumps(rows, indent=2, default=_row_json_default))
for table, rows in history_rows.items():
zf.writestr(f"history/{table}.json", json.dumps(rows, indent=2, default=_row_json_default))
manifest = TransferManifest(
schema_version=SCHEMA_VERSION,
source_bank_id=bank_id,
exported_at=datetime.now(UTC),
document_count=len(documents),
fact_count=fact_total,
observation_count=len(observations),
archive_type="bank",
mental_model_count=len(bank_rows.get("mental_models", [])),
directive_count=len(bank_rows.get("directives", [])),
webhook_count=len(bank_rows.get("webhooks", [])),
includes_history=include_history,
)
zf.writestr("manifest.json", manifest.model_dump_json(indent=2))
logger.info(
"[transfer] Exported bank %s: %d document(s), %d fact(s), %d observation(s), "
"%d mental model(s), %d directive(s), %d webhook(s)%s",
bank_id,
len(documents),
fact_total,
len(observations),
len(bank_rows.get("mental_models", [])),
len(bank_rows.get("directives", [])),
len(bank_rows.get("webhooks", [])),
" (with history)" if include_history else "",
)
return archive.getvalue()
async def _load_documents(
conn: Any,
bank_id: str,
document_ids: list[str] | None,
) -> _LoadedExport:
"""Load and assemble TransferDocument payloads for the requested documents."""
doc_filter = "AND id = ANY($2)" if document_ids else ""
params: list[Any] = [bank_id]
if document_ids:
params.append(document_ids)
doc_rows = await conn.fetch(
f"""
SELECT id, original_text, retain_params, tags, created_at
FROM {fq_table("documents")}
WHERE bank_id = $1 {doc_filter}
ORDER BY created_at, id
""",
*params,
)
if not doc_rows:
return _LoadedExport()
selected_ids = [row["id"] for row in doc_rows]
chunks_by_doc = await _load_chunks(conn, bank_id, selected_ids)
loaded = await _load_facts(conn, bank_id, selected_ids)
await _attach_entities(conn, loaded)
await _attach_causal_relations(conn, loaded)
documents: list[TransferDocument] = []
for row in doc_rows:
doc_id = row["id"]
documents.append(
TransferDocument(
id=doc_id,
original_text=row["original_text"],
retain_params=_as_jsonb(row["retain_params"]),
tags=list(row["tags"] or []),
created_at=row["created_at"],
chunks=chunks_by_doc.get(doc_id, []),
facts=loaded.facts_by_doc.get(doc_id, []),
)
)
return _LoadedExport(documents=documents, unit_index=loaded.unit_index)
async def _load_observations(
conn: Any,
bank_id: str,
unit_index: dict[Any, _UnitLocation],
) -> list[TransferObservation]:
"""Load observations whose source facts are all present in the exported set.
Each source unit id is rewritten to its (document_id, fact_index) reference
via ``unit_index``. Only called for a whole-bank export, so every live source
fact is present; an observation is skipped only if a source no longer exists
(stale reference) that keeps every exported observation resolvable on import.
"""
rows = await conn.fetch(
f"""
SELECT id, text, tags, event_date, occurred_start, occurred_end,
mentioned_at, observation_scopes, proof_count, source_memory_ids
FROM {fq_table("memory_units")}
WHERE bank_id = $1 AND fact_type = 'observation'
ORDER BY created_at, id
""",
bank_id,
)
observations: list[TransferObservation] = []
skipped = 0
for row in rows:
source_ids = list(row["source_memory_ids"] or [])
locations = [unit_index.get(sid) for sid in source_ids]
if not source_ids or any(loc is None for loc in locations):
# An observation with sources outside the exported documents would be
# incoherent on import — skip it rather than emit dangling refs.
skipped += 1
continue
observations.append(
TransferObservation(
text=row["text"],
tags=list(row["tags"] or []),
event_date=row["event_date"],
occurred_start=row["occurred_start"],
occurred_end=row["occurred_end"],
mentioned_at=row["mentioned_at"],
observation_scopes=_as_jsonb(row["observation_scopes"]),
proof_count=row["proof_count"] or len(source_ids),
sources=[
TransferObservationSource(document_id=loc.document_id, fact_index=loc.ordinal)
for loc in locations
if loc is not None
],
)
)
if skipped:
logger.info("[transfer] Skipped %d observation(s) with sources outside the exported documents", skipped)
return observations
async def _load_chunks(conn: Any, bank_id: str, doc_ids: list[str]) -> dict[str, list[TransferChunk]]:
rows = await conn.fetch(
f"""
SELECT document_id, chunk_index, chunk_text
FROM {fq_table("chunks")}
WHERE bank_id = $1 AND document_id = ANY($2)
ORDER BY document_id, chunk_index
""",
bank_id,
doc_ids,
)
chunks_by_doc: dict[str, list[TransferChunk]] = {}
for row in rows:
chunks_by_doc.setdefault(row["document_id"], []).append(
TransferChunk(chunk_index=row["chunk_index"], chunk_text=row["chunk_text"])
)
return chunks_by_doc
async def _load_facts(conn: Any, bank_id: str, doc_ids: list[str]) -> _LoadedFacts:
"""Load non-observation facts grouped by document, with a unit-id location index.
The ordering is fixed (created_at, id) so that
``causal_relations.target_fact_index`` ordinals stay consistent.
"""
rows = await conn.fetch(
f"""
SELECT id, document_id, text, fact_type, context, event_date,
occurred_start, occurred_end, mentioned_at, metadata,
chunk_id, tags, observation_scopes
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND document_id = ANY($2)
AND fact_type = ANY($3)
ORDER BY document_id, created_at, id
""",
bank_id,
doc_ids,
list(_EXPORTED_FACT_TYPES),
)
loaded = _LoadedFacts()
for row in rows:
doc_id = row["document_id"]
bucket = loaded.facts_by_doc.setdefault(doc_id, [])
ordinal = len(bucket)
fact = TransferFact(
text=row["text"],
fact_type=row["fact_type"],
context=row["context"],
event_date=row["event_date"],
occurred_start=row["occurred_start"],
occurred_end=row["occurred_end"],
mentioned_at=row["mentioned_at"],
metadata=_as_jsonb(row["metadata"]) or {},
tags=list(row["tags"] or []),
observation_scopes=_as_jsonb(row["observation_scopes"]),
chunk_index=_chunk_index_from_chunk_id(row["chunk_id"]),
)
bucket.append(fact)
loaded.unit_index[row["id"]] = _UnitLocation(document_id=doc_id, ordinal=ordinal)
return loaded
async def _attach_entities(conn: Any, loaded: _LoadedFacts) -> None:
"""Populate each fact's ``entities`` list with its entities' canonical names."""
if not loaded.unit_index:
return
rows = await conn.fetch(
f"""
SELECT ue.unit_id, e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON e.id = ue.entity_id
WHERE ue.unit_id = ANY($1)
ORDER BY e.canonical_name
""",
list(loaded.unit_index.keys()),
)
for row in rows:
location = loaded.unit_index.get(row["unit_id"])
if location is None:
continue
loaded.facts_by_doc[location.document_id][location.ordinal].entities.append(row["canonical_name"])
async def _attach_causal_relations(conn: Any, loaded: _LoadedFacts) -> None:
"""Reconstruct causal edges as fact ordinals within each document.
A memory_link (from_unit -> to_unit, link_type) means ``from_unit`` carries
the relation pointing at ``to_unit``, so the edge is attached to the source
fact with the target's ordinal. Edges spanning two documents are skipped
(causal links are created within a single retain batch in practice).
"""
if not loaded.unit_index:
return
rows = await conn.fetch(
f"""
SELECT from_unit_id, to_unit_id, link_type
FROM {fq_table("memory_links")}
WHERE link_type = ANY($1)
AND from_unit_id = ANY($2)
AND to_unit_id = ANY($2)
""",
list(_CAUSAL_LINK_TYPES),
list(loaded.unit_index.keys()),
)
for row in rows:
source = loaded.unit_index.get(row["from_unit_id"])
target = loaded.unit_index.get(row["to_unit_id"])
if source is None or target is None:
continue
if source.document_id != target.document_id:
continue
loaded.facts_by_doc[source.document_id][source.ordinal].causal_relations.append(
TransferCausalRelation(
relation_type=row["link_type"],
target_fact_index=target.ordinal,
)
)
@@ -0,0 +1,716 @@
"""Import documents from a transfer archive by replaying the deterministic retain pipeline.
For each document the importer rebuilds the extracted facts, re-embeds them with
the *target* bank's embedding model, then runs entity resolution (Phase 1) and
the fact/link insert (Phase 2) exactly the steps retain runs after LLM
extraction. No LLM is called. Temporal/semantic/causal links and entity merges
are therefore computed relative to the target bank's existing memories.
"""
from __future__ import annotations
import io
import json
import logging
import uuid
import zipfile
from dataclasses import dataclass, field
from datetime import UTC, date, datetime
from typing import Any, Literal
from ..db_utils import acquire_with_retry
from ..retain import bank_utils, chunk_storage, embedding_processing, fact_storage, orchestrator
from ..retain.types import (
CausalRelation,
ChunkMetadata,
ExtractedFact,
ProcessedFact,
RetainContent,
)
from ..schema import fq_table
from .schema import (
SCHEMA_VERSION,
TransferDocument,
TransferFact,
TransferManifest,
TransferObservation,
)
logger = logging.getLogger(__name__)
OnConflict = Literal["skip", "replace", "new-id"]
_VALID_CONFLICT_MODES: tuple[OnConflict, ...] = ("skip", "replace", "new-id")
@dataclass
class ImportedDocument:
"""A single document successfully imported, with the units it produced.
Carried back so the engine can fire the post-retain extension hook
(usage tracking / metrics / notifications) once per imported document,
mirroring how retain reports each completed document.
"""
document_id: str
unit_ids: list[str]
content: str
tags: list[str]
@dataclass
class ImportResult:
"""Outcome of importing a transfer archive into a bank."""
documents_imported: int = 0
documents_skipped: int = 0
facts_imported: int = 0
observations_imported: int = 0
# Observations dropped because some source fact was not imported in this run.
observations_skipped: int = 0
skipped_document_ids: list[str] = field(default_factory=list)
# Original id -> freshly generated id, for documents imported under "new-id".
remapped_document_ids: dict[str, str] = field(default_factory=dict)
# Per-document outcomes, for the engine's post-retain hook. Not serialized
# into operation result_metadata (the worker handler writes counts only).
imported_documents: list[ImportedDocument] = field(default_factory=list)
@dataclass
class _ObservationOutcome:
"""Counts from the observation import pass."""
imported: int = 0
skipped: int = 0
@dataclass
class ParsedArchive:
"""A transfer archive after parsing/validation."""
manifest: TransferManifest
documents: list[TransferDocument]
observations: list[TransferObservation] = field(default_factory=list)
def parse_archive(archive_bytes: bytes) -> ParsedArchive:
"""Parse and validate a transfer ZIP archive produced by ``export_documents``."""
with zipfile.ZipFile(io.BytesIO(archive_bytes), "r") as zf:
names = set(zf.namelist())
if "manifest.json" not in names:
raise ValueError("Invalid transfer archive: manifest.json is missing")
manifest = TransferManifest.model_validate_json(zf.read("manifest.json"))
if manifest.schema_version != SCHEMA_VERSION:
raise ValueError(
f"Unsupported transfer archive schema version {manifest.schema_version} "
f"(this build supports {SCHEMA_VERSION})"
)
doc_names = sorted(n for n in names if n.startswith("documents/") and n.endswith(".json"))
documents = [TransferDocument.model_validate_json(zf.read(name)) for name in doc_names]
observations: list[TransferObservation] = []
if "observations.json" in names:
observations = [TransferObservation.model_validate(o) for o in json.loads(zf.read("observations.json"))]
return ParsedArchive(manifest=manifest, documents=documents, observations=observations)
async def import_documents(
*,
backend: Any,
embeddings_model: Any,
entity_resolver: Any,
config: Any,
format_date_fn: Any,
bank_id: str,
archive_bytes: bytes,
on_conflict: OnConflict = "skip",
ops: Any = None,
outbox_callback_factory: Any = None,
) -> ImportResult:
"""Import every document in ``archive_bytes`` into ``bank_id``.
Args:
backend: Database backend (provides ``acquire()`` and ``ops``).
embeddings_model: Target bank's embedding model (used to re-embed facts).
entity_resolver: Shared entity resolver for the target bank.
config: Resolved bank config for the target bank.
format_date_fn: Date formatter used when augmenting fact text for embedding
(must match retain so embeddings are consistent).
bank_id: Target bank.
archive_bytes: A ZIP archive produced by ``export_documents``.
on_conflict: How to handle a document id that already exists in the target
bank ``skip`` (default), ``replace`` (delete old data and re-import),
or ``new-id`` (import under a freshly generated id).
ops: Backend ``DataAccessOps``. Defaults to ``backend.ops``.
Returns:
An :class:`ImportResult` with per-document counts.
"""
if on_conflict not in _VALID_CONFLICT_MODES:
raise ValueError(f"Invalid on_conflict '{on_conflict}'; expected one of {_VALID_CONFLICT_MODES}")
if ops is None:
ops = backend.ops
parsed = parse_archive(archive_bytes)
result = ImportResult()
# (original document_id, fact ordinal) -> freshly inserted unit id. Used to
# resolve observation source references after all facts exist.
ref_map: dict[tuple[str, int], str] = {}
for document in parsed.documents:
target_id = await _resolve_target_id(backend, bank_id, document.id, on_conflict)
if target_id is None:
result.documents_skipped += 1
result.skipped_document_ids.append(document.id)
continue
if target_id != document.id:
result.remapped_document_ids[document.id] = target_id
unit_ids = await _import_one_document(
backend=backend,
embeddings_model=embeddings_model,
entity_resolver=entity_resolver,
config=config,
format_date_fn=format_date_fn,
bank_id=bank_id,
document=document,
target_id=target_id,
ops=ops,
outbox_callback_factory=outbox_callback_factory,
)
result.documents_imported += 1
result.facts_imported += len(unit_ids)
result.imported_documents.append(
ImportedDocument(
document_id=target_id,
unit_ids=unit_ids,
content=document.original_text or "",
tags=list(document.tags),
)
)
for ordinal, unit_id in enumerate(unit_ids):
ref_map[(document.id, ordinal)] = unit_id
if parsed.observations:
outcome = await _import_observations(
backend=backend,
embeddings_model=embeddings_model,
bank_id=bank_id,
observations=parsed.observations,
ref_map=ref_map,
ops=ops,
)
result.observations_imported = outcome.imported
result.observations_skipped = outcome.skipped
logger.info(
"[transfer] Imported %d document(s), %d fact(s), %d observation(s) into bank %s "
"(%d docs skipped, %d observations skipped)",
result.documents_imported,
result.facts_imported,
result.observations_imported,
bank_id,
result.documents_skipped,
result.observations_skipped,
)
return result
# Bank-level config/state tables restored verbatim from a whole-bank archive.
# Order matters for foreign keys: banks (parent) is restored before any child.
_BANK_CHILD_TABLES = ("mental_models", "directives", "webhooks")
# Child-history carried verbatim; restored after its parent (mental_models) so the
# foreign key resolves. Surrogate ids were dropped on export (the target reassigns
# them), so these restore via fresh IDENTITY values.
_CARRIED_HISTORY_TABLES = ("mental_model_history",)
_HISTORY_TABLES = ("audit_log", "llm_requests")
@dataclass
class BankImportResult:
"""Outcome of importing a whole-bank archive."""
bank_id: str
documents_imported: int = 0
facts_imported: int = 0
observations_imported: int = 0
mental_models_imported: int = 0
mental_model_history_imported: int = 0
directives_imported: int = 0
webhooks_imported: int = 0
history_rows_imported: int = 0
@dataclass
class ParsedBankArchive:
"""The bank-level sections of a whole-bank archive (documents read separately)."""
manifest: TransferManifest
# table name -> list of verbatim row dicts (banks, mental_models, directives, webhooks)
bank_rows: dict[str, list[dict]] = field(default_factory=dict)
# table name -> rows (audit_log, llm_requests), present only with --include-history
history_rows: dict[str, list[dict]] = field(default_factory=dict)
def parse_bank_archive(archive_bytes: bytes) -> ParsedBankArchive:
"""Parse the bank-level sections of a whole-bank archive (``archive_type='bank'``)."""
with zipfile.ZipFile(io.BytesIO(archive_bytes), "r") as zf:
names = set(zf.namelist())
if "manifest.json" not in names:
raise ValueError("Invalid transfer archive: manifest.json is missing")
manifest = TransferManifest.model_validate_json(zf.read("manifest.json"))
if manifest.archive_type != "bank":
raise ValueError(
f"Not a whole-bank archive (archive_type={manifest.archive_type!r}); use import_documents instead"
)
bank_rows: dict[str, list[dict]] = {}
for table in ("banks", *_BANK_CHILD_TABLES, *_CARRIED_HISTORY_TABLES):
fname = f"{table}.json"
bank_rows[table] = json.loads(zf.read(fname)) if fname in names else []
history_rows: dict[str, list[dict]] = {}
for table in _HISTORY_TABLES:
fname = f"history/{table}.json"
if fname in names:
history_rows[table] = json.loads(zf.read(fname))
return ParsedBankArchive(manifest=manifest, bank_rows=bank_rows, history_rows=history_rows)
async def _restore_rows(conn: Any, table: str, rows: list[dict]) -> int:
"""Insert verbatim rows into a bank-scoped table, coercing JSON-encoded values
back to the column's type (timestamps, uuids, jsonb). ``ON CONFLICT DO NOTHING``
keeps an import idempotent and safe to re-run against a partially-filled target."""
if not rows:
return 0
from ..memory_engine import get_current_schema
schema = get_current_schema()
col_types = {
r["column_name"]: r["data_type"]
for r in await conn.fetch(
"SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2",
schema,
table,
)
}
inserted = 0
for row in rows:
cols = [c for c in row if c in col_types]
placeholders: list[str] = []
values: list[Any] = []
for position, col in enumerate(cols, start=1):
data_type = col_types[col]
value = row[col]
if data_type in ("jsonb", "json"):
# asyncpg has no JSON codec on these raw connections; pass JSON
# text and cast. Values may already be str (no codec on export) or
# a Python object (codec on export) — normalize to text either way.
values.append(value if isinstance(value, str) or value is None else json.dumps(value))
placeholders.append(f"${position}::jsonb")
continue
if value is not None and isinstance(value, str):
if data_type in ("timestamp with time zone", "timestamp without time zone"):
value = datetime.fromisoformat(value)
elif data_type == "date":
value = date.fromisoformat(value)
elif data_type == "uuid":
value = uuid.UUID(value)
placeholders.append(f"${position}")
values.append(value)
col_list = ", ".join(f'"{c}"' for c in cols)
await conn.execute(
f"INSERT INTO {fq_table(table)} ({col_list}) VALUES ({', '.join(placeholders)}) ON CONFLICT DO NOTHING",
*values,
)
inserted += 1
return inserted
async def import_bank(
*,
backend: Any,
embeddings_model: Any,
entity_resolver: Any,
config: Any,
format_date_fn: Any,
archive_bytes: bytes,
target_bank_id: str | None = None,
include_history: bool = False,
ops: Any = None,
) -> BankImportResult:
"""Restore a whole bank from a ``export_bank`` archive into the target instance.
Re-embeds facts with the *target* instance's embedding model and rebuilds links,
entities and search/vector indexes the path for migrating a bank to an instance
configured with a different embedding model / vector / text-search backend.
The **target bank must not already exist**: import restores a complete bank
(config + facts + mental models + ) and is not a merge. If a bank with the
target id is present, this raises delete it first or pass ``target_bank_id``
for a fresh id. A migration restores *exact* state, so unlike the document
import it fires no retain webhooks and triggers no consolidation/graph
maintenance: observations and mental models are restored as exported.
"""
if ops is None:
ops = backend.ops
parsed = parse_bank_archive(archive_bytes)
source_bank_id = parsed.manifest.source_bank_id
bank_id = target_bank_id or source_bank_id
# Remapping to a different id: rewrite the carried bank_id on every row so FKs
# and PKs line up with the (also-remapped) documents/facts.
if bank_id != source_bank_id:
for rows in (*parsed.bank_rows.values(), *parsed.history_rows.values()):
for row in rows:
if "bank_id" in row:
row["bank_id"] = bank_id
async with acquire_with_retry(backend) as conn:
# Refuse to import into an existing bank — this restores a whole bank, it
# does not merge. Merging would silently mix the archive's config/mental
# models/webhooks with whatever is already there (and global-unique ids
# like webhooks/directives would collide).
if await conn.fetchval(f"SELECT 1 FROM {fq_table('banks')} WHERE bank_id = $1", bank_id):
raise ValueError(
f"Target bank '{bank_id}' already exists; import-bank restores into a fresh bank "
f"(it is not a merge). Delete the bank first, or pass a different target bank id."
)
# Bank row first — children (documents, mental_models, …) FK to it.
await _restore_rows(conn, "banks", parsed.bank_rows.get("banks", []))
# Ensure the bank's per-bank vector indexes exist (no-op for global-index
# extensions); idempotent and keeps the restored banks row (ON CONFLICT DO NOTHING).
await bank_utils.get_or_create_bank_profile(backend, bank_id)
doc_result = await import_documents(
backend=backend,
embeddings_model=embeddings_model,
entity_resolver=entity_resolver,
config=config,
format_date_fn=format_date_fn,
bank_id=bank_id,
archive_bytes=archive_bytes,
ops=ops,
outbox_callback_factory=None,
)
result = BankImportResult(
bank_id=bank_id,
documents_imported=doc_result.documents_imported,
facts_imported=doc_result.facts_imported,
observations_imported=doc_result.observations_imported,
)
async with acquire_with_retry(backend) as conn:
result.mental_models_imported = await _restore_rows(
conn, "mental_models", parsed.bank_rows.get("mental_models", [])
)
# Restored after mental_models so the (mental_model_id, bank_id) FK resolves.
result.mental_model_history_imported = await _restore_rows(
conn, "mental_model_history", parsed.bank_rows.get("mental_model_history", [])
)
result.directives_imported = await _restore_rows(conn, "directives", parsed.bank_rows.get("directives", []))
result.webhooks_imported = await _restore_rows(conn, "webhooks", parsed.bank_rows.get("webhooks", []))
if include_history:
for table in _HISTORY_TABLES:
result.history_rows_imported += await _restore_rows(conn, table, parsed.history_rows.get(table, []))
logger.info(
"[transfer] Imported bank %s: %d doc(s), %d fact(s), %d observation(s), "
"%d mental model(s), %d mm-history row(s), %d directive(s), %d webhook(s), %d history row(s)",
bank_id,
result.documents_imported,
result.facts_imported,
result.observations_imported,
result.mental_models_imported,
result.mental_model_history_imported,
result.directives_imported,
result.webhooks_imported,
result.history_rows_imported,
)
return result
async def _resolve_target_id(backend: Any, bank_id: str, document_id: str, on_conflict: OnConflict) -> str | None:
"""Decide the document id to write under, or ``None`` to skip.
Returns the original id when there is no conflict, a fresh id under
``new-id``, the original id under ``replace`` (the insert path cascades the
old data away), or ``None`` under ``skip`` when the document already exists.
"""
async with acquire_with_retry(backend) as conn:
exists = await conn.fetchval(
f"SELECT 1 FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2",
document_id,
bank_id,
)
if not exists:
return document_id
if on_conflict == "skip":
return None
if on_conflict == "new-id":
return str(uuid.uuid4())
return document_id # replace
async def _import_one_document(
*,
backend: Any,
embeddings_model: Any,
entity_resolver: Any,
config: Any,
format_date_fn: Any,
bank_id: str,
document: TransferDocument,
target_id: str,
ops: Any,
outbox_callback_factory: Any = None,
) -> list[str]:
"""Re-embed and insert a single document; returns the new unit ids in fact order."""
log_buffer: list[str] = []
# Fire the same retain.completed webhook retain emits, transactionally inside
# this document's insert. Factory returns None when no webhook manager exists.
outbox_callback = (
outbox_callback_factory([{"document_id": target_id, "tags": list(document.tags)}])
if outbox_callback_factory
else None
)
extracted_facts = [_to_extracted_fact(fact) for fact in document.facts]
processed_facts: list[ProcessedFact] = []
if extracted_facts:
augmented = embedding_processing.augment_texts_with_dates(extracted_facts, format_date_fn)
embeddings = await embedding_processing.generate_embeddings_batch(embeddings_model, augmented)
processed_facts = [ProcessedFact.from_extracted_fact(ef, emb) for ef, emb in zip(extracted_facts, embeddings)]
contents = [RetainContent(content=document.original_text or "")]
chunk_meta = [
ChunkMetadata(chunk_text=chunk.chunk_text, fact_count=0, content_index=0, chunk_index=chunk.chunk_index)
for chunk in document.chunks
]
# Phase 1 (entity resolution + semantic ANN) on its own connection, outside
# the write transaction — mirrors the retain pipeline.
entity_resolver.discard_pending_stats()
phase1 = await orchestrator._pre_resolve_phase1(
backend,
entity_resolver,
bank_id,
contents,
processed_facts,
config,
log_buffer,
skip_semantic_ann=False,
)
async with acquire_with_retry(backend) as conn:
async with conn.transaction():
# is_first_batch=True: cascade-delete any existing data for this id
# (the "replace" path) and (re)insert the document row.
await fact_storage.handle_document_tracking(
conn,
bank_id,
target_id,
document.original_text or "",
True,
document.retain_params,
document.tags,
ops=ops,
)
chunk_id_map: dict[int, str] = {}
if chunk_meta:
chunk_id_map = await chunk_storage.store_chunks_batch(conn, bank_id, target_id, chunk_meta, ops=ops)
for extracted, processed in zip(extracted_facts, processed_facts):
processed.document_id = target_id
if chunk_id_map and extracted.chunk_index is not None:
chunk_id = chunk_id_map.get(extracted.chunk_index)
if chunk_id:
processed.chunk_id = chunk_id
result_unit_ids = await orchestrator._insert_facts_and_links(
conn,
entity_resolver,
bank_id,
contents,
extracted_facts,
processed_facts,
config,
log_buffer,
resolved_entity_ids=phase1.entities.resolved_entity_ids,
entity_to_unit=phase1.entities.entity_to_unit,
unit_to_entity_ids=phase1.entities.unit_to_entity_ids,
semantic_ann_links=phase1.semantic_ann_links,
skip_semantic_links=False,
outbox_callback=outbox_callback,
ops=ops,
)
try:
await entity_resolver.flush_pending_stats()
except Exception:
logger.warning("[transfer] Entity stats flush failed for document %s", target_id, exc_info=True)
logger.debug("[transfer] Imported document %s:\n%s", target_id, "\n".join(log_buffer))
# Single content item -> result_unit_ids[0] holds the new unit ids in fact order.
return list(result_unit_ids[0]) if result_unit_ids else []
async def _import_observations(
*,
backend: Any,
embeddings_model: Any,
bank_id: str,
observations: list[TransferObservation],
ref_map: dict[tuple[str, int], str],
ops: Any,
) -> _ObservationOutcome:
"""Insert observations whose source facts were all imported in this run.
Observations carry no embedding, links, or entity rows only the unit row
plus ``source_memory_ids`` (remapped to the freshly inserted source units)
and ``proof_count``. Their source facts are marked ``consolidated_at`` so the
target bank's consolidator won't re-process them. Mirrors what consolidation
writes, but driven from the archive instead of the LLM.
Inserted as-is: imported observations are NOT merged or deduplicated against
observations that already exist in the target bank (unlike consolidation,
which merges related observations). Importing into a bank that already has
observations or importing the same archive twice can therefore produce
overlapping observations over the same facts.
"""
outcome = _ObservationOutcome()
# Resolve each observation's sources to new unit ids; drop any whose sources
# weren't all imported (e.g. a subset/skip import).
resolved: list[tuple[TransferObservation, list[str]]] = []
for obs in observations:
source_ids = [ref_map.get((s.document_id, s.fact_index)) for s in obs.sources]
if not source_ids or any(sid is None for sid in source_ids):
outcome.skipped += 1
continue
resolved.append((obs, [sid for sid in source_ids if sid is not None]))
if not resolved:
return outcome
# Observations embed the raw text (matching consolidation), not the
# date-augmented text used for facts.
embeddings = await embedding_processing.generate_embeddings_batch(
embeddings_model, [obs.text for obs, _ in resolved]
)
processed = [
ProcessedFact(
fact_text=obs.text,
fact_type="observation",
embedding=embedding,
occurred_start=obs.occurred_start,
occurred_end=obs.occurred_end,
mentioned_at=_observation_mentioned_at(obs),
context="",
metadata={},
tags=list(obs.tags),
observation_scopes=obs.observation_scopes,
document_id=None,
chunk_id=None,
)
for (obs, _sources), embedding in zip(resolved, embeddings)
]
async with acquire_with_retry(backend) as conn:
async with conn.transaction():
obs_unit_ids = await fact_storage.insert_facts_batch(conn, bank_id, processed, ops=ops)
all_source_ids: set[uuid.UUID] = set()
for (obs, sources), obs_unit_id in zip(resolved, obs_unit_ids):
source_uuids = [uuid.UUID(s) for s in sources]
all_source_ids.update(source_uuids)
await _link_observation_sources(
conn, ops, bank_id, uuid.UUID(obs_unit_id), source_uuids, obs.proof_count
)
# Mark source facts consolidated so the target consolidator skips them.
if all_source_ids:
await conn.execute(
f"UPDATE {fq_table('memory_units')} SET consolidated_at = now() "
f"WHERE bank_id = $1 AND id = ANY($2)",
bank_id,
list(all_source_ids),
)
outcome.imported = len(resolved)
return outcome
async def _link_observation_sources(
conn: Any,
ops: Any,
bank_id: str,
observation_id: uuid.UUID,
source_ids: list[uuid.UUID],
proof_count: int,
) -> None:
"""Attach source ids + proof_count to a freshly inserted observation row.
PG stores the sources in the ``source_memory_ids`` array column; Oracle uses
the ``observation_sources`` junction table (same split as consolidation).
"""
if ops.uses_observation_sources_table:
await conn.executemany(
f"INSERT INTO {fq_table('observation_sources')} (observation_id, source_id) "
f"VALUES ($1, $2) ON CONFLICT (observation_id, source_id) DO NOTHING",
[(observation_id, sid) for sid in dict.fromkeys(source_ids)],
)
await conn.execute(
f"UPDATE {fq_table('memory_units')} SET proof_count = $1 WHERE id = $2 AND bank_id = $3",
proof_count,
observation_id,
bank_id,
)
else:
await conn.execute(
f"UPDATE {fq_table('memory_units')} SET source_memory_ids = $1, proof_count = $2 "
f"WHERE id = $3 AND bank_id = $4",
source_ids,
proof_count,
observation_id,
bank_id,
)
def _observation_mentioned_at(obs: TransferObservation) -> datetime | None:
"""event_date (NOT NULL) is derived from occurred_start or mentioned_at on
insert; fall back so the column stays populated for observations too."""
mentioned_at = obs.mentioned_at
if obs.occurred_start is None and mentioned_at is None:
mentioned_at = obs.event_date or datetime.now(UTC)
return mentioned_at
def _to_extracted_fact(fact: TransferFact) -> ExtractedFact:
"""Rebuild the retain pipeline's ExtractedFact from a serialized transfer fact."""
# event_date is NOT NULL in the schema and is derived from occurred_start or
# mentioned_at on insert. When neither is present, fall back to the carried
# event_date (or now) via mentioned_at so the column stays populated.
mentioned_at = fact.mentioned_at
if fact.occurred_start is None and mentioned_at is None:
mentioned_at = fact.event_date or datetime.now(UTC)
return ExtractedFact(
fact_text=fact.text,
fact_type=fact.fact_type,
entities=list(fact.entities),
occurred_start=fact.occurred_start,
occurred_end=fact.occurred_end,
where=None,
causal_relations=[
CausalRelation(relation_type=rel.relation_type, target_fact_index=rel.target_fact_index)
for rel in fact.causal_relations
],
content_index=0,
chunk_index=fact.chunk_index,
context=fact.context or "",
mentioned_at=mentioned_at,
metadata=dict(fact.metadata),
tags=list(fact.tags),
observation_scopes=fact.observation_scopes,
)
@@ -0,0 +1,138 @@
"""Serialization schema for the document transfer archive (manifest + per-document payloads).
The archive is a ZIP:
manifest.json -- TransferManifest
documents/000000.json -- TransferDocument (one file per document)
documents/000001.json
...
Documents are stored under a zero-padded index rather than their id so that
arbitrary document ids (which may contain path-unsafe characters) never leak
into archive entry names. The real id lives inside each payload.
"""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
# Bump when the archive layout changes in a backward-incompatible way.
SCHEMA_VERSION = 1
ObservationScopes = Literal["per_tag", "combined", "all_combinations"] | list[list[str]]
class TransferCausalRelation(BaseModel):
"""A causal edge from this fact to an earlier fact in the same document.
``target_fact_index`` is the ordinal of the target fact within the document's
``facts`` list (not a database id), so it survives transfer to a new bank.
"""
relation_type: str
target_fact_index: int
class TransferFact(BaseModel):
"""One extracted fact (memory unit) without its embedding or database id.
Everything here is reused verbatim on import except the embedding, which is
regenerated by the target bank's model, and the entity ids, which are
re-resolved against the target bank by canonical name.
"""
text: str
fact_type: str
context: str | None = None
# event_date is a fallback used only when both occurred_start and
# mentioned_at are absent, to satisfy the NOT NULL event_date column.
event_date: datetime | None = None
occurred_start: datetime | None = None
occurred_end: datetime | None = None
mentioned_at: datetime | None = None
metadata: dict[str, str] = Field(default_factory=dict)
tags: list[str] = Field(default_factory=list)
observation_scopes: ObservationScopes | None = None
# Ordinal of the source chunk within the document (parsed from chunk_id).
chunk_index: int | None = None
# Entity canonical names; re-resolved against the target bank on import.
entities: list[str] = Field(default_factory=list)
causal_relations: list[TransferCausalRelation] = Field(default_factory=list)
class TransferChunk(BaseModel):
"""A raw text chunk of the source document, reused verbatim."""
chunk_index: int
chunk_text: str
class TransferObservationSource(BaseModel):
"""A reference to a source fact of an observation, by document + ordinal.
Observations span documents and reference their source facts by unit id;
those ids don't survive transfer, so each source is carried as the
(document_id, fact_index) of the fact within the exported document set.
"""
document_id: str
fact_index: int
class TransferObservation(BaseModel):
"""A consolidated observation (``fact_type='observation'``).
Observations are bank-level (not tied to one document), carry no embedding
(re-generated on import) and no entity/link associations retrieval reaches
entities/links through their source facts. Only exported when explicitly
requested, and only when every source resolves within the archive.
"""
text: str
tags: list[str] = Field(default_factory=list)
event_date: datetime | None = None
occurred_start: datetime | None = None
occurred_end: datetime | None = None
mentioned_at: datetime | None = None
observation_scopes: ObservationScopes | None = None
proof_count: int = 1
sources: list[TransferObservationSource] = Field(default_factory=list)
class TransferDocument(BaseModel):
"""A single document plus its chunks and extracted facts."""
id: str
original_text: str | None = None
retain_params: dict | None = None
tags: list[str] = Field(default_factory=list)
created_at: datetime | None = None
chunks: list[TransferChunk] = Field(default_factory=list)
facts: list[TransferFact] = Field(default_factory=list)
class TransferManifest(BaseModel):
"""Top-level archive descriptor (``manifest.json``).
The bank-level fields default to a documents-only archive so older
document-only archives (and the document import path) keep parsing
unchanged; ``export_bank`` populates them for a whole-bank archive.
"""
schema_version: int = SCHEMA_VERSION
source_bank_id: str
exported_at: datetime | None = None
document_count: int = 0
fact_count: int = 0
observation_count: int = 0
# "documents" = doc/fact/observation subset; "bank" = whole-bank export
# (also carries bank config, mental models, directives, webhooks).
archive_type: Literal["documents", "bank"] = "documents"
mental_model_count: int = 0
directive_count: int = 0
webhook_count: int = 0
# True when --include-history carried audit_log / llm_requests.
includes_history: bool = False
@@ -40,6 +40,11 @@ class Tenant:
"""
schema: str
# Optional tenant identifier. When provided, background maintenance (e.g. the
# consolidation reconcile sweep) can build a RequestContext carrying this id so
# tenant-level config overrides are honored. Leave as None for single-tenant
# setups or extensions that do not key config by tenant id.
tenant_id: str | None = None
class TenantExtension(Extension, ABC):
+51 -4
View File
@@ -184,6 +184,8 @@ class MetricsCollectorBase:
input_tokens: int = 0,
output_tokens: int = 0,
success: bool = True,
cached_input_tokens: int = 0,
thoughts_tokens: int = 0,
):
"""
Record metrics for an LLM call.
@@ -193,9 +195,11 @@ class MetricsCollectorBase:
model: Model name
scope: Scope identifier (e.g., "memory", "reflect", "consolidation")
duration: Call duration in seconds
input_tokens: Number of input/prompt tokens
output_tokens: Number of output/completion tokens
input_tokens: Number of input/prompt tokens (total)
output_tokens: Number of output/completion tokens visible in candidates
success: Whether the call was successful
cached_input_tokens: Subset of input_tokens billed at the cached rate
thoughts_tokens: Reasoning tokens (billed as output, hidden from candidates)
"""
raise NotImplementedError
@@ -233,6 +237,8 @@ class NoOpMetricsCollector(MetricsCollectorBase):
input_tokens: int = 0,
output_tokens: int = 0,
success: bool = True,
cached_input_tokens: int = 0,
thoughts_tokens: int = 0,
):
"""No-op LLM call recording."""
pass
@@ -287,6 +293,27 @@ class MetricsCollector(MetricsCollectorBase):
name="hindsight.llm.calls.total", description="Total number of LLM API calls", unit="calls"
)
# Cached input tokens (subset of input_tokens billed at the cached rate).
# Useful for tracking prompt-cache hit-rate independently of total
# input volume. provider.scope.model labels matche llm_tokens_input.
self.llm_tokens_cached_input = self.meter.create_counter(
name="hindsight.llm.tokens.cached_input",
description="Number of cached input tokens (billed at cached rate) for LLM calls",
unit="tokens",
)
# Thinking / reasoning tokens (Gemini 2.5+ family). Billed at the
# output rate by the provider but invisible to candidates_token_count.
# Surfacing them as a distinct counter is required for honest cost
# attribution: a workload that "looks cheap" by output volume can be
# silently expensive if the model is doing long reasoning chains.
self.llm_tokens_thoughts = self.meter.create_counter(
name="hindsight.llm.tokens.thoughts",
description="Number of reasoning/thinking tokens emitted by the model "
"(billed as output but not surfaced in candidates)",
unit="tokens",
)
# HTTP request metrics
self.http_request_duration = self.meter.create_histogram(
name="hindsight.http.duration", description="Duration of HTTP requests in seconds", unit="s"
@@ -370,6 +397,8 @@ class MetricsCollector(MetricsCollectorBase):
input_tokens: int = 0,
output_tokens: int = 0,
success: bool = True,
cached_input_tokens: int = 0,
thoughts_tokens: int = 0,
):
"""
Record metrics for an LLM call.
@@ -379,9 +408,15 @@ class MetricsCollector(MetricsCollectorBase):
model: Model name
scope: Scope identifier (e.g., "memory", "reflect", "consolidation")
duration: Call duration in seconds
input_tokens: Number of input/prompt tokens
output_tokens: Number of output/completion tokens
input_tokens: Number of input/prompt tokens (total, including cached portion)
output_tokens: Number of output/completion tokens visible in candidates
success: Whether the call was successful
cached_input_tokens: Subset of input_tokens billed at the cached
rate (Gemini context caching). Defaults to 0 when caching is
disabled or the provider doesn't surface this field.
thoughts_tokens: Reasoning/thinking tokens (Gemini 2.5+ family).
Billed at the output rate but not counted in candidates.
Defaults to 0 for providers that don't emit thoughts.
"""
# Base attributes for all metrics
base_attributes = {
@@ -413,6 +448,18 @@ class MetricsCollector(MetricsCollectorBase):
}
self.llm_tokens_output.add(output_tokens, output_attributes)
if cached_input_tokens > 0:
self.llm_tokens_cached_input.add(
cached_input_tokens,
{**base_attributes, "token_bucket": get_token_bucket(cached_input_tokens)},
)
if thoughts_tokens > 0:
self.llm_tokens_thoughts.add(
thoughts_tokens,
{**base_attributes, "token_bucket": get_token_bucket(thoughts_tokens)},
)
@contextmanager
def record_http_request(self, method: str, endpoint: str, status_code_getter: Callable[[], int]):
"""
+52 -7
View File
@@ -273,6 +273,8 @@ class LLMSpanRecorder:
finish_reason: Optional[str] = None,
error: Optional[Exception] = None,
tool_calls: Optional[list[dict[str, Any]]] = None,
cached_tokens: int = 0,
**_extra: Any,
) -> None:
"""
Record a completed LLM call as a span with GenAI semantic conventions.
@@ -293,6 +295,8 @@ class LLMSpanRecorder:
finish_reason: Reason the model stopped (stop, length, tool_calls, etc.)
error: Exception if call failed
tool_calls: List of tool calls made (for function calling)
cached_tokens: Cached/cache-read prompt tokens, when reported by the provider.
_extra: Tolerated forward-compatible kwargs from other recorders.
"""
try:
# Map provider name to GenAI semantic convention
@@ -326,6 +330,8 @@ class LLMSpanRecorder:
span.set_attribute(GenAIAttributes.RESPONSE_MODEL, model)
span.set_attribute(GenAIAttributes.USAGE_INPUT_TOKENS, input_tokens)
span.set_attribute(GenAIAttributes.USAGE_OUTPUT_TOKENS, output_tokens)
if cached_tokens:
span.set_attribute("gen_ai.usage.cached_tokens", cached_tokens)
# Add custom attributes for Hindsight context
span.set_attribute("hindsight.scope", scope)
@@ -460,22 +466,61 @@ class NoOpLLMSpanRecorder:
pass
# Global span recorder instance
class CompositeSpanRecorder:
"""Fans out ``record_llm_call`` to every registered recorder.
This lets multiple GenAI consumers observe the same LLM calls e.g. the
OpenTelemetry span exporter and the per-bank DB tracer through the single
``record_llm_call`` chokepoint each provider already calls. A failure in one
recorder never affects the others or the LLM call itself.
"""
def __init__(self) -> None:
self._recorders: list[Any] = []
def register(self, recorder: Any) -> None:
if recorder not in self._recorders:
self._recorders.append(recorder)
def unregister(self, recorder: Any) -> None:
if recorder in self._recorders:
self._recorders.remove(recorder)
def record_llm_call(self, **kwargs: Any) -> None:
for recorder in self._recorders:
try:
recorder.record_llm_call(**kwargs)
except Exception as e: # never let one recorder break others
logger.debug(f"Span recorder {type(recorder).__name__} failed: {e}", exc_info=True)
# Global composite recorder — always present; fans out to whatever is registered.
_composite_recorder = CompositeSpanRecorder()
# Backward-compat reference to the OTel recorder (if created).
_span_recorder: Optional[LLMSpanRecorder] = None
def get_span_recorder() -> LLMSpanRecorder | NoOpLLMSpanRecorder:
"""Get the global span recorder (NoOp if tracing disabled)."""
if _span_recorder is None:
return NoOpLLMSpanRecorder()
return _span_recorder
def get_span_recorder() -> CompositeSpanRecorder:
"""Get the global composite span recorder (fans out to all registered recorders)."""
return _composite_recorder
def register_span_recorder(recorder: Any) -> None:
"""Register an additional GenAI recorder (e.g. the per-bank DB tracer)."""
_composite_recorder.register(recorder)
def unregister_span_recorder(recorder: Any) -> None:
"""Remove a previously registered recorder."""
_composite_recorder.unregister(recorder)
def create_span_recorder() -> LLMSpanRecorder:
"""Create and set the global span recorder."""
"""Create and register the OpenTelemetry span recorder."""
global _span_recorder
tracer = get_tracer()
if tracer is None:
raise RuntimeError("Tracing not initialized. Call initialize_tracing() first.")
_span_recorder = LLMSpanRecorder(tracer)
register_span_recorder(_span_recorder)
return _span_recorder
+20 -6
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api-slim"
version = "0.7.1"
version = "0.8.0"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -81,6 +81,10 @@ local-ml = [
# Local ML models for embeddings/reranking
"sentence-transformers>=3.3.0",
"transformers>=4.53.0", # Security fixes for ReDoS vulnerabilities
# transformers (incl. latest 5.x) hard-requires tokenizers<=0.23.0 via a
# runtime check; without this cap an in-place upgrade can pull tokenizers
# 0.23.1 and break local embeddings/reranker startup. See issue #2055.
"tokenizers>=0.22.0,<=0.23.0",
"torch>=2.6.0", # CVE fix for remote code execution
"einops>=0.8.2",
"flashrank>=0.2.0",
@@ -96,6 +100,14 @@ local-llm = [
"llama-cpp-python[server]>=0.3.0",
"huggingface-hub>=0.20.0",
]
local-onnx = [
# In-process ONNX Runtime embeddings without an Ollama/TEI sidecar
"onnxruntime>=1.17.0",
"transformers>=4.53.0",
"tokenizers>=0.22.0,<=0.23.0", # See issue #2055 (transformers caps tokenizers<=0.23.0)
"huggingface-hub>=0.20.0",
"numpy>=1.26.0",
]
embedded-db = [
"pg0-embedded>=0.14.2",
]
@@ -103,7 +115,7 @@ oracle = [
"oracledb>=2.5.0",
]
all = [
"hindsight-api-slim[local-ml,embedded-db]",
"hindsight-api-slim[local-ml,local-onnx,embedded-db]",
]
test = [
"pytest>=7.0.0",
@@ -175,12 +187,14 @@ dev = [
[tool.ruff]
line-length = 120
target-version = "py311"
exclude = [
"tests/",
"**/tests/",
]
[tool.ruff.lint]
# Tests are formatted (via `ruff format`) but excluded from lint rules, which
# are too noisy for test code (unused imports/vars, import ordering).
exclude = [
"tests/**",
"**/tests/**",
]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
+30 -10
View File
@@ -1,6 +1,7 @@
"""
Pytest configuration and shared fixtures.
"""
import asyncio
import os
from pathlib import Path
@@ -20,6 +21,16 @@ from hindsight_api.pg0 import EmbeddedPostgres
DEFAULT_PG0_INSTANCE_NAME = "hindsight-test"
DEFAULT_PG0_PORT = int(os.environ.get("HINDSIGHT_TEST_PG_PORT", "5556"))
# Keep the background MaintenanceLoop from auto-starting during tests. In
# production it sweeps retention and re-schedules consolidation, but its timers
# would race shared-pg0 test data (e.g. delete llm_requests/audit_log rows a test
# just inserted). Disabling the reconcile interval and llm-trace retention — with
# audit retention already off by default — leaves no job enabled, so the loop
# never starts. Tests that exercise it call MaintenanceLoop methods
# (_run_reconcile / _purge_expired) directly.
os.environ.setdefault("HINDSIGHT_API_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS", "0")
os.environ.setdefault("HINDSIGHT_API_LLM_TRACE_RETENTION_DAYS", "-1")
# Load environment variables from .env at the start of test session
def pytest_configure(config):
@@ -66,6 +77,7 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
if db_url and not _parse_pg0_url(db_url)[0]:
# Plain postgresql:// URL - use it directly but still run migrations
from hindsight_api.migrations import run_migrations
run_migrations(db_url)
return db_url
@@ -117,6 +129,7 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
# Run migrations - uses PostgreSQL advisory lock internally,
# so safe to call from multiple workers (only one will actually run migrations)
from hindsight_api.migrations import run_migrations
run_migrations(url)
# Clean up stale test data from previous sessions. Per-bank vector indexes
@@ -147,8 +160,7 @@ def _cleanup_stale_test_data(db_url: str) -> None:
conn = await asyncpg.connect(db_url)
try:
idx_rows = await conn.fetch(
"SELECT indexname FROM pg_indexes "
"WHERE schemaname = 'public' AND indexname LIKE 'idx_mu_emb_%'"
"SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND indexname LIKE 'idx_mu_emb_%'"
)
if idx_rows:
for row in idx_rows:
@@ -156,10 +168,20 @@ def _cleanup_stale_test_data(db_url: str) -> None:
# Truncate test data in dependency order
for table in [
"entity_cooccurrences", "unit_entities", "memory_links",
"entities", "memory_units", "chunks", "documents",
"mental_models", "directives", "async_operations",
"audit_log", "webhooks", "file_storage", "banks",
"entity_cooccurrences",
"unit_entities",
"memory_links",
"entities",
"memory_units",
"chunks",
"documents",
"mental_models",
"directives",
"async_operations",
"audit_log",
"webhooks",
"file_storage",
"banks",
]:
try:
await conn.execute(f"TRUNCATE {table} CASCADE")
@@ -242,8 +264,7 @@ def oracle_db_url(_oracle_admin_dsn):
# Create test user (idempotent — skip if already exists)
try:
cursor.execute(
f'CREATE USER {test_user} IDENTIFIED BY "{test_pass}" '
f"DEFAULT TABLESPACE USERS QUOTA UNLIMITED ON USERS"
f'CREATE USER {test_user} IDENTIFIED BY "{test_pass}" DEFAULT TABLESPACE USERS QUOTA UNLIMITED ON USERS'
)
except oracledb.DatabaseError as e:
if hasattr(e.args[0], "code") and e.args[0].code == 1920:
@@ -410,13 +431,12 @@ def cross_encoder(tmp_path_factory, worker_id):
return ce
@pytest.fixture(scope="session")
def query_analyzer():
return DateparserQueryAnalyzer()
@pytest_asyncio.fixture(scope="function")
async def memory(pg0_db_url, embeddings, cross_encoder, query_analyzer):
"""
+4 -2
View File
@@ -23,6 +23,7 @@ from urllib.parse import urlparse
# Helpers
# ---------------------------------------------------------------------------
def _log(step: int, total: int, msg: str) -> None:
print(f" [{step}/{total}] {msg}")
@@ -64,8 +65,7 @@ def _bootstrap_test_user(admin_dsn: dict[str, str]) -> str:
# Create user (skip if already exists - ORA-01920)
try:
cursor.execute(
f'CREATE USER {test_user} IDENTIFIED BY "{test_pass}" '
f"DEFAULT TABLESPACE USERS QUOTA UNLIMITED ON USERS"
f'CREATE USER {test_user} IDENTIFIED BY "{test_pass}" DEFAULT TABLESPACE USERS QUOTA UNLIMITED ON USERS'
)
except oracledb.DatabaseError as e:
if hasattr(e.args[0], "code") and e.args[0].code == 1920:
@@ -100,6 +100,7 @@ def _bootstrap_test_user(admin_dsn: dict[str, str]) -> str:
# Main
# ---------------------------------------------------------------------------
async def _run() -> None:
total_steps = 8
@@ -290,6 +291,7 @@ def main() -> int:
except Exception as exc:
print(f"\nFAILED: {exc}", file=sys.stderr)
import traceback
traceback.print_exc()
return 1
+97 -35
View File
@@ -12,6 +12,7 @@ Usage in tests:
)
"""
import asyncio
import json
import logging
import os
@@ -36,6 +37,19 @@ _JUDGE_API_KEY = os.getenv(
)
_JUDGE_BASE_URL = os.getenv("HINDSIGHT_TEST_JUDGE_BASE_URL", "")
# Flakiness hardening. A single temperature-0 judge call still occasionally flips
# its verdict on borderline phrasing — the dominant source of hs_llm_core
# flakiness. When the primary verdict is "not met", we ask for a few independent
# second opinions (at a higher temperature so the samples genuinely differ) and
# uphold the failure only if the majority agrees. Verdicts that pass on the first
# call are returned immediately, so passing tests are unaffected in cost or
# behaviour, and genuine failures (where every judge agrees) still fail.
_JUDGE_CONFIRMATIONS = int(os.getenv("HINDSIGHT_TEST_JUDGE_CONFIRMATIONS", "2"))
_JUDGE_CONFIRM_TEMPERATURE = float(os.getenv("HINDSIGHT_TEST_JUDGE_CONFIRM_TEMPERATURE", "0.5"))
# Retry transient judge-call errors (rate limits, 5xx) so judge infrastructure
# hiccups never fail the test under evaluation.
_JUDGE_CALL_ATTEMPTS = int(os.getenv("HINDSIGHT_TEST_JUDGE_CALL_ATTEMPTS", "3"))
class JudgeVerdict(BaseModel):
meets_criteria: bool
@@ -58,6 +72,58 @@ def _get_judge():
return _judge_instance
async def _judge_once(
response: str,
criteria: str,
context: str | None,
temperature: float,
) -> JudgeVerdict:
"""Run a single judge verdict, retrying transient call errors."""
judge = _get_judge()
context_block = f"\n\nContext provided to the system:\n{context}" if context else ""
messages = [
{
"role": "system",
"content": (
"You are a test evaluation judge. Given a response and evaluation criteria, "
"determine whether the response meets the criteria. "
'Respond with JSON: {"meets_criteria": true/false, "reasoning": "brief explanation"}'
),
},
{
"role": "user",
"content": (
f"## Response to evaluate\n{response}\n"
f"{context_block}\n"
f"## Criteria\n{criteria}\n\n"
"Does the response meet the criteria?"
),
},
]
last_error: Exception | None = None
for attempt in range(max(1, _JUDGE_CALL_ATTEMPTS)):
try:
result = await judge.call(
messages=messages,
response_format=JudgeVerdict,
max_completion_tokens=256,
temperature=temperature,
scope="test_judge",
)
if isinstance(result, JudgeVerdict):
return result
if isinstance(result, dict):
return JudgeVerdict(**result)
return JudgeVerdict(**json.loads(str(result)))
except Exception as e: # transient provider error — retry before giving up
last_error = e
logger.warning(f"Judge call failed (attempt {attempt + 1}/{_JUDGE_CALL_ATTEMPTS}): {e}")
await asyncio.sleep(1.0 * (attempt + 1))
raise RuntimeError(f"Judge call failed after {_JUDGE_CALL_ATTEMPTS} attempts: {last_error}") from last_error
async def evaluate(
response: str,
criteria: str,
@@ -65,6 +131,12 @@ async def evaluate(
) -> JudgeVerdict:
"""Ask the judge LLM whether a response meets the given criteria.
The primary verdict is deterministic (temperature 0). If it says the criteria
are NOT met, we collect a few independent higher-temperature second opinions
and overrule the failure only when the majority disagrees smoothing out the
single-call noise that makes these tests flaky. See the module-level
``_JUDGE_CONFIRMATIONS`` notes.
Args:
response: The LLM-generated text to evaluate.
criteria: Plain-English description of what the response should contain/satisfy.
@@ -73,43 +145,33 @@ async def evaluate(
Returns:
JudgeVerdict with meets_criteria bool and reasoning string.
"""
judge = _get_judge()
primary = await _judge_once(response, criteria, context, temperature=0.0)
if primary.meets_criteria or _JUDGE_CONFIRMATIONS <= 0:
return primary
context_block = f"\n\nContext provided to the system:\n{context}" if context else ""
result = await judge.call(
messages=[
{
"role": "system",
"content": (
"You are a test evaluation judge. Given a response and evaluation criteria, "
"determine whether the response meets the criteria. "
"Respond with JSON: {\"meets_criteria\": true/false, \"reasoning\": \"brief explanation\"}"
),
},
{
"role": "user",
"content": (
f"## Response to evaluate\n{response}\n"
f"{context_block}\n"
f"## Criteria\n{criteria}\n\n"
"Does the response meet the criteria?"
),
},
],
response_format=JudgeVerdict,
max_completion_tokens=256,
temperature=0.0,
scope="test_judge",
# Primary says "not met": get independent second opinions before trusting it.
confirmations = await asyncio.gather(
*(
_judge_once(response, criteria, context, temperature=_JUDGE_CONFIRM_TEMPERATURE)
for _ in range(_JUDGE_CONFIRMATIONS)
),
return_exceptions=True,
)
verdicts = [primary] + [c for c in confirmations if isinstance(c, JudgeVerdict)]
met = sum(1 for v in verdicts if v.meets_criteria)
not_met = len(verdicts) - met
if isinstance(result, JudgeVerdict):
return result
# Fallback: parse raw dict/string
if isinstance(result, dict):
return JudgeVerdict(**result)
return JudgeVerdict(**json.loads(str(result)))
if met > not_met:
agreeing = next(v for v in verdicts if v.meets_criteria)
logger.info(f"Judge: primary 'not met' overruled by majority ({met}/{len(verdicts)} met). Criteria: {criteria}")
return JudgeVerdict(
meets_criteria=True,
reasoning=f"Majority of {len(verdicts)} judges met criteria (primary verdict overruled as noise). {agreeing.reasoning}",
)
return JudgeVerdict(
meets_criteria=False,
reasoning=f"{not_met}/{len(verdicts)} judges agree criteria not met. {primary.reasoning}",
)
async def assert_meets_criteria(
@@ -124,7 +186,7 @@ async def assert_meets_criteria(
"""
verdict = await evaluate(response=response, criteria=criteria, context=context)
if not verdict.meets_criteria:
fail_msg = msg or f"LLM judge: criteria not met"
fail_msg = msg or "LLM judge: criteria not met"
raise AssertionError(
f"{fail_msg}\n"
f" Criteria: {criteria}\n"
+128
View File
@@ -0,0 +1,128 @@
"""Tests for the admin surface: GET /admin/config + the admin_api feature flag.
These are deterministic (no LLM): the endpoint only reads server-level config. We
toggle env vars + clear the config cache to exercise the enable flag, the optional
admin token, and credential redaction.
"""
import httpx
import pytest
import pytest_asyncio
from hindsight_api.api import create_app
from hindsight_api.config import clear_config_cache
@pytest_asyncio.fixture
async def admin_client(memory):
"""Async test client for the FastAPI app (mock LLM)."""
app = create_app(memory, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
def _set_env(monkeypatch, **values: str | None) -> None:
"""Set/unset env vars and reset the cached config so the next read reflects them."""
for key, value in values.items():
if value is None:
monkeypatch.delenv(key, raising=False)
else:
monkeypatch.setenv(key, value)
clear_config_cache()
@pytest.fixture(autouse=True)
def _restore_config_cache():
"""Ensure the global config cache is reset after each test."""
yield
clear_config_cache()
@pytest.mark.asyncio
async def test_admin_config_disabled_by_default(admin_client, monkeypatch):
"""When the admin API is disabled (default), the endpoint is invisible (404)."""
_set_env(monkeypatch, HINDSIGHT_API_ENABLE_ADMIN_API=None, HINDSIGHT_API_ADMIN_TOKEN=None)
response = await admin_client.get("/admin/config")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_admin_config_enabled_no_token(admin_client, monkeypatch):
"""When enabled without a token, the endpoint is open and returns config."""
_set_env(monkeypatch, HINDSIGHT_API_ENABLE_ADMIN_API="true", HINDSIGHT_API_ADMIN_TOKEN=None)
response = await admin_client.get("/admin/config")
assert response.status_code == 200
config = response.json()["config"]
# A representative spread of non-credential fields should be present.
assert "llm_provider" in config
assert "enable_admin_api" in config
assert config["enable_admin_api"] is True
@pytest.mark.asyncio
async def test_admin_config_redacts_credentials(admin_client, monkeypatch):
"""Credential fields are masked, never returned in cleartext."""
_set_env(
monkeypatch,
HINDSIGHT_API_ENABLE_ADMIN_API="true",
HINDSIGHT_API_ADMIN_TOKEN="s3cret-token",
HINDSIGHT_API_LLM_API_KEY="super-secret-key",
)
response = await admin_client.get("/admin/config", headers={"Authorization": "Bearer s3cret-token"})
assert response.status_code == 200
config = response.json()["config"]
# The configured LLM key is present but masked.
assert config["llm_api_key"] == "***"
assert "super-secret-key" not in response.text
# Provider keys that fall back to the LLM key (and aren't in the credential
# denylist) must also be masked — the view redacts by name, not just the set.
assert config["embeddings_openrouter_api_key"] == "***"
assert config["reranker_openrouter_api_key"] == "***"
# The admin token must never leak through its own config view.
assert config["admin_api_token"] == "***"
assert "s3cret-token" not in response.text
# Value-bearing fields that merely contain "token" in their name (plural) are
# NOT redacted — they carry useful config, not secrets.
assert config["recall_max_tokens"] != "***"
@pytest.mark.asyncio
async def test_admin_config_requires_token_when_set(admin_client, monkeypatch):
"""With a token configured, missing/wrong tokens are rejected; the right one passes."""
_set_env(
monkeypatch,
HINDSIGHT_API_ENABLE_ADMIN_API="true",
HINDSIGHT_API_ADMIN_TOKEN="right-token",
)
missing = await admin_client.get("/admin/config")
assert missing.status_code == 401
wrong = await admin_client.get("/admin/config", headers={"Authorization": "Bearer wrong-token"})
assert wrong.status_code == 401
bearer = await admin_client.get("/admin/config", headers={"Authorization": "Bearer right-token"})
assert bearer.status_code == 200
# A bare token (no "Bearer " prefix) is also accepted.
bare = await admin_client.get("/admin/config", headers={"Authorization": "right-token"})
assert bare.status_code == 200
@pytest.mark.asyncio
async def test_version_reports_admin_api_flag(admin_client, monkeypatch):
"""The /version feature flags track the admin enable flag."""
_set_env(monkeypatch, HINDSIGHT_API_ENABLE_ADMIN_API="true")
enabled = await admin_client.get("/version")
assert enabled.json()["features"]["admin_api"] is True
_set_env(monkeypatch, HINDSIGHT_API_ENABLE_ADMIN_API="false")
disabled = await admin_client.get("/version")
assert disabled.json()["features"]["admin_api"] is False
+9 -22
View File
@@ -1,6 +1,7 @@
"""
Tests for agent management API (profile, disposition).
"""
import pytest
import uuid
from hindsight_api import MemoryEngine, RequestContext
@@ -17,9 +18,7 @@ class TestAgentProfile:
"""Tests for agent profile management."""
@pytest.mark.asyncio
async def test_get_bank_profile_no_auto_create_returns_none(
self, memory: MemoryEngine, request_context
):
async def test_get_bank_profile_no_auto_create_returns_none(self, memory: MemoryEngine, request_context):
"""When create_if_missing=False is passed, a missing bank returns None
rather than being silently auto-created. This is what read-only
endpoints (HTTP GET, polling, etc.) must use to avoid creating banks
@@ -27,28 +26,20 @@ class TestAgentProfile:
bank_id = unique_agent_id("test_no_auto_create")
# First call with create_if_missing=False on a non-existent bank
result = await memory.get_bank_profile(
bank_id, request_context=request_context, create_if_missing=False
)
result = await memory.get_bank_profile(bank_id, request_context=request_context, create_if_missing=False)
assert result is None, "Expected None for missing bank with create_if_missing=False"
# Verify the bank was NOT created as a side effect
result_again = await memory.get_bank_profile(
bank_id, request_context=request_context, create_if_missing=False
)
result_again = await memory.get_bank_profile(bank_id, request_context=request_context, create_if_missing=False)
assert result_again is None, "Bank must not exist after read-only call"
# And explicit auto-create still works
created = await memory.get_bank_profile(
bank_id, request_context=request_context, create_if_missing=True
)
created = await memory.get_bank_profile(bank_id, request_context=request_context, create_if_missing=True)
assert created is not None
assert created["disposition"]["skepticism"] == 3
# Now read-only call sees it
seen = await memory.get_bank_profile(
bank_id, request_context=request_context, create_if_missing=False
)
seen = await memory.get_bank_profile(bank_id, request_context=request_context, create_if_missing=False)
assert seen is not None
assert seen["disposition"]["skepticism"] == 3
@@ -122,11 +113,7 @@ class TestAgentEndpoint:
bank_id = unique_agent_id("test_put_create")
request = CreateBankRequest(
disposition=DispositionTraits(
skepticism=4,
literalism=5,
empathy=2
),
disposition=DispositionTraits(skepticism=4, literalism=5, empathy=2),
)
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
@@ -155,7 +142,7 @@ class TestAgentDispositionIntegration:
disposition = {
"skepticism": 5, # Very skeptical
"literalism": 4, # High literalism
"empathy": 2, # Low empathy
"empathy": 2, # Low empathy
}
await memory.update_bank_disposition(bank_id, disposition, request_context=request_context)
@@ -163,7 +150,7 @@ class TestAgentDispositionIntegration:
bank_id=bank_id,
contents=[
{"content": "Traditional painting techniques have been used for centuries"},
{"content": "Modern digital art is changing the art world"}
{"content": "Modern digital art is changing the art world"},
],
request_context=request_context,
)
@@ -103,6 +103,11 @@ async def test_small_async_batch_no_splitting(memory, request_context):
assert status["result_metadata"]["num_sub_batches"] == 1 # Single sub-batch
assert len(status["child_operations"]) == 1
assert status["child_operations"][0]["status"] == "completed"
child_meta = await _child_metadata(memory, bank_id, operation_id, request_context)
assert child_meta["unit_ids_count"] > 0
assert child_meta["extraction_errors_count"] == 0
assert status["result_metadata"]["unit_ids_count"] == child_meta["unit_ids_count"]
assert status["result_metadata"]["extraction_errors_count"] == 0
@pytest.mark.asyncio
@@ -166,6 +171,19 @@ async def test_large_async_batch_auto_splits(memory, request_context):
# Parent status should be aggregated as "completed"
assert parent_status["status"] == "completed"
child_unit_counts = []
for child in child_ops:
child_status = await memory.get_operation_status(
bank_id=bank_id,
operation_id=child["operation_id"],
request_context=request_context,
)
child_meta = child_status["result_metadata"]
assert child_meta["unit_ids_count"] > 0
assert child_meta["extraction_errors_count"] == 0
child_unit_counts.append(child_meta["unit_ids_count"])
assert parent_status["result_metadata"]["unit_ids_count"] == sum(child_unit_counts)
assert parent_status["result_metadata"]["extraction_errors_count"] == 0
@pytest.mark.asyncio
@@ -461,6 +479,42 @@ async def _child_metadata(memory, bank_id: str, parent_operation_id: str, reques
return child["result_metadata"]
@pytest.mark.asyncio
async def test_retain_outcome_metadata_records_zero_counts(memory, request_context, monkeypatch):
"""Completed retain operations expose explicit zero outcome counters."""
from hindsight_api.engine.response_models import TokenUsage
from hindsight_api.engine.retain import fact_extraction
async def empty_extract_facts_from_contents(
*args: object, **kwargs: object
) -> tuple[list[object], list[object], TokenUsage]:
return [], [], TokenUsage()
monkeypatch.setattr(fact_extraction, "extract_facts_from_contents", empty_extract_facts_from_contents)
bank_id = "test_retain_outcome_zero_counts"
result = await memory.submit_async_retain(
bank_id=bank_id,
contents=[{"content": "No extracted facts for this item."}],
request_context=request_context,
)
await asyncio.sleep(0.2)
parent = await memory.get_operation_status(
bank_id=bank_id,
operation_id=result["operation_id"],
request_context=request_context,
)
child_meta = await _child_metadata(memory, bank_id, result["operation_id"], request_context)
assert child_meta["unit_ids_count"] == 0
assert child_meta["extraction_errors_count"] == 0
assert "extraction_errors_sample" not in child_meta
assert parent["result_metadata"]["unit_ids_count"] == 0
assert parent["result_metadata"]["extraction_errors_count"] == 0
assert "extraction_errors_sample" not in parent["result_metadata"]
@pytest.mark.asyncio
async def test_retain_records_user_provided_document_ids(memory, request_context):
"""User-supplied document_ids land in child op result_metadata.document_ids."""
@@ -1060,3 +1114,67 @@ async def test_submit_async_batch_retain_rolls_back_parent_on_child_failure(
f"{[(r['operation_type'], r['status'], r['task_payload'] is not None) for r in rows]}. "
"The parent INSERT must be transactionally coupled to the child INSERTs."
)
@pytest.mark.asyncio
async def test_submit_async_batch_retain_creates_missing_bank(memory, request_context, monkeypatch):
"""First async retain to a new bank lazily creates the bank (async_operations
has an FK to banks) instead of raising a constraint error."""
async def noop_submit_task(_task_dict):
return None
monkeypatch.setattr(memory._task_backend, "submit_task", noop_submit_task)
bank_id = f"test_batch_newbank_{uuid.uuid4().hex[:8]}"
pool = await memory._get_pool()
await memory.submit_async_retain(
bank_id=bank_id,
contents=[{"content": "Alice works at Google.", "document_id": "doc1"}],
request_context=request_context,
)
bank = await pool.fetchrow("SELECT bank_id FROM banks WHERE bank_id = $1", bank_id)
assert bank is not None, "submit_async_retain should have lazily created the bank"
@pytest.mark.asyncio
async def test_submit_async_batch_retain_rolls_back_missing_bank_on_child_failure(
memory_no_llm_verify, request_context, monkeypatch
):
"""The lazy bank-create shares the parent+child transaction. When the child
loop fails for a bank that did not previously exist, the freshly-created bank
must roll back together with the operation rows no orphan bank."""
import hindsight_api.engine.memory_engine as me
from hindsight_api.engine.memory_engine import count_tokens
bank_id = f"test_batch_bank_rollback_{uuid.uuid4().hex[:8]}"
pool = await memory_no_llm_verify._get_pool()
# Intentionally do NOT pre-create the bank — it must be created (and then
# rolled back) inside submit_async_retain's transaction.
large_content = "The quick brown fox jumps over the lazy dog. " * 500
contents = [{"content": large_content + f" item {i}", "document_id": f"doc{i}"} for i in range(2)]
assert sum(count_tokens(item["content"]) for item in contents) > 10_000
real_class = me.BatchRetainChildMetadata
call_count = {"n": 0}
def failing_child_metadata(*args, **kwargs):
call_count["n"] += 1
if call_count["n"] == 2:
raise RuntimeError("Simulated child-step failure mid-batch")
return real_class(*args, **kwargs)
monkeypatch.setattr(me, "BatchRetainChildMetadata", failing_child_metadata)
with pytest.raises(RuntimeError, match="Simulated child-step failure"):
await memory_no_llm_verify.submit_async_retain(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
bank = await pool.fetchrow("SELECT bank_id FROM banks WHERE bank_id = $1", bank_id)
assert bank is None, "the lazily-created bank must roll back with the failed operation inserts"
@@ -1,6 +1,6 @@
"""Unit tests for async retain tag propagation."""
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -56,19 +56,18 @@ async def test_submit_async_retain_includes_document_tags_in_task_payload():
contents = [{"content": "Async retain payload test."}]
document_tags = ["scope:tools", "user:alice"]
# Return (profile, created=False) so the default-template-on-create hook is skipped.
with patch(
"hindsight_api.engine.memory_engine.bank_utils.get_or_create_bank_profile",
new_callable=AsyncMock,
return_value=(MagicMock(), False),
):
result = await MemoryEngine.submit_async_retain(
engine,
bank_id="bank-1",
contents=contents,
document_tags=document_tags,
request_context=request_context,
)
# Stub the lazy bank-create/default-template hook to a no-op (created=False)
# so the inline transaction path runs against the mock connection without
# real DB work. The hook itself is covered by dedicated tests.
engine._ensure_bank_exists = AsyncMock(return_value=False)
result = await MemoryEngine.submit_async_retain(
engine,
bank_id="bank-1",
contents=contents,
document_tags=document_tags,
request_context=request_context,
)
# Check result structure
assert "operation_id" in result
+146 -6
View File
@@ -5,6 +5,7 @@ Covers the new fields exposed by GET /v1/default/banks/{bank_id}/stats
(operations_by_status) and the new endpoint
GET /v1/default/banks/{bank_id}/stats/memories-timeseries.
"""
import uuid
from datetime import datetime
@@ -83,9 +84,7 @@ async def test_bank_stats_exposes_operations_by_status(api_client, test_bank_id)
("90d", 90, "day"),
],
)
async def test_memories_timeseries_periods(
api_client, test_bank_id, period, expected_count, expected_trunc
):
async def test_memories_timeseries_periods(api_client, test_bank_id, period, expected_count, expected_trunc):
"""Every period must return the full expected bucket count and trunc."""
try:
response = await api_client.post(
@@ -139,9 +138,7 @@ async def test_memories_timeseries_invalid_period_falls_back(api_client, test_ba
@pytest.mark.asyncio
async def test_memories_timeseries_empty_bank_returns_zero_filled_buckets(
api_client, test_bank_id
):
async def test_memories_timeseries_empty_bank_returns_zero_filled_buckets(api_client, test_bank_id):
"""A bank with no memories must still return the full zero-filled bucket set."""
try:
response = await api_client.get(
@@ -242,3 +239,146 @@ async def test_list_memories_filter_by_consolidation_state_rejects_unknown(api_c
assert response.status_code == 400
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
async def test_bank_stats_link_counts_have_no_join(api_client, test_bank_id):
"""link_counts must be populated; the deprecated breakdown fields must be empty.
Confirms the simplified single-table aggregation still produces the totals
the UI reads (`links_by_link_type`) without the historical
memory_linksmemory_units join that powered the 2D `links_breakdown` no
consumer reads.
"""
try:
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={"items": [{"content": "Carol leads platform engineering.", "context": "team"}]},
)
assert response.status_code == 200
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
assert response.status_code == 200
stats = response.json()
# link totals must still come back so the UI overview cards render.
assert isinstance(stats["links_by_link_type"], dict)
assert stats["total_links"] >= 0
# Deprecated breakdown fields stay in the response shape but are empty.
assert stats["links_breakdown"] == {}
assert stats["links_by_fact_type"] == {}
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
async def test_get_bank_freshness_returns_only_consolidation_fields(memory, test_bank_id):
"""get_bank_freshness must return just the freshness keys, no link aggregation."""
from hindsight_api.extensions import RequestContext
try:
await _insert_memory(memory, test_bank_id, "Headed for consolidation.", failed=False)
await _insert_memory(memory, test_bank_id, "Also pending.", failed=True)
freshness = await memory.get_bank_freshness(
test_bank_id,
request_context=RequestContext(internal=True),
)
assert set(freshness.keys()) == {
"last_consolidated_at",
"pending_consolidation",
"failed_consolidation",
}
assert freshness["pending_consolidation"] >= 2
assert freshness["failed_consolidation"] >= 1
finally:
await memory._bank_stats_cache.clear()
async with memory._pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", test_bank_id)
@pytest.mark.asyncio
async def test_reflect_uses_freshness_not_bank_stats(memory, test_bank_id):
"""reflect() must call the cheap freshness query, not get_bank_stats.
Counts calls to `_compute_bank_stats` (the heavy loader) during a reflect
invocation; it must stay at zero reflect should route through
`get_bank_freshness` instead.
"""
from hindsight_api.extensions import RequestContext
try:
# Seed a single memory so reflect has something to inspect.
await _insert_memory(memory, test_bank_id, "Reflect seed.", failed=False)
compute_calls = 0
original_compute = memory._compute_bank_stats
async def counting_compute(bank_id: str):
nonlocal compute_calls
compute_calls += 1
return await original_compute(bank_id)
memory._compute_bank_stats = counting_compute # type: ignore[method-assign]
try:
await memory._bank_stats_cache.clear()
try:
await memory.reflect(
test_bank_id,
"What do you know about this bank?",
request_context=RequestContext(internal=True),
)
except Exception:
# reflect may fail without a configured LLM in this test env;
# we only care that it did not invoke the heavy stats loader
# before failing.
pass
assert compute_calls == 0
finally:
memory._compute_bank_stats = original_compute # type: ignore[method-assign]
finally:
await memory._bank_stats_cache.clear()
async with memory._pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", test_bank_id)
@pytest.mark.asyncio
async def test_bank_stats_served_from_cache_on_repeat_call(api_client, memory, test_bank_id):
"""A second /stats call within the TTL must not re-run the aggregations.
The cache layer wraps the DB-heavy `_compute_bank_stats` body; counting
its invocations is the cleanest way to prove the wiring works without
relying on timing.
"""
try:
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={"items": [{"content": "Bob is a project manager.", "context": "team"}]},
)
assert response.status_code == 200
original = memory._compute_bank_stats
call_count = 0
async def counting_compute(bank_id: str):
nonlocal call_count
call_count += 1
return await original(bank_id)
# Make sure no stale entry exists from prior test ordering.
await memory._bank_stats_cache.clear()
memory._compute_bank_stats = counting_compute # type: ignore[method-assign]
try:
first = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
second = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
assert first.status_code == 200
assert second.status_code == 200
assert first.json() == second.json()
assert call_count == 1
finally:
memory._compute_bank_stats = original # type: ignore[method-assign]
await memory._bank_stats_cache.clear()
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@@ -0,0 +1,201 @@
"""Unit tests for `BankStatsCache` — TTL, eviction, and concurrent coalescing.
These tests don't touch the database; they exercise the cache wrapper
directly so the semantics are checked in isolation from `MemoryEngine`.
"""
from __future__ import annotations
import asyncio
from typing import Any
import pytest
from hindsight_api.engine.bank_stats_cache import BankStatsCache
def make_loader(return_value: dict[str, Any]) -> tuple[Any, list[int]]:
"""Returns (loader_fn, call_count_list). `call_count_list[0]` is the count."""
calls = [0]
async def loader() -> dict[str, Any]:
calls[0] += 1
return return_value
return loader, calls
@pytest.mark.asyncio
async def test_cache_disabled_passes_through() -> None:
cache = BankStatsCache(ttl_seconds=0, max_entries=100)
loader, calls = make_loader({"v": 1})
for _ in range(3):
result = await cache.get_or_load("schema", "bank", loader)
assert result == {"v": 1}
assert calls[0] == 3
@pytest.mark.asyncio
async def test_cache_serves_hits_within_ttl() -> None:
cache = BankStatsCache(ttl_seconds=60, max_entries=100)
loader, calls = make_loader({"v": 1})
first = await cache.get_or_load("schema", "bank", loader)
second = await cache.get_or_load("schema", "bank", loader)
assert first == second == {"v": 1}
assert calls[0] == 1
@pytest.mark.asyncio
async def test_cache_reloads_after_ttl_expires(monkeypatch) -> None:
cache = BankStatsCache(ttl_seconds=0.05, max_entries=100)
loader, calls = make_loader({"v": 1})
fake_time = [1000.0]
monkeypatch.setattr(cache, "_now", lambda: fake_time[0])
await cache.get_or_load("schema", "bank", loader)
fake_time[0] += 0.1 # advance past TTL
await cache.get_or_load("schema", "bank", loader)
assert calls[0] == 2
@pytest.mark.asyncio
async def test_cache_isolates_by_schema_and_bank() -> None:
cache = BankStatsCache(ttl_seconds=60, max_entries=100)
loader, calls = make_loader({"v": 1})
await cache.get_or_load("schema_a", "bank", loader)
await cache.get_or_load("schema_b", "bank", loader)
await cache.get_or_load("schema_a", "other", loader)
# 3 distinct keys → 3 loader calls.
assert calls[0] == 3
@pytest.mark.asyncio
async def test_concurrent_misses_are_coalesced() -> None:
"""6 concurrent callers on the same cold key must trigger exactly one loader."""
cache = BankStatsCache(ttl_seconds=60, max_entries=100)
calls = [0]
started = asyncio.Event()
release = asyncio.Event()
async def slow_loader() -> dict[str, Any]:
calls[0] += 1
started.set()
await release.wait()
return {"v": calls[0]}
tasks = [asyncio.create_task(cache.get_or_load("schema", "bank", slow_loader)) for _ in range(6)]
await started.wait()
# All other tasks should now be queued behind the in-flight loader.
release.set()
results = await asyncio.gather(*tasks)
assert calls[0] == 1
assert all(r == {"v": 1} for r in results)
@pytest.mark.asyncio
async def test_loader_exception_does_not_poison_cache() -> None:
cache = BankStatsCache(ttl_seconds=60, max_entries=100)
calls = [0]
async def flaky_loader() -> dict[str, Any]:
calls[0] += 1
if calls[0] == 1:
raise RuntimeError("boom")
return {"v": calls[0]}
with pytest.raises(RuntimeError, match="boom"):
await cache.get_or_load("schema", "bank", flaky_loader)
# Second call should still attempt the loader (cache wasn't populated).
result = await cache.get_or_load("schema", "bank", flaky_loader)
assert result == {"v": 2}
assert calls[0] == 2
@pytest.mark.asyncio
async def test_concurrent_loader_exception_propagates_to_waiters() -> None:
cache = BankStatsCache(ttl_seconds=60, max_entries=100)
started = asyncio.Event()
release = asyncio.Event()
async def failing_loader() -> dict[str, Any]:
started.set()
await release.wait()
raise RuntimeError("loader failed")
tasks = [asyncio.create_task(cache.get_or_load("schema", "bank", failing_loader)) for _ in range(3)]
await started.wait()
release.set()
results = await asyncio.gather(*tasks, return_exceptions=True)
assert all(isinstance(r, RuntimeError) for r in results)
@pytest.mark.asyncio
async def test_lru_eviction_respects_max_entries() -> None:
cache = BankStatsCache(ttl_seconds=60, max_entries=2)
async def loader_for(value: int):
async def _loader() -> dict[str, Any]:
return {"v": value}
return _loader
await cache.get_or_load("s", "a", await loader_for(1))
await cache.get_or_load("s", "b", await loader_for(2))
# Touch "a" so it's most-recently-used.
await cache.get_or_load("s", "a", await loader_for(99))
# Insert "c" — should evict "b" (the LRU), not "a".
await cache.get_or_load("s", "c", await loader_for(3))
# "a" is still cached (loader for "a" with value=99 must NOT be called again).
miss_check_calls = [0]
async def should_not_run() -> dict[str, Any]:
miss_check_calls[0] += 1
return {"v": -1}
cached_a = await cache.get_or_load("s", "a", should_not_run)
assert cached_a == {"v": 1}
assert miss_check_calls[0] == 0
# "b" was evicted; the loader must run on the next get.
new_b_calls = [0]
async def new_b() -> dict[str, Any]:
new_b_calls[0] += 1
return {"v": 200}
fetched_b = await cache.get_or_load("s", "b", new_b)
assert fetched_b == {"v": 200}
assert new_b_calls[0] == 1
@pytest.mark.asyncio
async def test_invalidate_drops_entry() -> None:
cache = BankStatsCache(ttl_seconds=60, max_entries=100)
loader, calls = make_loader({"v": 1})
await cache.get_or_load("schema", "bank", loader)
await cache.invalidate("schema", "bank")
await cache.get_or_load("schema", "bank", loader)
assert calls[0] == 2
@pytest.mark.asyncio
async def test_clear_drops_all_entries() -> None:
cache = BankStatsCache(ttl_seconds=60, max_entries=100)
loader, calls = make_loader({"v": 1})
await cache.get_or_load("s", "a", loader)
await cache.get_or_load("s", "b", loader)
assert calls[0] == 2
await cache.clear()
await cache.get_or_load("s", "a", loader)
await cache.get_or_load("s", "b", loader)
assert calls[0] == 4
@@ -643,9 +643,7 @@ class TestDefaultBankTemplateEnvVar:
yield default_template
@pytest.mark.asyncio
async def test_default_template_applied_on_new_bank(
self, api_client, bank_id, _patched_default_template
):
async def test_default_template_applied_on_new_bank(self, api_client, bank_id, _patched_default_template):
"""Creating a new bank applies the default template (config + mental models + directives)."""
# Trigger bank auto-creation via GET profile
resp = await api_client.put(f"/v1/default/banks/{bank_id}", json={})
@@ -730,9 +728,7 @@ class TestDefaultBankTemplateEnvVar:
assert config_resp.json()["overrides"] == {}
@pytest.mark.asyncio
async def test_default_template_malformed_is_swallowed(
self, api_client, bank_id, monkeypatch
):
async def test_default_template_malformed_is_swallowed(self, api_client, bank_id, monkeypatch):
"""A malformed default template is logged and ignored — bank creation still succeeds."""
from hindsight_api.config import _get_raw_config
+5 -10
View File
@@ -4,6 +4,7 @@ Integration test for API base path support.
Tests that the API works correctly when deployed with a base path (e.g., /hindsight)
for reverse proxy deployments.
"""
import os
import pytest
import pytest_asyncio
@@ -27,10 +28,7 @@ async def api_client_with_base_path(memory):
# Use base_url with base path
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url=f"http://test{base_path}"
) as client:
async with httpx.AsyncClient(transport=transport, base_url=f"http://test{base_path}") as client:
yield client
# Cleanup: unset base path
@@ -122,10 +120,10 @@ async def test_base_path_full_workflow(api_client_with_base_path):
"items": [
{
"content": "The API supports base path deployment for reverse proxy use cases.",
"context": "testing base path feature"
"context": "testing base path feature",
}
]
}
},
)
assert response.status_code == 200
result = response.json()
@@ -133,10 +131,7 @@ async def test_base_path_full_workflow(api_client_with_base_path):
# 3. Recall the memory
response = await api_client_with_base_path.post(
f"/v1/default/banks/{bank_id}/memories/recall",
json={
"query": "base path support"
}
f"/v1/default/banks/{bank_id}/memories/recall", json={"query": "base path support"}
)
assert response.status_code == 200
recall_result = response.json()
+187 -76
View File
@@ -7,21 +7,24 @@ Tests cover:
- Hard error when provider doesn't support the batch API (no silent fallback)
- Worker recovery on restart
"""
import pytest
import asyncio
import logging
import json
import logging
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from hindsight_api import RequestContext
from hindsight_api.engine.retain.fact_extraction import (
extract_facts_from_contents_batch_api,
extract_facts_from_contents,
RetainContent,
)
from hindsight_api.config import HindsightConfig
from hindsight_api.engine.llm_wrapper import create_llm_provider
from hindsight_api.engine.retain.fact_extraction import (
RetainContent,
extract_facts_from_contents,
extract_facts_from_contents_batch_api,
)
from hindsight_api.worker.poller import WorkerPoller
logger = logging.getLogger(__name__)
@@ -103,19 +106,21 @@ async def test_batch_api_normal_flow(mock_llm_config, test_contents, hindsight_c
"choices": [
{
"message": {
"content": json.dumps({
"facts": [
{
"what": "Alice is a senior software engineer at TechCorp",
"when": "present",
"where": "TechCorp",
"who": "Alice",
"why": "Professional background information",
"fact_type": "world",
"fact_kind": "conversation",
}
]
})
"content": json.dumps(
{
"facts": [
{
"what": "Alice is a senior software engineer at TechCorp",
"when": "present",
"where": "TechCorp",
"who": "Alice",
"why": "Professional background information",
"fact_type": "world",
"fact_kind": "conversation",
}
]
}
)
}
}
],
@@ -130,19 +135,21 @@ async def test_batch_api_normal_flow(mock_llm_config, test_contents, hindsight_c
"choices": [
{
"message": {
"content": json.dumps({
"facts": [
{
"what": "Bob joined the team last month as a junior developer",
"when": "last month",
"where": "team",
"who": "Bob",
"why": "New team member information",
"fact_type": "world",
"fact_kind": "conversation",
}
]
})
"content": json.dumps(
{
"facts": [
{
"what": "Bob joined the team last month as a junior developer",
"when": "last month",
"where": "team",
"who": "Bob",
"why": "New team member information",
"fact_type": "world",
"fact_kind": "conversation",
}
]
}
)
}
}
],
@@ -211,6 +218,7 @@ async def test_batch_api_crash_recovery(mock_llm_config, test_contents, hindsigh
schema = request_context.tenant_id
from hindsight_api.engine.task_backend import fq_table
table = fq_table("async_operations", schema)
# Create operation with batch_id already stored
@@ -221,11 +229,13 @@ async def test_batch_api_crash_recovery(mock_llm_config, test_contents, hindsigh
""",
operation_id,
bank_id,
json.dumps({
"batch_id": batch_id,
"batch_provider": "openai",
"chunk_count": 2,
}),
json.dumps(
{
"batch_id": batch_id,
"batch_provider": "openai",
"chunk_count": 2,
}
),
)
# Mock batch API responses for resume scenario
@@ -248,19 +258,21 @@ async def test_batch_api_crash_recovery(mock_llm_config, test_contents, hindsigh
"choices": [
{
"message": {
"content": json.dumps({
"facts": [
{
"what": "Alice is a senior software engineer",
"when": "present",
"where": "TechCorp",
"who": "Alice",
"why": "Background",
"fact_type": "world",
"fact_kind": "conversation",
}
]
})
"content": json.dumps(
{
"facts": [
{
"what": "Alice is a senior software engineer",
"when": "present",
"where": "TechCorp",
"who": "Alice",
"why": "Background",
"fact_type": "world",
"fact_kind": "conversation",
}
]
}
)
}
}
],
@@ -275,19 +287,21 @@ async def test_batch_api_crash_recovery(mock_llm_config, test_contents, hindsigh
"choices": [
{
"message": {
"content": json.dumps({
"facts": [
{
"what": "Bob is a junior developer",
"when": "last month",
"where": "team",
"who": "Bob",
"why": "New member",
"fact_type": "world",
"fact_kind": "conversation",
}
]
})
"content": json.dumps(
{
"facts": [
{
"what": "Bob is a junior developer",
"when": "last month",
"where": "team",
"who": "Bob",
"why": "New member",
"fact_type": "world",
"fact_kind": "conversation",
}
]
}
)
}
}
],
@@ -331,6 +345,105 @@ async def test_batch_api_crash_recovery(mock_llm_config, test_contents, hindsigh
pass
@pytest.mark.asyncio
async def test_batch_api_records_non_fatal_extraction_errors(
mock_llm_config, test_contents, hindsight_config, memory, request_context
):
"""Batch API skipped chunks are surfaced in operation result_metadata."""
bank_id = f"test_batch_errors_{datetime.now(timezone.utc).timestamp()}"
operation_id = str(uuid.uuid4())
try:
await memory.get_bank_profile(bank_id, request_context=request_context)
pool = memory._pool
schema = request_context.tenant_id
from hindsight_api.engine.task_backend import fq_table
table = fq_table("async_operations", schema)
await pool.execute(
f"""
INSERT INTO {table} (operation_id, operation_type, bank_id, status, result_metadata)
VALUES ($1, 'retain', $2, 'processing', $3::jsonb)
""",
operation_id,
bank_id,
json.dumps({}),
)
batch_id = "batch_partial_errors"
mock_llm_config._provider_impl.supports_batch_api = AsyncMock(return_value=True)
mock_llm_config._provider_impl.submit_batch = AsyncMock(return_value={"batch_id": batch_id})
mock_llm_config._provider_impl.get_batch_status = AsyncMock(
return_value={
"status": "completed",
"request_counts": {"total": 2, "completed": 2, "failed": 0},
}
)
mock_llm_config._provider_impl.retrieve_batch_results = AsyncMock(
return_value=[
{
"custom_id": "chunk_0",
"response": {
"body": {
"choices": [
{
"message": {
"content": json.dumps(
{
"facts": [
{
"what": "Alice is a senior software engineer",
"when": "present",
"where": "TechCorp",
"who": "Alice",
"why": "Background",
"fact_type": "world",
"fact_kind": "conversation",
}
]
}
)
}
}
],
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
}
},
}
]
)
facts, chunks, usage = await extract_facts_from_contents_batch_api(
contents=test_contents,
llm_config=mock_llm_config,
agent_name="test_agent",
config=hindsight_config,
pool=pool,
operation_id=operation_id,
schema=schema,
)
assert len(facts) == 1
assert len(chunks) == 2
assert chunks[1].fact_count == 0
assert usage.total_tokens == 150
row = await pool.fetchrow(f"SELECT result_metadata FROM {table} WHERE operation_id = $1", operation_id)
metadata = (
json.loads(row["result_metadata"]) if isinstance(row["result_metadata"], str) else row["result_metadata"]
)
assert metadata["batch_id"] == batch_id
assert metadata["extraction_errors_count"] == 1
assert metadata["extraction_errors_sample"] == ["chunk_1: missing batch result"]
finally:
try:
await memory.delete_bank(bank_id, request_context=request_context)
except Exception:
pass
@pytest.mark.asyncio
async def test_batch_api_raises_for_unsupported_provider(mock_llm_config, test_contents, hindsight_config):
"""Batch extraction must surface a hard error (not silently fall back) when
@@ -372,6 +485,7 @@ async def test_worker_batch_recovery(memory, request_context):
schema = request_context.tenant_id
from hindsight_api.engine.task_backend import fq_table
table = fq_table("async_operations", schema)
# Create orphaned batch operation (simulates worker crash during polling)
@@ -389,16 +503,19 @@ async def test_worker_batch_recovery(memory, request_context):
""",
operation_id,
bank_id,
json.dumps({
"batch_id": batch_id,
"batch_provider": "openai",
"chunk_count": 1,
}),
json.dumps(
{
"batch_id": batch_id,
"batch_provider": "openai",
"chunk_count": 1,
}
),
json.dumps(task_payload),
)
# Create WorkerPoller
from hindsight_api.extensions.builtin.tenant import DefaultTenantExtension
tenant_extension = DefaultTenantExtension(config={"schema": schema} if schema else {})
poller = WorkerPoller(
@@ -462,13 +579,7 @@ async def test_batch_api_via_extract_facts_from_contents(
"custom_id": "chunk_0",
"response": {
"body": {
"choices": [
{
"message": {
"content": json.dumps({"facts": []})
}
}
],
"choices": [{"message": {"content": json.dumps({"facts": []})}}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
}
},
@@ -10,6 +10,7 @@ To run:
To skip in CI:
Add @pytest.mark.skip at the test level
"""
import pytest
import os
import asyncio
@@ -115,7 +116,9 @@ def integration_config():
return config
@pytest.mark.skip(reason="Real API test - takes minutes and costs money. Run manually with: pytest tests/test_batch_api_integration.py::test_real_openai_batch_api -v -s")
@pytest.mark.skip(
reason="Real API test - takes minutes and costs money. Run manually with: pytest tests/test_batch_api_integration.py::test_real_openai_batch_api -v -s"
)
@pytest.mark.integration # Mark as integration test
@pytest.mark.slow # Mark as slow test
@pytest.mark.asyncio
@@ -174,17 +177,21 @@ async def test_real_openai_batch_api(real_llm_config, test_contents_real, integr
logger.info("\n" + "=" * 80)
logger.info("✅ BATCH COMPLETED SUCCESSFULLY")
logger.info("=" * 80)
logger.info(f"Total duration: {total_duration:.1f} seconds ({total_duration/60:.1f} minutes)")
logger.info(f"Total duration: {total_duration:.1f} seconds ({total_duration / 60:.1f} minutes)")
logger.info(f"Facts extracted: {len(facts)}")
logger.info(f"Chunks processed: {len(chunks)}")
logger.info(f"Token usage: {usage.input_tokens} input + {usage.output_tokens} output = {usage.total_tokens} total")
logger.info(f"Estimated cost: ${(usage.input_tokens * 0.00015 / 1000 + usage.output_tokens * 0.0006 / 1000):.4f}")
logger.info(
f"Token usage: {usage.input_tokens} input + {usage.output_tokens} output = {usage.total_tokens} total"
)
logger.info(
f"Estimated cost: ${(usage.input_tokens * 0.00015 / 1000 + usage.output_tokens * 0.0006 / 1000):.4f}"
)
logger.info("=" * 80)
# Log sample facts
logger.info("\n📋 Sample extracted facts:")
for i, fact in enumerate(facts[:5]): # Show first 5 facts
logger.info(f"\nFact {i+1}:")
logger.info(f"\nFact {i + 1}:")
logger.info(f" Type: {fact.fact_type}")
logger.info(f" Text: {fact.fact_text[:100]}...")
logger.info(f" Entities: {fact.entities}")
@@ -212,11 +219,13 @@ async def test_real_openai_batch_api(real_llm_config, test_contents_real, integr
f.write(f"Contents: {len(test_contents_real)} items\n")
f.write(f"Poll Interval: {integration_config.retain_batch_poll_interval_seconds}s\n\n")
f.write(f"Results:\n")
f.write(f" Total Duration: {total_duration:.1f}s ({total_duration/60:.1f} min)\n")
f.write(f" Total Duration: {total_duration:.1f}s ({total_duration / 60:.1f} min)\n")
f.write(f" Facts Extracted: {len(facts)}\n")
f.write(f" Chunks Processed: {len(chunks)}\n")
f.write(f" Token Usage: {usage.total_tokens} ({usage.input_tokens} in + {usage.output_tokens} out)\n")
f.write(f" Estimated Cost: ${(usage.input_tokens * 0.00015 / 1000 + usage.output_tokens * 0.0006 / 1000):.4f}\n")
f.write(
f" Estimated Cost: ${(usage.input_tokens * 0.00015 / 1000 + usage.output_tokens * 0.0006 / 1000):.4f}\n"
)
logger.info(f"\n📄 Timing report written to: {report_path}")
@@ -174,9 +174,7 @@ def test_async_children_packs_small_items_by_budget():
num_items = max(4, (tokens_per_batch // max(item_tokens, 1)) * 3)
contents = [{"content": item_text, "document_id": f"doc-{i}"} for i in range(num_items)]
total = sum(count_tokens(c["content"]) for c in contents)
assert total > tokens_per_batch, (
f"Test setup error: {total} tokens does not exceed budget {tokens_per_batch}"
)
assert total > tokens_per_batch, f"Test setup error: {total} tokens does not exceed budget {tokens_per_batch}"
children = _split_contents_into_async_children(contents, tokens_per_batch)
@@ -104,8 +104,7 @@ class TestCausalRelationsValidation:
for rel in facts[0].causal_relations:
# This should never happen due to validation
assert False, (
f"First fact should not have causal relations, "
f"but found: target_index={rel.target_fact_index}"
f"First fact should not have causal relations, but found: target_index={rel.target_fact_index}"
)
@pytest.mark.asyncio
@@ -139,11 +138,13 @@ class TestCausalRelationsValidation:
for i, fact in enumerate(facts):
if fact.causal_relations:
for rel in fact.causal_relations:
all_relations.append({
"from_fact": i,
"to_fact": rel.target_fact_index,
"type": rel.relation_type,
})
all_relations.append(
{
"from_fact": i,
"to_fact": rel.target_fact_index,
"type": rel.relation_type,
}
)
# If causal relations were extracted, verify they form a valid chain
if all_relations:
@@ -226,6 +227,5 @@ class TestCausalRelationsValidation:
if fact.causal_relations:
for rel in fact.causal_relations:
assert rel.relation_type in valid_types, (
f"Invalid relation_type '{rel.relation_type}'. "
f"Must be one of: {valid_types}"
f"Invalid relation_type '{rel.relation_type}'. Must be one of: {valid_types}"
)
@@ -40,7 +40,11 @@ After searching for weeks, I finally found a cheaper apartment in Brooklyn.
llm_config = LLMConfig.from_env()
facts, _, _ = await extract_facts_from_text(
text=text, event_date=datetime(2024, 3, 15), context=context, llm_config=llm_config, agent_name="TestUser",
text=text,
event_date=datetime(2024, 3, 15),
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
@@ -109,7 +113,11 @@ The renovation took three months and cost $15,000.
llm_config = LLMConfig.from_env()
facts, _, _ = await extract_facts_from_text(
text=text, event_date=datetime(2024, 6, 1), context=context, llm_config=llm_config, agent_name="TestUser",
text=text,
event_date=datetime(2024, 6, 1),
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
@@ -140,7 +148,11 @@ Machine learning fascinated me so much that I changed my career to data science.
llm_config = LLMConfig.from_env()
facts, _, _ = await extract_facts_from_text(
text=text, event_date=datetime(2024, 1, 1), context=context, llm_config=llm_config, agent_name="TestUser",
text=text,
event_date=datetime(2024, 1, 1),
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
@@ -168,7 +180,11 @@ The new role enabled me to lead a team of engineers.
llm_config = LLMConfig.from_env()
facts, _, _ = await extract_facts_from_text(
text=text, event_date=datetime(2024, 2, 15), context=context, llm_config=llm_config, agent_name="TestUser",
text=text,
event_date=datetime(2024, 2, 15),
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
@@ -180,4 +196,3 @@ The new role enabled me to lead a team of engineers.
f"Invalid target_fact_index {rel.target_fact_index} in fact {i}. "
f"Must reference previous facts only (valid range: 0 to {i - 1})"
)
@@ -130,8 +130,7 @@ async def test_store_chunks_batch_second_call_with_identical_payload(memory):
await _seed_bank_and_document(conn, bank_id, document_id)
chunks = [
ChunkMetadata(chunk_text=f"chunk-{i}", fact_count=1, content_index=0, chunk_index=i)
for i in range(5)
ChunkMetadata(chunk_text=f"chunk-{i}", fact_count=1, content_index=0, chunk_index=i) for i in range(5)
]
await chunk_storage.store_chunks_batch(conn, bank_id, document_id, chunks, ops=ops)
+1 -1
View File
@@ -1,6 +1,7 @@
"""
Test chunking functionality for large documents.
"""
import pytest
from hindsight_api.engine.retain.fact_extraction import chunk_text
@@ -53,4 +54,3 @@ def test_chunk_text_64k():
# Verify we didn't lose content
combined_length = sum(len(chunk) for chunk in chunks)
assert combined_length >= len(text) * 0.95, "Lost too much content during chunking"
@@ -344,4 +344,7 @@ class TestFactoryFunction:
assert isinstance(encoder, CohereCrossEncoder)
assert encoder.api_key == "test_key"
assert encoder.model == "cohere-rerank-v3-english"
assert encoder.base_url == "https://my-endpoint.inference.ai.azure.com/models/cohere-rerank-v3-english/invoke"
assert (
encoder.base_url
== "https://my-endpoint.inference.ai.azure.com/models/cohere-rerank-v3-english/invoke"
)
@@ -18,10 +18,12 @@ def setup_test_env():
# Save original environment values
env_vars_to_save = [
"HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS",
"HINDSIGHT_API_CONSOLIDATION_MAX_COMPLETION_TOKENS",
"HINDSIGHT_API_RETAIN_CHUNK_SIZE",
"HINDSIGHT_API_LLM_PROVIDER",
"HINDSIGHT_API_LLM_MODEL",
"HINDSIGHT_API_LLM_REASONING_EFFORT",
"HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY",
"HINDSIGHT_API_DATABASE_URL",
"HINDSIGHT_API_MIGRATION_DATABASE_URL",
]
@@ -103,6 +105,48 @@ def test_valid_retain_config_succeeds():
assert config.retain_chunk_size == 3000
def test_semantic_min_similarity_reads_from_env():
"""Semantic retrieval min similarity can be configured at the server level."""
from hindsight_api.config import HindsightConfig
os.environ["HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY"] = "0.58"
config = HindsightConfig.from_env()
assert config.semantic_min_similarity == 0.58
def test_semantic_min_similarity_must_be_between_zero_and_one():
"""Invalid semantic min similarity fails fast during configuration loading."""
from hindsight_api.config import HindsightConfig
os.environ["HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY"] = "1.5"
with pytest.raises(ValueError, match="semantic_min_similarity"):
HindsightConfig.from_env()
def test_consolidation_max_completion_tokens_defaults_to_unset():
"""By default consolidation sends no explicit output budget (backwards compatible)."""
from hindsight_api.config import HindsightConfig
os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock"
os.environ.pop("HINDSIGHT_API_CONSOLIDATION_MAX_COMPLETION_TOKENS", None)
config = HindsightConfig.from_env()
assert config.consolidation_max_completion_tokens is None
def test_consolidation_max_completion_tokens_env_override():
"""HINDSIGHT_API_CONSOLIDATION_MAX_COMPLETION_TOKENS controls consolidation LLM output budget."""
from hindsight_api.config import HindsightConfig
os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock"
os.environ["HINDSIGHT_API_CONSOLIDATION_MAX_COMPLETION_TOKENS"] = "8192"
config = HindsightConfig.from_env()
assert config.consolidation_max_completion_tokens == 8192
def test_log_config_masks_database_urls(caplog):
"""Config startup logs must not expose database credentials."""
from hindsight_api.config import HindsightConfig
@@ -376,3 +420,48 @@ def test_llm_reasoning_effort_loaded_from_env(monkeypatch):
config = HindsightConfig.from_env()
assert config.llm_reasoning_effort == "xhigh"
# ---------------------------------------------------------------------------
# Recall candidate gating (BM25 score floor + per-source cap) — issue #1707
# ---------------------------------------------------------------------------
def test_bm25_min_score_defaults_to_zero(monkeypatch):
from hindsight_api.config import HindsightConfig
monkeypatch.delenv("HINDSIGHT_API_BM25_MIN_SCORE", raising=False)
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.bm25_min_score == 0.0
def test_bm25_min_score_loaded_from_env(monkeypatch):
from hindsight_api.config import HindsightConfig
monkeypatch.setenv("HINDSIGHT_API_BM25_MIN_SCORE", "1.5")
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.bm25_min_score == 1.5
def test_recall_max_candidates_per_source_defaults_to_disabled(monkeypatch):
from hindsight_api.config import HindsightConfig
monkeypatch.delenv("HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE", raising=False)
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.recall_max_candidates_per_source == 0
def test_recall_max_candidates_per_source_loaded_from_env(monkeypatch):
from hindsight_api.config import HindsightConfig
monkeypatch.setenv("HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE", "150")
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.recall_max_candidates_per_source == 150
+58 -4
View File
@@ -464,7 +464,7 @@ class TestConsolidationIntegration:
async with memory._pool.acquire() as conn:
observations = await conn.fetch(
"""
SELECT id, text, source_memory_ids, history
SELECT id, text, source_memory_ids
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
""",
@@ -3040,10 +3040,13 @@ def _make_mock_llm_one_obs_per_fact():
def callback(messages, scope):
if scope != "consolidation":
return _ConsolidationBatchResponse()
# Parse all fact UUIDs from the prompt — one create per fact
# Parse all fact UUIDs from the prompt — one create per fact. Read only
# the user message(s): consolidation sends the facts there, while the
# stable (cacheable) system message carries example UUIDs in its OUTPUT
# FORMAT samples that must not be mistaken for real facts.
import re
prompt = messages[0]["content"] if messages else ""
prompt = "\n".join(m.get("content", "") for m in messages if m.get("role") == "user")
fact_ids = re.findall(r"\[([0-9a-f-]{36})\]", prompt)
creates = [_CreateAction(text=f"Observation about fact {fid[:8]}", source_fact_ids=[fid]) for fid in fact_ids]
return _ConsolidationBatchResponse(creates=creates)
@@ -3145,7 +3148,9 @@ async def test_max_observations_per_scope_allows_updates_at_capacity(memory: Mem
call_count += 1
import re
prompt = messages[0]["content"] if messages else ""
# Facts live in the user message; the system message (stable, cached)
# carries example UUIDs in its OUTPUT samples — read user only.
prompt = "\n".join(m.get("content", "") for m in messages if m.get("role") == "user")
fact_ids = re.findall(r"\[([0-9a-f-]{36})\]", prompt)
if call_count == 1 and fact_ids:
# First call: create an observation
@@ -3512,3 +3517,52 @@ async def test_enable_auto_consolidation_flag(memory: MemoryEngine, request_cont
finally:
memory._config_resolver._global_config = original_global_config
await memory.delete_bank(bank_id, request_context=request_context)
def test_consolidation_prompt_split_is_cacheable_and_complete():
"""The split consolidation prompt: bank-agnostic system prefix + per-batch user.
The system prefix must be byte-identical across batches AND across banks (the
property that lets a single Gemini context cache serve every bank), carry only
stable instructions, and the per-batch/per-bank data (mission, facts,
observations, capacity note) must live in the user message never in the
cached prefix.
"""
from hindsight_api.engine.consolidation.prompts import (
build_consolidation_input,
build_consolidation_system_prompt,
)
sys_prompt = build_consolidation_system_prompt()
# Byte-stable across calls and independent of any mission → one cache for all banks.
assert sys_prompt == build_consolidation_system_prompt()
# Instructions only: no per-batch placeholders leaked into the prefix.
assert "{facts_text}" not in sys_prompt
assert "{observations_text}" not in sys_prompt
# JSON examples are unescaped (single braces), i.e. .format() ran.
assert '{"creates"' in sys_prompt
assert "{{" not in sys_prompt
# The stable observation-format boilerplate lives in the cached prefix.
assert "proof_count" in sys_prompt
# Two banks with DIFFERENT missions share the identical cached prefix; the
# mission rides in the per-batch user message instead.
user_a = build_consolidation_input(
facts_text="[id-a] Fact A.", observations_text="[]", observations_mission="Track widgets."
)
user_b = build_consolidation_input(
facts_text="[id-b] Fact B.", observations_text="[]", observations_mission="Track gadgets."
)
assert "Track widgets." in user_a
assert "Track widgets." not in sys_prompt # mission NOT in the cached prefix
assert "Fact A." in user_a
assert user_a != user_b
# The format boilerplate is NOT re-sent per batch (it's in the cached prefix).
assert "proof_count" not in user_a
# The capacity note is per-batch too — kept out of the cached prefix.
capped = build_consolidation_input(
facts_text="[id] F.", observations_text="[]", observation_capacity_note="OBSERVATION LIMIT REACHED"
)
assert "OBSERVATION LIMIT REACHED" in capped
assert "OBSERVATION LIMIT REACHED" not in sys_prompt
@@ -0,0 +1,259 @@
"""Deterministic unit tests for the consolidation duplicate-create guard.
These exercise the dedup decision directly (no LLM, no DB), so they reliably
guard the fix in CI unlike the real-LLM integration test, which only triggers
the path stochastically.
"""
import types
import uuid
from dataclasses import dataclass
from unittest.mock import AsyncMock, patch
from hindsight_api.engine.consolidation.consolidator import (
_dedup_active,
_dedup_reconcile_create,
_dedup_reconcile_update,
_DedupDecision,
_duplicate_create_target,
_norm_obs_text,
)
from hindsight_api.engine.search.types import RetrievalResult
@dataclass
class _FakeObs:
id: str
text: str
def _shown(*observations: _FakeObs) -> dict[str, _FakeObs]:
return {_norm_obs_text(o.text): o for o in observations}
def test_norm_obs_text_collapses_whitespace_preserves_case() -> None:
# Whitespace (incl. newlines) collapses; case is preserved.
assert _norm_obs_text(" The User likes BASIL.\n") == "The User likes BASIL."
assert _norm_obs_text(None) == ""
def test_create_matching_shown_observation_is_duplicate() -> None:
shown = _shown(_FakeObs(id="11111111-aaaa", text="User waters the herbs early in the morning."))
# Same text with only-whitespace differences still matches.
target = _duplicate_create_target("User waters the herbs early in the morning.", shown, set())
assert target is not None
assert target.startswith("shown observation 11111111")
def test_create_differing_only_in_case_is_not_duplicate() -> None:
# Case-folding would lose information (e.g. acronyms), so a case-only difference
# is treated as novel rather than silently dropped.
shown = _shown(_FakeObs(id="22222222-bbbb", text="The user prefers TLS."))
assert _duplicate_create_target("The user prefers tls.", shown, set()) is None
def test_create_matching_inresponse_update_is_duplicate() -> None:
update_texts = {_norm_obs_text("Mint is kept in its own separate bed.")}
target = _duplicate_create_target("Mint is kept in its own separate bed.", {}, update_texts)
assert target == "an UPDATE in this response"
def test_novel_create_is_not_duplicate() -> None:
shown = _shown(_FakeObs(id="22222222-bbbb", text="User waters the herbs early in the morning."))
assert _duplicate_create_target("Rosemary is drought-tolerant.", shown, set()) is None
assert _duplicate_create_target("", {}, set()) is None
# ── semantic dedup (_dedup_reconcile_create) ──────────────────────────────────
#
# Mocks the embedder, the obs-anchored ANN probe, and the LLM so the decision logic is
# tested without a DB or a real model.
_TWIN_ID = "33333333-3333-4333-8333-333333333333"
def _obs(text: str, sim: float, oid: str = _TWIN_ID) -> RetrievalResult:
return RetrievalResult(id=oid, text=text, fact_type="observation", similarity=sim)
def _ctx(threshold: float = 0.97):
"""Return (kwargs, conn_mock, llm_mock) for a _dedup_reconcile_create call."""
conn = AsyncMock()
llm = types.SimpleNamespace(call=AsyncMock())
kwargs = dict(
conn=conn,
memory_engine=types.SimpleNamespace(embeddings=object()),
bank_id="bank1",
config=types.SimpleNamespace(consolidation_dedup_threshold=threshold),
dedup_llm_config=llm,
create_text="YouTube content in Uzbek is very rich.",
create_source_ids=[uuid.uuid4()],
tags=["t1"],
)
return kwargs, conn, llm
def _patch_probe(results):
return patch(
"hindsight_api.engine.search.retrieval.retrieve_semantic_bm25_combined",
AsyncMock(return_value={"observation": (results, [])}),
)
def _patch_embed():
return patch(
"hindsight_api.engine.retain.embedding_utils.generate_embeddings_batch",
AsyncMock(return_value=[[0.1, 0.2, 0.3]]),
)
async def test_dedup_no_twin_above_threshold_returns_none() -> None:
kwargs, conn, llm = _ctx(threshold=0.97)
with _patch_embed(), _patch_probe([_obs("something loosely related", 0.81)]):
result = await _dedup_reconcile_create(**kwargs)
assert result is None
llm.call.assert_not_called() # below threshold → no LLM call
conn.execute.assert_not_called() # no merge
async def test_dedup_llm_keep_does_not_merge() -> None:
kwargs, conn, llm = _ctx()
llm.call.return_value = _DedupDecision(action="keep", reason="different language")
with _patch_embed(), _patch_probe([_obs("Uzbek content on YouTube is described as very rich.", 0.98)]):
result = await _dedup_reconcile_create(**kwargs)
assert result is None
llm.call.assert_awaited_once()
conn.execute.assert_not_called() # kept distinct → no merge
async def test_dedup_llm_merge_folds_into_twin() -> None:
kwargs, conn, llm = _ctx()
kwargs["create_source_ids"] = [uuid.uuid4(), uuid.uuid4()]
llm.call.return_value = _DedupDecision(action="merge", text="Uzbek content on YouTube is very rich.")
with _patch_embed(), _patch_probe([_obs("Uzbek content on YouTube is described as very rich.", 0.99)]):
result = await _dedup_reconcile_create(**kwargs)
assert result == _TWIN_ID # merged into the twin; caller skips the CREATE
conn.execute.assert_awaited_once()
args = conn.execute.await_args.args
assert args[1] == "Uzbek content on YouTube is very rich." # merged text persisted
assert args[2] == kwargs["create_source_ids"] # new source facts folded in
assert args[3] == uuid.UUID(_TWIN_ID) # onto the twin row
async def test_dedup_picks_highest_above_threshold_skips_below() -> None:
# Only the >=threshold candidate is considered; a 0.95 result is ignored at threshold 0.97.
kwargs, conn, llm = _ctx(threshold=0.97)
llm.call.return_value = _DedupDecision(action="keep")
with _patch_embed(), _patch_probe([_obs("near but distinct", 0.95), _obs("the real twin", 0.98)]):
await _dedup_reconcile_create(**kwargs)
# the twin passed to the LLM is the >=0.97 one, not the 0.95
sent = llm.call.await_args.kwargs["messages"][0]["content"]
assert "the real twin" in sent
assert "near but distinct" not in sent
# ── UPDATE-path dedup (_dedup_reconcile_update) ───────────────────────────────
#
# An UPDATE rewrites+re-embeds an observation, which can drift it into a near-twin of a
# DIFFERENT existing observation. These cover the fold-and-delete reconciliation (unlike
# CREATE, both rows already exist), the self-exclusion, and the keep/no-twin no-ops.
_UPDATED_ID = "44444444-4444-4444-8444-444444444444"
def _update_ctx(threshold: float = 0.97):
"""Return (kwargs, conn_mock, llm_mock) for a _dedup_reconcile_update call."""
conn = AsyncMock()
llm = types.SimpleNamespace(call=AsyncMock())
kwargs = dict(
conn=conn,
memory_engine=types.SimpleNamespace(embeddings=object()),
bank_id="bank1",
config=types.SimpleNamespace(consolidation_dedup_threshold=threshold),
dedup_llm_config=llm,
updated_id=_UPDATED_ID,
updated_text="Uzbek content on YouTube is very rich and growing.",
updated_emb_str="[0.1, 0.2, 0.3]", # already embedded by _execute_update_action
tags=["t1"],
)
return kwargs, conn, llm
async def test_dedup_update_merge_folds_into_twin_and_deletes_updated() -> None:
kwargs, conn, llm = _update_ctx()
llm.call.return_value = _DedupDecision(action="merge", text="Uzbek YouTube content is very rich and growing.")
with _patch_probe([_obs("Uzbek content on YouTube is described as very rich.", 0.98)]):
await _dedup_reconcile_update(**kwargs)
llm.call.assert_awaited_once()
# Two writes: fold-into-twin UPDATE, then DELETE of the updated row.
assert conn.execute.await_count == 2
fold_args = conn.execute.await_args_list[0].args
assert fold_args[1] == "Uzbek YouTube content is very rich and growing." # merged text on the twin
assert fold_args[2] == uuid.UUID(_TWIN_ID) # survivor = the twin
assert fold_args[3] == uuid.UUID(_UPDATED_ID) # folded-from = the updated row
delete_args = conn.execute.await_args_list[1].args
assert delete_args[1] == uuid.UUID(_UPDATED_ID) # the updated row is deleted
async def test_dedup_update_keep_does_not_merge() -> None:
kwargs, conn, llm = _update_ctx()
llm.call.return_value = _DedupDecision(action="keep", reason="different growth claim")
with _patch_probe([_obs("Uzbek content on YouTube is described as very rich.", 0.98)]):
await _dedup_reconcile_update(**kwargs)
llm.call.assert_awaited_once()
conn.execute.assert_not_called() # kept distinct → neither fold nor delete
async def test_dedup_update_excludes_self() -> None:
# The probe surfaces the updated observation itself at 1.0; it must be excluded so we don't
# "merge" a row into itself. With no other candidate, there is no twin → no LLM, no writes.
kwargs, conn, llm = _update_ctx()
with _patch_probe([_obs("its own current text", 1.0, oid=_UPDATED_ID)]):
await _dedup_reconcile_update(**kwargs)
llm.call.assert_not_called()
conn.execute.assert_not_called()
async def test_dedup_update_no_twin_above_threshold() -> None:
kwargs, conn, llm = _update_ctx(threshold=0.97)
with _patch_probe([_obs("loosely related", 0.8)]):
await _dedup_reconcile_update(**kwargs)
llm.call.assert_not_called()
conn.execute.assert_not_called()
# ── dedup activation gate (_dedup_active) ─────────────────────────────────────
#
# Enabled by default (threshold < 1.0), but skipped on Oracle because the merge path is
# Postgres-only — so the feature can ship on-by-default without breaking Oracle.
def _gate_cfg(threshold: float):
return types.SimpleNamespace(consolidation_dedup_threshold=threshold)
def _patch_backend(name: str):
return patch(
"hindsight_api.engine.consolidation.consolidator.get_config",
return_value=types.SimpleNamespace(database_backend=name),
)
def test_dedup_active_enabled_on_postgres() -> None:
with _patch_backend("postgresql"):
assert _dedup_active(_gate_cfg(0.97)) is True
def test_dedup_active_disabled_when_threshold_is_one() -> None:
with _patch_backend("postgresql"):
assert _dedup_active(_gate_cfg(1.0)) is False
def test_dedup_active_skipped_on_oracle() -> None:
# PG-only merge path → dedup is skipped on Oracle even with a sub-1.0 threshold.
with _patch_backend("oracle"):
assert _dedup_active(_gate_cfg(0.97)) is False
def test_dedup_active_none_config() -> None:
assert _dedup_active(None) is False
@@ -0,0 +1,41 @@
import uuid
import pytest
from hindsight_api.engine.consolidation import consolidator
class _ZeroLengthEmbeddings:
dimension = 384
def encode_documents(self, texts):
assert texts == ["Consolidated observation text."]
return [[]]
class _FakeMemoryEngine:
embeddings = _ZeroLengthEmbeddings()
class _FailingConn:
async def fetchrow(self, *args, **kwargs):
raise AssertionError("zero-length embedding should be rejected before database insert")
@pytest.mark.asyncio
async def test_create_observation_rejects_zero_length_embedding_before_insert(monkeypatch):
source_id = uuid.uuid4()
async def fake_filter_live_source_memories(conn, bank_id, source_memory_ids):
return source_memory_ids
monkeypatch.setattr(consolidator, "_filter_live_source_memories", fake_filter_live_source_memories)
with pytest.raises(RuntimeError, match="embedding 0 has dimension 0; expected 384"):
await consolidator._create_observation_directly(
conn=_FailingConn(),
memory_engine=_FakeMemoryEngine(),
bank_id="test-bank",
source_memory_ids=[source_id],
observation_text="Consolidated observation text.",
)
@@ -371,9 +371,7 @@ class TestRecoverConsolidation:
mem_id,
)
result = await memory_no_llm_verify.retry_failed_consolidation(
bank_id, request_context=request_context
)
result = await memory_no_llm_verify.retry_failed_consolidation(bank_id, request_context=request_context)
assert result["retried_count"] == 2
@@ -394,9 +392,7 @@ class TestRecoverConsolidation:
bank_id = f"test-recover-zero-{uuid.uuid4().hex[:8]}"
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
result = await memory_no_llm_verify.retry_failed_consolidation(
bank_id, request_context=request_context
)
result = await memory_no_llm_verify.retry_failed_consolidation(bank_id, request_context=request_context)
assert result["retried_count"] == 0
@@ -410,14 +406,10 @@ class TestRecoverConsolidation:
async with memory_no_llm_verify._pool.acquire() as conn:
(mem_id,) = await _insert_memories(conn, bank_id, ["Grace is an expert rock climber."])
await conn.execute(
"UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1", mem_id
)
await conn.execute("UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1", mem_id)
# Recover
recover_result = await memory_no_llm_verify.retry_failed_consolidation(
bank_id, request_context=request_context
)
recover_result = await memory_no_llm_verify.retry_failed_consolidation(bank_id, request_context=request_context)
assert recover_result["retried_count"] == 1
# Now consolidate with a healthy LLM
@@ -460,9 +452,7 @@ class TestRecoverConsolidation:
["Henry is a professional chef.", "Henry trained at Le Cordon Bleu."],
)
for mem_id in ids:
await conn.execute(
"UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1", mem_id
)
await conn.execute("UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1", mem_id)
app = create_app(memory_no_llm_verify, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
@@ -80,9 +80,7 @@ async def _pending_consolidation_ops(memory, bank_id: str) -> list[str]:
@pytest.mark.asyncio
async def test_round_limited_consolidation_leaves_followup_pending_op(
memory: MemoryEngine, request_context
):
async def test_round_limited_consolidation_leaves_followup_pending_op(memory: MemoryEngine, request_context):
"""A round-limited consolidation must leave a new ``pending`` consolidation
op in ``async_operations`` for the same bank so the worker poller can
drain the backlog without external intervention."""
@@ -147,9 +145,7 @@ async def test_round_limited_consolidation_leaves_followup_pending_op(
op_id,
)
assert row is not None
assert row["status"] == "completed", (
f"first consolidation op should be marked completed, got {row['status']}"
)
assert row["status"] == "completed", f"first consolidation op should be marked completed, got {row['status']}"
# 4. Backlog must remain (round limit kept one round under the total)
unconsolidated_after = await _count_unconsolidated(memory, bank_id)
@@ -169,8 +165,6 @@ async def test_round_limited_consolidation_leaves_followup_pending_op(
f"backlog. Found {len(pending_ops)} pending ops; backlog still has "
f"{unconsolidated_after} unconsolidated memory_units."
)
assert pending_ops[0] != str(op_id), (
"The pending op must be a NEW row, not the original op we just executed."
)
assert pending_ops[0] != str(op_id), "The pending op must be a NEW row, not the original op we just executed."
await memory.delete_bank(bank_id, request_context=request_context)

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