Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 0022d427d3 feat(integrations): add hindsight-opencode-coding plugin
Reflect-only long-term memory for coding agents in OpenCode, with a git+chat
backfill and (opt-in) live session write-back.

- reflect + INJECT: on a task, reflect() the symptom and push the root-cause
  answer into the system prompt (no tools/recall).
- backfill: every commit (full message + full diff, commit timestamp + git
  metadata) under a 'git' retain strategy; each chat as a JSON user/assistant
  transcript with custom extraction (<=2 coherent facts) under a 'chat' strategy;
  observations on; optional codebase knowledge pages.
- live write-back (opt-in HINDSIGHT_RETAIN_SESSIONS): every N turns upsert the
  tool-filtered transcript under a stable conversation:<sessionID> document_id.
2026-07-02 13:55:40 +02:00
Nicolò Boschi 1f9bad0858 feat(knowledge-base): default pages to living-document trigger + 4096 tokens
Client-created pages had no server curation applying a trigger, so they fell back
to the plain mental-model default (no refresh, full mode, all fact types). Make a
knowledge page a living document by default: when the client omits `trigger`, use
observation-only + delta + exclude_mental_models + refresh_after_consolidation;
when it omits `max_tokens`, default to 4096 (vs the mental-model 2048). Clients
can still override either.
2026-07-02 10:30:40 +02:00
Nicolò Boschi 138bf02f29 refactor(knowledge-base): drop server-side curation + folder missions
The knowledge base is now purely client-managed (CRUD over folders/pages); the
server no longer auto-curates. Removes the folder curator entirely and the
folder `mission` concept, and leads the sidebar with Knowledge Base.

- Remove engine/knowledge_curator.py, the curate_folder task (handler + dispatch
  + submit_async_curate_folder / _bank_folders), the post-consolidation curation
  hook, and the folder-create / mission-update curation triggers.
- Remove folder `mission` and `last_curated_at` (columns + engine + API + UI);
  keep `managed` as a client-set flag. Migration a5b6 now adds `managed` only;
  the last_curated_at migration is dropped and the unique-index migration
  repointed. Single alembic head preserved.
- API: KnowledgeNode/CreateFolderRequest/UpdateNodeRequest lose `mission`;
  PATCH node handles name/parent_id only.
- Control plane: sidebar leads with Knowledge Base (before Memories); remove the
  mission field, edit-mission dialog, and mission display from the KB view.
- Delete the curator tests; regenerate OpenAPI + SDK clients.
2026-07-02 10:30:39 +02:00
Nicolò Boschi df178aae8a refactor(hindsight-fs): mirror the knowledge-base tree, not mental models
Re-point hindsight-fs at the knowledge base so it projects a bank's folder/page
hierarchy as nested directories + .md files, instead of a flat list of mental
models.

- client: fetch GET /knowledge-base/tree + /export (two calls, any bank size)
  and join by page id; replaces the paginated mental-models list.
- format: planMirror() walks the tree into folder dirs + page files at nested
  paths (slug per segment, collision-safe); pages render the page's OKF doc.
- sync: create folder dirs, write pages at nested paths, prune removed pages and
  emptied folders; state keyed by relative path + tracked dirs.
- config/cli: drop the mental-model `detail` flag; `list` prints folders+pages;
  help/README updated. Tests rewritten for the tree/export model.

Verified live against a bank's knowledge base: the `people` folder mirrors to
people/anna.md + people/marco.md with OKF frontmatter.
2026-07-02 10:30:39 +02:00
Nicolò Boschi a43026b8f4 feat(hindsight-fs): mirror a bank's mental models as a live local folder
Add @vectorize-io/hindsight-fs, a CLI under hindsight-tools/ that mirrors a
Hindsight bank's mental models as real markdown files (YAML frontmatter + body)
in a local directory, refreshed from the API on an interval. Once mounted,
ordinary shell tools (ls, cat, grep, find, ...) work against current memory.

- Pull-based sync engine: full list each tick, write changed/new/tampered
  files, skip unchanged (content-hashed), prune deleted models. Atomic writes;
  a transient API error never wipes the mirror.
- One-way mirror enforced two ways: files are read-only (0444) so agent edits
  fail with EACCES, plus a tamper-revert backstop that compares on-disk bytes
  and overwrites drift on the next pass. --writable opts out.
- Commands: mount/start/stop/restart/sync/status/list/logs/unmount. Background
  daemon via detached process + pidfile; per-mount config is remembered.
- status doubles as a healthcheck: --json report and a non-zero exit when the
  mount is dead/failed/stale (--stale-after overrides the threshold).
- Tests: unit (sync engine, frontmatter, health) + e2e that spawns the real
  CLI against a mock API and exercises real bash commands. 26 tests.
2026-07-02 10:30:39 +02:00
Nicolò Boschi 5c425e276e feat(knowledge-base): self-curating knowledge base (OKF pages + folder missions)
Server-side knowledge base: a hierarchy of folders and pages over mental
models, projected to the Open Knowledge Format, with a mission-driven curator
that maintains pages automatically after each consolidation.

- knowledge_pages table (PG + Oracle): parent_id tree, kind folder/page,
  mission, managed, last_curated_at; partial unique index on (folder, name)
  for concurrency-safe dedup; added to BACKUP_TABLES.
- api/okf.py: OKF serializer (frontmatter + body, index/log, constellation graph).
- engine/knowledge_curator.py: folder curator (LLM op plan + safe apply); reads
  new memories since last curation (delta, not recall); ops create/merge/delete
  page + spawn sub-folder (bounded depth<=3, <=8). Runs as an async curate_folder
  task on folder/mission create and after consolidation. Curator pages use an
  observation-only delta trigger with exclude_mental_models.
- MemoryEngine: folder/page CRUD, tree, curate, async submit + worker handler.
- /v1/default/banks/{bank}/knowledge-base/* endpoints.
- Control plane: knowledge-base tree view + constellation toggle, missions,
  OKF page panel + bundle export; proxies, client, sidebar, i18n.
- Tests: okf unit, knowledge-base HTTP, curator apply + dedup guard, hs_llm_core e2e.
- Regenerated OpenAPI + SDK clients + docs-skill.
2026-07-02 10:30:39 +02:00
Nicolò Boschi 265192e509 docs: restore audio in v0.8.4 release-notes video 2026-07-01 14:26:54 +02:00
Nicolò Boschi 8f2cee4568 docs: changelog and blog post for v0.8.4 (#2474)
* docs: changelog and blog post for v0.8.4

* docs: add compressed release-notes video for v0.8.4 blog
2026-07-01 14:17:12 +02:00
Nicolò Boschi 92f433c904 Release v0.8.4
- Update version to 0.8.4 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.8
2026-07-01 13:43:13 +02:00
Parafee41 f8ce15b9bf show full memory details in explorer (#2490) 2026-07-01 13:37:08 +02:00
Nicolò Boschi d68f618969 feat(stats): distributed bank_stats cache + ?refresh param + stats perf suite (#2495)
* feat(stats): distributed (table-backed) bank_stats cache on PostgreSQL

get_bank_stats aggregates over memory_links/unit_entities — a multi-second scan
on large banks. It was cached per-process (in-memory), so every API worker
recomputed once per TTL and the first caller after expiry stalled.

Add a bank_stats_cache table and a DistributedBankStatsCache that shares one
worker's computation across all workers. Same get_or_load/invalidate contract as
the in-memory cache, so the hot path is a single PK SELECT on a hit; only a miss
runs the existing _compute_bank_stats loader and UPSERTs the row (ON CONFLICT,
no lock — concurrent misses recompute, last write wins). All DB touches are
best-effort: an unreachable/missing cache table degrades to computing uncached
rather than failing the endpoint. PostgreSQL only; Oracle keeps the in-memory
cache (selected by dialect at construction).

* feat(stats): add ?refresh query param to force fresh /stats (default off)

Adds force_refresh to get_bank_stats (and both cache backends): when set, the
cached value is bypassed and recomputed, and the fresh result refreshes the
cache for subsequent callers. Exposed on GET /stats as ?refresh=true (default
false). Regenerated OpenAPI spec + clients.

* test(perf): add stats benchmark suite + huge prod-sim scale

New 'stats' perf suite measures get_bank_stats: uncached aggregation latency
(node/link counts + entity rollup) vs cached, run with the result cache disabled
so the headline numbers are the real per-poll cost. Adds a 'huge' prod-simulation
scale that bulk-loads ~500k units / ~17.8M physical memory_links via COPY (entity
links derived from unit_entities, not stored).

* test(stats): exclude bank_stats_cache from backup guard + HTTP refresh test

- bank_stats_cache is a derived TTL cache (no FK to banks, repopulates on
  demand), so exclude it from test_backup_tables_covers_entire_schema rather
  than back up stale cache rows — a restore starts it cold.
- Add a ?refresh=true assertion to the /stats HTTP integration test.

* fix(cli): pass refresh arg to get_agent_stats after ?refresh param

The new /stats ?refresh query param adds a positional arg to the progenitor-
generated get_agent_stats; the CLI reads the cached value, so pass None.
2026-07-01 13:35:54 +02:00
Nicolò Boschi 33e9db64a1 test: fix CI regressions (Vertex/litellmrouter construction, dedup config, trace recorder leak) (#2491)
* test(consolidation): fix dedup merge-path tests missing text-search config

The dedup merge/update path builds a search_vector UPDATE clause from
config.text_search_extension (+ _native_language) since #2425, but the
_dedup_reconcile_create / _dedup_reconcile_update test configs only set
consolidation_dedup_threshold, so the two merge-path tests raised
AttributeError: 'types.SimpleNamespace' object has no attribute
'text_search_extension' on main.

Add the two fields (production defaults native/english) to those configs.
The clause reuses $1, so the existing positional-arg assertions are unchanged.

* test(fact-extraction): pass Vertex AI settings when building LLMConfig

Regression: LLMConfig was refactored to use vertexai_project_id/region/
service_account_key as-passed (the caller resolves the global-config fallback),
but the llm_config fixture never forwarded them. So with the CI provider set to
vertexai, LLMConfig raised "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID is required"
even though the env var was set — the test errored in the test-api job.

Forward the three Vertex settings from config (mirroring MemoryEngine's own
LLMConfig construction). Verified: LLMConfig(provider="vertexai", ...) now
constructs with project_id passed, and still raises when it is omitted.

* test(llm-provider): forward provider-specific settings in _make_llm

_make_llm() built an LLMProvider from the env-selected provider without
forwarding provider-specific settings, so the vertexai and litellmrouter
acceptance-matrix jobs failed at construction ("VERTEXAI_PROJECT_ID is required"
/ "litellmrouter requires a config object"). LLMProvider uses these as-passed
(it does not resolve them from global config), so forward
vertexai_project_id/region/service_account_key and litellmrouter_config.

* test(llm-trace): drop leaked span recorders after each test (#2229)

Root cause of the flaky test_llm_trace::test_disabled_writes_no_rows:
MemoryEngine.__init__ registers its LLM-trace recorder in a process-global
registry, and only close() removes it. Tests that construct an engine directly
(test_per_operation_llm_config, test_llm_reasoning_effort_env, etc.) never
close it, leaking an ENABLED recorder. Locally those recorders' writes fail
(uninitialized backend), but in CI a leaked recorder with a live backend
records a later test's LLM calls into the shared DB — so test_disabled_writes_
no_rows sees rows for its bank even though its own recorder is disabled
(assert N == 0). Reproduced: after test_per_operation_llm_config the registry
holds 8 enabled recorders.

Add an autouse fixture that snapshots the registry and removes anything a test
leaked. Verified: the registry drops from 8 leaked recorders back to 1.
_teardown_memory_engine already guards the fixtures; this guards direct
constructions. 53 trace + leak-risk tests pass together under -n2.
2026-07-01 13:35:22 +02:00
Nicolò Boschi 0c8699dc20 docs: document CODEX_HOME isolation for long-running Codex services (#2496)
Hindsight already honors CODEX_HOME (openai-codex LLM + embeddings), but it
was only mentioned in the 0.8.3 changelog. Long-running services sharing
~/.codex/auth.json with another Codex process can get their refresh token
rotated out, leaving /reflect broken while /health stays green.

Add a 'Isolating Codex auth for long-running services' section to the Models
docs and a pointer next to the openai-codex snippet in configuration.

Refs #2476
2026-07-01 12:04:31 +02:00
Nicolò Boschi 251c451fc3 chore(search): remove HINDSIGHT_API_LAZY_RERANKER flag (#2478)
Lazy reranker init was the only mode in which CrossEncoderReranker.ensure_initialized()
could double-load the model: its check-then-act over the `await` is a real race, but
in the default (eager) path init_cross_encoder() runs at startup — single-threaded,
before any request — so the per-request guard always short-circuits and the window
never opens (see PR #2445 discussion).

Rather than guard the lazy path with a lock, drop the flag entirely. The cross-encoder
is now always initialized eagerly at startup, which removes the race by construction and
the first-recall latency cliff. The only thing the flag bought was skipping an ~80MB
model load for retain-only deployments — not worth the extra config surface and the
concurrency footgun.

- Remove ENV_LAZY_RERANKER, the config field, and from_env() wiring
- Remove the lazy_reranker constructor param; always append init_cross_encoder()
- Drop the now-dead kwarg/env from tests; rename the ensure_initialized timeout tests
- Update docs + regenerate the docs skill mirror

ensure_initialized() is kept as a cheap idempotent guard on the recall path.
2026-07-01 12:01:29 +02:00
Evo 8ed49e4387 fix(retain): honor configured LLM temperature in the batch fact-extraction path (#2469 follow-up) (#2485)
* fix(retain): honor configured LLM temperature in batch fact-extraction

#2469 de-hardcoded the streaming path but the batch _build_request_body still
sent temperature=0.1 unconditionally, so HINDSIGHT_API_LLM_TEMPERATURE=none was
ignored and Azure GPT-5.5 batch retain kept rejecting requests. Omit the field
when the configured retain temperature is None, mirroring LLMProvider.call.

* test(retain): cover batch _build_request_body temperature threading
2026-07-01 11:56:00 +02:00
Parafee41 82afa76182 fix(cli): explore header selection (#2489) 2026-07-01 11:49:25 +02:00
DK09876 4c307ce4e7 release(openhands): v0.1.1 2026-06-30 07:38:51 -07:00
DK09876 252b013243 release(continue): v0.1.1 2026-06-30 07:38:31 -07:00
DK09876 a6b0f82124 release(aider): v0.1.1 2026-06-30 07:33:43 -07:00
Nicolò Boschi a27754fb15 fix(llm): make per-operation temperature configurable (#2459) (#2469)
* fix(llm): make per-operation temperature configurable (#2459)

Internal LLM calls used hardcoded temperatures (verification 0.0, fact
extraction 0.1, reflect thinking 0.9, consolidation 0.0, bank mission 0.3).
Models like Azure gpt-5.5 reject any explicit temperature other than their
default, breaking retain/reflect/verification.

Expose each as an env knob with a global override:
- HINDSIGHT_API_LLM_TEMPERATURE (global) + _VERIFICATION/_RETAIN/_REFLECT/
  _CONSOLIDATION/_MISSION (per-operation override).
- Resolution: per-operation env -> global env -> historical default.
- A value of none/default/off/empty omits the temperature parameter entirely,
  so HINDSIGHT_API_LLM_TEMPERATURE=none fixes gpt-5.5 in one variable.

call() already drops temperature=None across providers, so the None config
value naturally omits the param. Defaults preserve prior behavior exactly
(fully backwards compatible). Server-level/static config.

* test(llm): verify per-operation temperature reaches the LLM call

MockLLM now records the temperature it receives, and a new pipeline test
drives the real engine: retain forwards 0.1 to fact extraction, the reflect
thinking path forwards 0.9, and HINDSIGHT_API_LLM_TEMPERATURE=none omits the
parameter (None) on a live call.

* test(llm): set llm_temperature_retain on the fact-extraction retry mock config

The retry tests build a MagicMock(spec=HindsightConfig); dataclass
annotation-only fields aren't in the spec, so the new llm_temperature_retain
field (now read at the extraction call site) must be set explicitly.
2026-06-30 16:25:14 +02:00
Nicolò Boschi 40fe7aac86 fix(llm): propagate per-scope LLM timeout + retry policy to the provider (#2452) (#2470)
The per-operation LLM request settings were resolved into HindsightConfig but
never reached the provider that uses them, so configuring them was a silent
no-op:

- *_llm_timeout (retain/reflect/consolidation) and the global llm_timeout never
  reached the provider impl; it fell back to HINDSIGHT_API_LLM_TIMEOUT/120s, so
  HINDSIGHT_API_RETAIN_LLM_TIMEOUT=300 did nothing ("LiteLLM call exceeded
  timeout=120.0s").
- reflect_llm_max_retries/initial_backoff/max_backoff and
  consolidation_llm_initial_backoff/max_backoff were never consumed; reflect and
  consolidation used the hardcoded call()/call_with_tools() defaults (10/5),
  ignoring the documented "falls back to llm_max_retries" contract.

Fix: resolve each operation's effective request defaults (per-op override else
global) in MemoryEngine and carry them on the LLMProvider:

- timeout is threaded config -> LLMProvider -> create_llm_provider -> provider
  impl for the providers that honour a configurable request timeout (LiteLLM,
  LiteLLM Router, OpenAI-compatible, Nous). None preserves each provider's own
  default, so Anthropic/Gemini keep their bespoke timeouts and the no-config
  path is byte-identical.
- max_retries/initial_backoff/max_backoff become LLMProvider instance defaults
  that call()/call_with_tools() use when the per-call arg is omitted. Explicit
  per-call args (retain's resolved values, reflect's fast structured-extraction
  path) still win; providers built without config (from_env, tests) keep the
  10/5 method fallback.

The four operation scopes (default/retain/reflect/consolidation) and multi-LLM
chain members all share their operation's resolved values via a small
_LLMCallDefaults bundle.

max_concurrent is intentionally left as-is (process-global semaphores read from
env at startup, server-level only); the docs are clarified to call out that
distinction.

Also fixes a pre-existing breakage in test_llm_router_provider's __new__-based
helper (missing _default_headers after #2466) so the suite is green.

Tests: tests/test_llm_timeout_propagation.py covers provider-impl timeout
threading, the call() retry-policy fallback/override, and per-op
resolution/fallback in MemoryEngine.
2026-06-30 16:25:02 +02:00
Nicolò Boschi 7393400f34 release(openclaw): v0.9.0 2026-06-30 11:25:18 +02:00
3b7d18d474 fix(openclaw): skip synthetic tool_result user messages in sliceLastTurnsByUserBoundary (#2307)
* fix: skip synthetic tool_result user messages in sliceLastTurnsByUserBoundary

OpenClaw normalizes tool_result blocks into role:"user" messages with a
tool_result content block. The sliceLastTurnsByUserBoundary function used
to count every role:"user" message as a turn boundary, causing synthetic
tool_result messages to fill the retention window and exclude actual user
input from retained transcripts.

This change adds a hasRealTextContent guard that skips user messages
containing only tool_result blocks, ensuring only genuine user text is
counted as turn boundaries for both retain and recall window slicing.

Fixes: retained transcripts missing user input when tool calls are present

* fix: skip synthetic tool_result user messages in sliceLastTurnsByUserBoundary

* fix: skip synthetic tool_result user messages in sliceLastTurnsByUserBoundary

* style(openclaw): prettier-format hasRealTextContent block

---------

Co-authored-by: Kumaxs <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-30 11:23:09 +02:00
Nicolò Boschi 6e18858e32 release(cursor-cli): v0.3.0 2026-06-30 11:20:40 +02:00
84e67efbf4 fix(cursor-cli): parse Cursor 3.x role-nested agent transcripts (#2465)
* fix(cursor-cli): parse Cursor 3.x role-nested agent transcripts

Cursor CLI writes agent-transcripts/*.jsonl as
{role, message: {content: [blocks]}} without a top-level type field.
The retain hook's transcript reader only handled flat and type-nested
SDK envelopes, so real transcripts parsed to zero messages and retain
appeared to succeed while storing nothing.

Port the third parser branch from the Cursor editor integration and add
a regression test. Closes the gap flagged as "Should fix #4" during
review of #1975.

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

* feat(cursor-cli): gate text-mode tool markers behind includeTools (default off)

The shared transcript parser surfaced [tool_use]/[tool_result] markers in
the plain-text view, changing what lands in recall queries and light retain.
Gate those markers behind a new includeTools config flag (default off), so
the default light read keeps only natural-language text as before.

Also collapse the now-dead user/assistant event_type branches in the rich
reader (handled by _parse_transcript_entry) and drop the redundant
_extract_text_from_blocks helper, folding the three text/rich finalization
paths into a single _finalize_entry.

---------

Co-authored-by: mutex <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-30 11:19:29 +02:00
ef2e8ab7ff fix(openclaw): apply configured defaults to dynamic banks (#2441)
* fix(openclaw): apply configured defaults to dynamic banks

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

* fix(openclaw): route knowledge tools through identity resolution for user-scoped banks

Knowledge tool factories now use resolveAndCacheIdentity before deriving bank IDs,
matching auto-recall/retain so PluginToolContext sessions hit the correct per-user
bank. Unresolved user identity returns a clear tool error instead of querying
anonymous/openclaw fallbacks, and bank defaults are applied before execution.

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

* fix(agent-sdk): stop mapping max_results to recall max_tokens

NemoClaw passed max_results=25 expecting a result-count cap, but the SDK
used it as max_tokens=25 and starved recall. max_tokens now defaults to
1024 from max_tokens only; max_results slices the results array (1-50).

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

* refactor(openclaw): drop dead alias exports + tighten entityLabels shape

- Remove unused @deprecated hasConfiguredMissions/applyConfiguredMissions
  aliases (new exports nothing imports).
- normalizeEntityLabels now only accepts the server's shapes (a list, or a
  { attributes: [...] } object); a plain keyed object is dropped client-side
  instead of being sent and silently ignored by parse_entity_labels.
- Update docs (types.ts, plugin.json, README) and tests to match.

* fix(agent-sdk): drop unsupported max_results from recall tool

The recall tool's max_results was previously aliased to the recall token
budget (a no-op for result count). Rather than make it a real cap, remove
it entirely — the tool accepts only max_tokens; use recallTopK for an
auto-recall count cap.

Also document the new per-user dynamic bank defaults (retainExtractionMode,
enableObservations, enableAutoConsolidation, dispositions, entityLabels) on
the docs-site OpenClaw page and fix its stale max_results guidance.

---------

Co-authored-by: DK09876 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-30 11:03:26 +02:00
Issam Bousfiha cc45e16904 feat(opencode): add env var overrides for retain and recall options (#2336)
* feat(config): add env var overrides for retain and recall options

Add missing environment variable overrides for configuration options
that were only settable via plugin options or config file:
- HINDSIGHT_RETAIN_EVERY_N_TURNS
- HINDSIGHT_RETAIN_OVERLAP_TURNS
- HINDSIGHT_RECALL_TAGS / HINDSIGHT_RETAIN_TAGS
- HINDSIGHT_RECALL_TAGS_MATCH
- HINDSIGHT_RECALL_PROMPT_PREAMBLE
- HINDSIGHT_RECALL_CONTEXT

* feat(config): add HINDSIGHT_BANK_ID_PREFIX env override

* fix(opencode): rename HINDSIGHT_RECALL_CONTEXT to HINDSIGHT_RETAIN_CONTEXT

The env var HINDSIGHT_RECALL_CONTEXT mapped to retainContext, which
breaks the naming convention where RECALL_* maps to recall* properties
and RETAIN_* maps to retain* properties.
2026-06-30 11:00:54 +02:00
Minghao Xiao 82b01ace5e fix(reflect): unwrap JSON answer envelopes (#2345)
* fix(reflect): unwrap JSON answer envelopes

* fix(reflect): clarify leaked done argument recovery
2026-06-30 10:53:45 +02:00
Parafee41 962140eef6 feat(claude-code): Add recall tag filters to memory hook (#2331)
* Add recall tag filters

* Support per-bank recall tag filters

* Use recall tag config names
2026-06-30 10:53:09 +02:00
EvoandNicolò Boschi 5e73d5ff62 fix(llm): wire default_headers into LiteLLM-backed providers (#2458) (#2466)
* fix(llm): wire default_headers into LiteLLM-backed providers (#2458)

HINDSIGHT_API_LLM_DEFAULT_HEADERS is documented and parsed but only wired
into the Anthropic provider, so it silently no-ops for the litellm /
litellmrouter / bedrock providers -- the proxy-routing providers where
custom headers (auditing, policy, request-tracing) matter most. The
create_llm_provider docstring even noted "other providers may opt in as
needed"; this opts the LiteLLM-backed providers in.

Forward the configured headers to litellm.acompletion via the extra_headers
kwarg, mirroring the existing Anthropic default_headers wiring. setdefault
keeps any explicit per-call extra_headers authoritative, and the dict is
defensively copied on construction and per call to avoid cross-request
contamination. LiteLLMRouterLLM inherits this through its **kwargs forward
to the shared LiteLLM base.

Adds regression tests covering storage, the acompletion extra_headers path,
the no-headers omission, router forwarding, and copy-isolation.

Closes #2458

* fix(llm): forward default_headers from LiteLLM Router call path

The Router subclass overrides _build_common_kwargs without calling super(),
so stored default_headers never reached acompletion for the litellmrouter
provider. Inject extra_headers in the override too, and replace the
storage-only router test with call()-driven coverage.

* style: apply ruff format to migrations.py (pre-existing lint drift)

Newer ruff collapses two multi-line log strings that now fit the line
length. The file was byte-identical to main; this brings it in sync with
the lint gate so verify-generated-files passes.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-30 10:52:26 +02:00
Evoandr266-tech 2c47b8b0d5 docs: document SDK version and MCP metadata helpers (#2291)
Co-authored-by: r266-tech <[email protected]>
2026-06-30 10:51:58 +02:00
Nicolò BoschiandChris Latimer 00968a1ce4 fix(retain): preserve exception message in fact_extraction error summary (#2468)
`test_extraction_failure_at_retry_cap_fails_terminally` (added in #2418,
guarding the recovered-worker path from #2413) asserts that when fact
extraction fails terminally, the original exception message survives
into `async_operations.error_message` so an operator can tell apart a
structured-JSON parse failure from a rate-limit reset from a network
5xx — all of which can surface as the same exception types in different
code paths.

The formatter was joining only `type(err).__name__`, producing rows like
"chunk 0: RuntimeError". The exception message was discarded, leaving
worker failures unactionable and silently defeating the test. The test
ran for the first time on this branch (its original PR's test-api job
was skipped) and surfaced the bug.

Add the message to the summary: "chunk 0: RuntimeError: structured JSON
parse failed after all retain_extract_facts attempts". Same shape, just
the field the test was added to enforce.

Drive-by: pre-existing, unrelated to the include_entity_links work in
this PR — but the test is wired in now and CI won't go green without it.

Co-authored-by: Chris Latimer <[email protected]>
2026-06-30 10:48:39 +02:00
EvoandNicolò Boschi b7080a16cf fix(llm-trace): stash litellm tool-call usage so token cost survives arg-parse failures (#2444)
* fix(llm-trace): stash litellm tool-call usage so token cost survives arg-parse failures (completes #2396)

* test(llm-trace): cover litellm tool-call arg-parse usage stash

Add a real-provider regression test for the fix in this PR: the existing
wrapper-level tools test uses a provider that already stashes, so it does
not guard LiteLLMLLM.call_with_tools. This drives the real provider with a
billed response whose tool arguments are malformed JSON and asserts the
error trace keeps the provider-reported tokens (input/output/cached). The
LiteLLMRouterLLM subclass inherits call_with_tools, so it is covered too.

Verified it fails (input_tokens=None) when the stash line is removed.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-30 10:46:23 +02:00
Nicolò Boschi c0aed313f4 fix(clients): thread recall min_scores through the maintained TypeScript SDK wrapper (#2467) 2026-06-30 10:39:22 +02:00
Nicolò Boschi 2c53629420 release(langgraph): v0.3.0 2026-06-30 10:30:12 +02:00
Parafee41andNicolò Boschi 760bfc7447 fix(langgraph): resolve tool bank IDs from config (#2443)
* fix(langgraph): resolve tool bank IDs from config

* review(langgraph): rename injected config param to avoid shadowing

Rename the injected RunnableConfig tool parameter to runnable_config so it
no longer shadows the outer Hindsight config = get_config().

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-30 10:28:54 +02:00
Evo cce0a2cb39 fix(clients): thread recall min_scores through the maintained Python SDK wrapper (#2446)
#2422 added the public RecallRequest.min_scores (per-stage score floors) to
the HTTP/MCP API and the generated clients, but the hand-maintained
high-level Python wrapper (hindsight_client.recall/arecall) never got it, so
high-level SDK users can't use the feature without dropping to the raw
generated client.

Thread an optional min_scores dict through recall()/arecall() into
RecallRequest, mirroring the existing tag_groups dict->from_dict pattern.
Unknown keys raise ValueError so a misspelled floor fails loud instead of
silently applying no filter. Parity test mirrors
tests/test_recall_prefer_observations.py.

Follow-up to #2422.
2026-06-30 10:23:39 +02:00
Parafee41 1c1cf4ce56 fix(consolidation): default missing dedup action to keep (#2454)
* fix(consolidation): default missing dedup action to keep

* sync generated test formatting
2026-06-30 10:19:48 +02:00
Sanderhoff-alt a99a1ebf9b chore(docs): sync hindsight docs skill references (#2461)
Update generated hindsight-docs skill references with Requesty provider
entries that are already present in the source documentation.

This keeps the generated skill bundle in sync with the docs generator so
pre-commit no longer rewrites these files.
2026-06-30 10:18:32 +02:00
Sanderhoff-alt 12a6739fc9 refactor(extensions): centralize operation names (#2419)
Add PrecheckOperation, BankReadOperation, and BankWriteOperation
StrEnum types for operation validator hook contexts. Use them at
every precheck and validate_bank_read/write call site while
preserving string comparison compatibility for existing extensions.

Tests:
- uv run pytest tests/test_extensions.py -q
- ./scripts/hooks/lint.sh
2026-06-30 10:18:20 +02:00
Sanderhoff-alt d8ee10a78d chore(repo): remove playwright debug artifacts (#2460)
Remove accidentally committed Playwright MCP logs, page snapshots, and
root-level screenshot artifacts.

Ignore future Playwright MCP output so local browser debugging does not
show up as repository changes.
2026-06-30 10:18:05 +02:00
Evo ab01144b26 fix(config): thread groq/openai service_tier into constructed LLM providers (#2438)
HINDSIGHT_API_LLM_GROQ_SERVICE_TIER (default "auto") and
HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER (OpenAI Flex, "50% cheaper") are parsed
into HindsightConfig but were never threaded into any constructed LLM provider.
The per-operation LLMConfig builds in memory_engine.py and LLMProvider.from_env
thread bedrock_service_tier and gemini_service_tier from config, but omitted
groq/openai, so setting either knob was a silent no-op. groq is the default
provider, so the cost-tier control was dead on the default path.

The constructor already accepts both fields and the providers already consume
them (gated on provider == "groq"/"openai"), so this only wires the missing
feed-in from config, mirroring the existing bedrock/gemini lines.
2026-06-30 10:16:59 +02:00
Evo 072b3278ba fix(config): thread groq/openai service_tier into constructed LLM providers (#2438)
HINDSIGHT_API_LLM_GROQ_SERVICE_TIER (default "auto") and
HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER (OpenAI Flex, "50% cheaper") are parsed
into HindsightConfig but were never threaded into any constructed LLM provider.
The per-operation LLMConfig builds in memory_engine.py and LLMProvider.from_env
thread bedrock_service_tier and gemini_service_tier from config, but omitted
groq/openai, so setting either knob was a silent no-op. groq is the default
provider, so the cost-tier control was dead on the default path.

The constructor already accepts both fields and the providers already consume
them (gated on provider == "groq"/"openai"), so this only wires the missing
feed-in from config, mirroring the existing bedrock/gemini lines.
2026-06-30 10:14:56 +02:00
Nicolò Boschi 74e82a3ea8 fix(parsers): handle UTF-8 text files with ASCII prefix in markitdown (#2456)
markitdown samples only the first chunk for charset detection, so a UTF-8
file (e.g. a JSON transcript) with a long ASCII-only prefix is mis-detected
as ASCII. Its JSON/ipynb converter then reads the whole file with the wrong
charset and crashes on the first multibyte byte during converter selection,
before the plain-text converter can run.

Pass an explicit UTF-8 charset hint to markitdown for text-like files whose
bytes are valid UTF-8, sidestepping the faulty detection. Binary and genuinely
non-UTF-8 files fall back to markitdown's own detection.
2026-06-30 10:14:27 +02:00
Evo a5b752a983 docs(api): correct stale memory-type taxonomy in published READMEs (#2447)
The top feature bullet of both primary published packages (hindsight-api
and hindsight-api-slim) says 'World facts, bank actions, and formed
opinions', but the live recall taxonomy is world/experience/observation:
'opinion' was removed (recall now 422-rejects it) and 'bank' was renamed
to 'experience'. Sync the bullet to VALID_RECALL_FACT_TYPES so the first
thing a PyPI/GitHub visitor reads matches the actual API contract.

Scoped to the taxonomy bullet only; opinion *formation* as a behavior is
unchanged.
2026-06-30 10:12:53 +02:00
Parafee41 7b878f89a7 fix(retain): clarify fact type boundary for user rules (#2440)
* clarify retain fact type boundary

* sync generated test formatting
2026-06-30 10:08:55 +02:00
Evo 1b92c8230f docs(api): drop removed 'opinion' fact_type from MemoryFact schema description (#2439)
The `MemoryFact.fact_type` field description still advertises 'opinion' as a
valid value, but it was removed from the fact-type enum: the DB CheckConstraint
and VALID_RECALL_FACT_TYPES now allow only 'world', 'experience', and
'observation', and the API hard-rejects 'opinion'. An SDK/API consumer reading
the response schema is misled into thinking 'opinion' is a real fact_type.

Drop 'opinion' so the schema description matches what the API actually returns
and accepts. Follow-up to the opinion-fact-type cleanup in #2198/#2302/#2335.
2026-06-30 10:08:09 +02:00
Parafee41 0178d91333 docs: fix operation image alt text (#2436)
* docs: fix operation image alt text

* Sync linted slim tests
2026-06-30 10:07:06 +02:00
Evoandr266-tech 017b8d7271 Respect vector extension during migration bootstrap (#2426)
Co-authored-by: r266-tech <[email protected]>
2026-06-30 10:06:29 +02:00
qxxaaandNicolò Boschi 21176f8ee8 Fix(consolidation): populate search_vector on observation INSERT/UPDATE in consolidator (#2425)
* fix: populate search_vector on observation INSERT/UPDATE in consolidator

The consolidator creates and updates observations without populating the
search_vector tsvector column. Under the native text search extension,
this means observations are invisible to BM25 full-text retrieval - the
BM25 arm returns 0 candidates regardless of query content.

Four code paths write observation text to memory_units:
1. _dedup_reconcile_create (merge into existing twin)
2. _dedup_reconcile_update (drift-merge into different twin)
3. _execute_update_action (LLM rewrite of existing observation)
4. _create_observation_directly (new observation INSERT)

None populated search_vector. This patch adds conditional tsvector
generation gated on config.text_search_extension == 'native', matching
the existing pattern in ops_postgresql.insert_facts_batch. Non-native
backends (pg_textsearch, pgroonga, pg_search) continue to leave
search_vector NULL as they index base text columns directly.

The INSERT path (Site 4) splits the existing else branch into an
explicit elif/else to avoid applying native tsvector logic to backends
that don't use it.

Fixes: observations invisible to BM25 retrieval arm.

* Implement test for search vector population in observations

Add test for observation creation with native search vector

* style: run lint

* fix(consolidation): backfill search_vector for existing native observations

The writer fix only populates search_vector for observations created or
updated after deploy. Observations already written under the native
backend keep a NULL search_vector and stay invisible to BM25 until
re-consolidated. Add migration c3f7a1b9d2e4 to backfill them, gated on the
native tsvector column type and scoped to fact_type='observation' with a
NULL search_vector (idempotent). Matches the writer's text-only tsvector
and the configured native language.

* chore: remove accidentally committed git-lfs hooks

post-checkout/post-commit/post-merge/pre-push were git-lfs stubs picked
up from the contributor's local hookspath and committed by mistake. They
are unrelated to this change; the project's real .githooks/pre-commit is
left intact.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-30 10:05:11 +02:00
Ben 74bdfc9475 Blog: Entity resolution in agent memory (#2424)
* Add entity resolution deep-dive blog post

Technical deep-dive on entity resolution in agent memory, grounded in
Hindsight's implementation: name similarity + a co-occurrence graph +
temporal recency (no embeddings/LLM for resolution), the 0.6 merge
threshold, and the conservative-merge design.
2026-06-29 14:22:04 -04:00
Evo b0038e9855 cli: show operation filenames (#2435) 2026-06-29 12:05:05 +02:00
Thibault Jaigu 6eb85570af feat: add Requesty as an OpenAI-compatible provider (#2399)
Requesty is an OpenAI-compatible LLM gateway. This mirrors the existing
OpenRouter named-provider wiring 1:1:

- llm_wrapper.py / openai_compatible_llm.py: add "requesty" to the
  provider lists and a base_url branch -> https://router.requesty.ai/v1
- config.py: default model map (openai/gpt-4o-mini), embeddings env vars,
  dataclass fields, and from_env wiring (REQUESTY_API_KEY fallbacks)
- embeddings.py: requesty branch (same /v1 base) + Supported list
- docs/llmProviders.json: factual provider entries

Tested live against https://router.requesty.ai/v1/chat/completions
(model openai/gpt-4o-mini) -> HTTP 200.
2026-06-29 11:42:48 +02:00
Parafee41 85599f3ef5 Limit reflect structured output retries (#2433) 2026-06-29 10:42:26 +02:00
Evo dd83bffeef docs(recall): align min_scores score field names (#2432)
* docs(recall): align min_scores score field names

* test: apply generated formatting
2026-06-29 10:41:59 +02:00
Evoandr266-tech 911d27fc5f fix(embed): locate pythonw beside installed API script (#2411)
Co-authored-by: r266-tech <[email protected]>
2026-06-29 10:39:03 +02:00
DK09876andClaude Opus 4.8 a0af096081 fix(aider,openhands): close client on exit + OpenHands Docker MCP docs (#2417)
From real-app integration testing:

- aider: close the Hindsight client when the wrapper owns it, so aiohttp no
  longer prints 'Unclosed connector' warnings after aider exits. Test-injected
  clients are left to the caller. Bump 0.1.1.
- openhands: document that the OpenHands Docker app loads MCP from UI settings
  (not the project config.toml), and that the server must be added as a
  Streamable HTTP server (not SSE) reachable via host.docker.internal. Same hint
  printed by 'init'. Bump 0.1.1.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-26 17:27:59 -07:00
DK09876andClaude Opus 4.8 fcb2c958e7 feat(devin-desktop): rename Windsurf→Devin Desktop + fix(continue) thread-safe adapter (#2410)
* feat(devin-desktop): rename windsurf integration to Devin Desktop

Cognition rebranded Windsurf to Devin Desktop (June 2026); Cascade is EOL
July 1. Rename the (unreleased) windsurf integration to devin-desktop before
first publish:

- Package hindsight-windsurf -> hindsight-devin-desktop (module
  hindsight_devin_desktop, CLI hindsight-devin-desktop, DevinDesktopConfig,
  bank default 'devin-desktop', HINDSIGHT_DEVIN_DESKTOP_BANK_ID)
- Rule now writes to .devin/rules/hindsight.md (preferred path) instead of
  the legacy .windsurf/rules/; trigger: always_on unchanged
- MCP config path stays ~/.codeium/windsurf/mcp_config.json (Devin Desktop's
  on-disk data dir, unchanged by the rebrand)
- Official Devin logo; docs + integrations.json + README refreshed with the
  'formerly Windsurf' framing
- Registries updated: test.yml job, release-integration.sh, generate_changelog,
  integrations.json (strict JSON), docs page

26 unit tests + gated live-MCP E2E pass; ruff check+format clean; real-app
smoke against local Hindsight verified (init writes both files; live recall
returns seeded facts).

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

* fix(continue): resolve a fresh Hindsight client per request (thread-safe)

The adapter runs on a ThreadingHTTPServer (one worker thread per request) but
shared a single Hindsight client across all of them. The client's aiohttp
session is bound to the thread/event-loop that first used it, so the first
@hindsight recall worked and every one after threw 'Timeout context manager
should be used inside a task' — Continue then showed an error context item and
the model answered with no memory.

Resolve the client per request (test-injected clients still used as-is), and
close per-request clients in a finally so the fresh aiohttp session doesn't leak
a connector each call. Bump to 0.1.1.

Found via a real in-editor VS Code test. Adds a regression test asserting
per-request client resolution across the threaded server.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-26 17:27:39 -07:00
Nicolò Boschi 758f346d30 feat(recall): structured per-stage scores and two-level min_scores filtering (#2422)
Replace the recall result's single `score` with a `scores` object exposing the
scores from each pipeline stage, and replace the `min_score` request param with
`min_scores`, a per-stage filter that operates at two levels.

Response — each result carries `scores`:
- final     : the value results are ranked by
- reranker  : cross-encoder normalized relevance (null for passthrough rerankers)
- semantic  : raw vector cosine similarity (null if not surfaced semantically)
- text      : raw keyword/BM25 score (null if not surfaced by keyword search)

Per-arm semantic/text scores are aggregated across retrieval arms during RRF /
interleave fusion (ArmScores on MergedCandidate), since fusion otherwise keeps
only the first-seen arm's score per doc.

Request — `min_scores` floors (inclusive, AND-ed, opt-in; default no filtering):
- semantic / text : retrieval-level cutoffs pushed into the SQL arms, overriding
  the global similarity / BM25 minimums for the request (prune before fusion)
- reranker / final: post-query filters on the scored results

There is deliberately no default threshold: the cross-encoder's absolute scores
are reliable for ordering but not calibrated across queries (a clearly-relevant
match can score ~0.001 on one query and ~1.0 on another), so a fixed cutoff would
silently drop good results.

Also surfaces proof_norm in the search trace and reworks the control-plane trace
view to render scores at full precision (no rounding) and show the per-stage
`scores` breakdown; relabels the trace's "CE" column to "reranker score".

Threaded through engine, HTTP, MCP (both recall tools), and the control-plane
proxy; OpenAPI spec, Python/TS/Go/Rust clients, and the docs-skill mirror
regenerated; docs updated.
2026-06-26 17:12:27 +02:00
qxxaa 78d32cd16c fix(retain): merge JSON arrays in append mode to preserve conversation-aware chunking (#2412)
* fix(retain): merge JSON arrays in append mode to preserve conversation-aware chunking

When update_mode=append prepends existing document text as a second
content item, combined_content is built with "\n".join(...). For
conversation-format content (flat JSON arrays of message dicts), this
produces "[...]\n[...]" which is not valid JSON.

On subsequent append cycles, chunk_text() fails to parse the corrupted
original_text. _chunk_jsonl() also rejects it (lines are arrays, not
dicts). The text falls through to RecursiveCharacterTextSplitter, which
splits on sentence boundaries with no awareness of conversation turn
structure. This produces chunks that begin mid-sentence without speaker
attribution, causing the extraction LLM to misattribute statements.

Fix: after the append-mode block assembles contents_dicts with the
existing and new content items, detect when all items are JSON arrays
of dicts and merge them into a single flat array. Non-conversation
content (plain text, JSONL) is unaffected.

close #2409

* Enhance chunking tests for JSON array formats

Add tests for chunking newline-joined and merged JSON arrays.

* Add test for valid JSON in append mode

This test ensures that appending conversation arrays maintains the original_text as a valid flat JSON array after multiple append cycles, preventing degradation of the data structure.

* add missing json import to test_retain_append_mode
2026-06-26 15:28:53 +02:00
Fox Kiester fb475cc5bc docs: add Epimetheus - pi community integration (#2414) 2026-06-26 14:54:26 +02:00
Evo 1621e5d261 docs(mental-models): document scheduled refresh triggers (#2421) 2026-06-26 14:54:03 +02:00
Nicolò Boschi 91e095afa9 fix(control-plane): show pending uploaded documents from server operations (#2420)
Render in-flight and failed file uploads in the Documents view by deriving
them from the server's file_convert_retain operations — no client-side store
or client-generated document ids.

- surface document_id + original_filename on the operations list endpoint
  (already stored in the operation's result_metadata)
- documents-view derives pending/failed rows from those operations, deduped
  against the real document list by document_id, and polls while in-flight
- bridge the brief window where an operation reports completed before the
  document becomes visible in listDocuments, so the row never flickers

Supersedes #2346 (client-side sessionStorage approach). Closes #2314.
2026-06-26 11:46:31 +02:00
Parafee41 815d99f5ba test(worker): cover retry-capped retain extraction failures (#2418) 2026-06-26 10:56:20 +02:00
Ben 2452f72e75 Blog: Zapier persistent memory (#2408)
* Add Zapier persistent memory blog post

Adds the integration walkthrough for the Hindsight Zapier app: persistent
memory for any Zap via Retain/Recall/Reflect actions plus REST-Hook
triggers that start Zaps from memory events.
2026-06-25 14:50:15 -04:00
Nicolò Boschi dae18b1faf feat(mental-models): cron-scheduled mental model refresh (#2377)
Adds a third, independent way to refresh a mental model — on a cron schedule —
alongside the existing auto (refresh_after_consolidation) and manual paths,
driven by the background MaintenanceLoop ticker.

API/engine:
- trigger.refresh_cron (UTC 5-field cron, croniter-validated); mutually
  exclusive with refresh_after_consolidation.
- PG-only discovery routine public.mental_models_with_cron() (migration
  f4d1c2b3a5e6); cron due-ness evaluated in Python, refresh only when stale.
- HINDSIGHT_API_MENTAL_MODEL_REFRESH_TICK_SECONDS check cadence.
- One timing line logged per maintenance sweep.

Control plane:
- Single "Refresh trigger" choice (Manual / On new memories / On a schedule)
  with per-option sub-labels; cron input shown only when scheduled.
- Live cron schedule preview (human-readable + next/upcoming runs, UTC+local).
- "Next refresh" shown next to "last refreshed" in list, dashboard, and dialog.
- Fixed an app-wide off-by-one in formatRelativeTime.

Regenerated OpenAPI + clients + bank-template schema; i18n across all locales.
2026-06-25 18:27:14 +02:00
Nicolò Boschi 6a10b6241d refactor(llm): make LLMProvider constructor config-free; resolve fallbacks at callers (#2405)
The constructor previously reached into global HindsightConfig (via get_config /
_get_raw_config) to backfill any None argument: default_headers,
gemini_safety_settings, gemini_service_tier, prompt_cache_enabled,
litellmrouter_config, and the vertexai project/region/service-account key. That
hidden global read is exactly what made indexed multi-LLM members hard to
configure independently — each #2384/#2401 fix was "thread one more field so an
explicit value can win over the constructor's global fallback."

Remove all of it. The constructor now uses its arguments verbatim (plus pure
normalizations: the Gemini tier parse, the non-Gemini tier reset, the google/
model-prefix strip, and the us-central1 region default). Resolving the
server-level default for an omitted field is the caller's responsibility:

- MemoryEngine's four per-op base builds pass the global LLM config explicitly
  (gemini_safety_settings comes from the raw config since the StaticConfigProxy
  blocks that one bank-configurable field; the rest are static).
- _member_to_llm resolves member-value-or-global for each field, preserving how a
  chain member inherits global defaults.
- LLMProvider.from_env reads the remaining fields straight from os.getenv, staying
  a lightweight env-only loader (no full-config build).

This makes a provider's effective settings a pure function of its arguments,
which is what lets each member of a multi-LLM chain be configured independently.
Behavior is unchanged for single-LLM, member, and from_env paths.

Tests: update the vertexai/gemini-safety unit tests to the explicit-args contract
(they previously fed the constructor via env), and add two tests asserting the
constructor ignores global config for headers/prompt-cache/safety-settings.
2026-06-25 15:03:14 +02:00
Nicolò Boschi 47992d843b feat(config): let multi-LLM members configure litellmrouter config + Vertex SA key (#2401)
Follow-up to #2384. That PR let an indexed multi-LLM member carry its own
Vertex AI project/region, but two parity gaps remained vs the primary provider:

- A `litellmrouter` member had no per-member router config, so it silently fell
  back to the global `HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG` — a chain could not
  fail over between differently-routed LiteLLM routers (same bug class #2384 fixed
  for Vertex).
- A `vertexai` member used only the global service-account key, so cross-project
  failover with distinct credentials was impossible (project/region alone weren't
  enough).

Adds `litellmrouter_config` and `vertexai_service_account_key` to
`LLMMemberConfig`, reads `{prefix}LLM_{n}_LITELLMROUTER_CONFIG` /
`_VERTEXAI_SERVICE_ACCOUNT_KEY` in `_parse_llm_members`, threads both through
`_member_to_llm`, and lets `LLMProvider.__init__` take a per-instance Vertex SA
key (explicit wins, else global fallback). Single-LLM/global behavior unchanged.

Tests: parse (incl. per-op prefix + invalid-JSON), and build-path proving the
member's own values reach `litellm.Router` and the Vertex SDK client. Docs table
updated with the new per-member keys.
2026-06-25 15:00:45 +02:00
Nicolò Boschi 93100ed314 Remove Atlas Cloud section from README
Removed Atlas Cloud promotional content and related instructions from the README.
2026-06-25 14:50:08 +02:00
Nicolò Boschi 01eda51880 fix(llm-trace): keep provider token usage on parse/validation failures (#2387) (#2396)
* fix(llm-trace): keep provider token usage on parse/validation failures (#2387)

When an LLM call succeeds and returns usage but local JSON parsing or
structured-output validation then fails, the failure trace was recorded
with input_tokens=0/output_tokens=0 because response.usage was out of
scope by the time the exception reached the wrapper. Providers still
charge for those tokens, so error rows lost real cost data.

Providers now stash provider-reported usage (LLMResponseUsage) into a
contextvar as soon as a response is in hand, before parse/validate; the
wrapper attaches it to the error trace. Codex/Claude Code (no SDK token
counts) stash the same char/4 estimate their success path already traces.

* test(llm-trace): drive real provider parse/validation failure with mocked SDK

Add tests that exercise the actual OpenAICompatibleLLM structured-output
path through the LLMProvider wrapper with a mocked SDK client returning a
successful usage-bearing response but bad output: a non-JSON body (parse
failure) and schema-mismatched JSON (validation failure) both record the
provider usage on the status=error retain_extract_facts trace. A success
case asserts the same usage flows on the happy path.
2026-06-25 14:39:32 +02:00
Nicolò Boschi e63d028a5a test(openai): set usage.completion_tokens_details in tool-call mocks (#2378) (#2400)
PR #2378 added reasoning-token accounting in OpenAICompatibleLLM that
subtracts thoughts_tokens from output/total. Several tool-call tests build
their mock response with MagicMock() and set only prompt/completion/total
tokens, leaving usage.completion_tokens_details as a truthy auto-MagicMock.
The new code then does arithmetic on a MagicMock and raises TypeError,
failing all test-api shards. Set completion_tokens_details = None in the
affected mock helpers (matching the explicit-field convention already
documented in test_openrouter_null_content).
2026-06-25 13:49:18 +02:00
Nicolò Boschi 58b5677617 release(claude-code): v0.7.2 2026-06-25 13:32:25 +02:00
Nicolò Boschi b6608076ff fix(release): bump marketplace version on claude-code release (#2386) (#2398)
The Claude Code plugin ships via the marketplace manifest, not a package
registry. The integration release (release-integration.sh claude-code) already
bumps plugin.json, but the marketplace manifest carried no version and was
never bumped — so the published catalog never reflected new releases (e.g.
#2066 on Windows).

- add a "version" field to the root .claude-plugin/marketplace.json
- release-integration.sh now bumps it in lockstep with the plugin version when
  releasing claude-code, and commits it
- remove the redundant hindsight-integrations/.claude-plugin/marketplace.json:
  `claude plugin marketplace add vectorize-io/hindsight` only ever reads the
  root manifest (even with --sparse), so the second manifest was never consulted
- drop the stale --sparse install hint from the release-integration workflow

The claude-code release flow is otherwise unchanged — release it as before.
2026-06-25 13:31:00 +02:00
Chris Bartholomew 4fe477eaa3 feat(config): let indexed multi-LLM members configure Vertex AI project/region (#2384)
Indexed multi-LLM members previously carried only provider/api_key/model/
base_url, so a 'vertexai' member could not initialize (its client requires a
project id, and the region defaults to us-central1). That made vertexai
unusable as a member of a failover/round-robin chain.

Add optional vertexai_project_id / vertexai_region to LLMMemberConfig, parse
them from {prefix}LLM_{n}_VERTEXAI_PROJECT_ID / _VERTEXAI_REGION (global and
per-op prefixes), thread them through the member build path, and accept them on
LLMProvider so an explicit per-instance value wins while existing single-LLM
setups still fall back to the global config.
2026-06-25 13:28:39 +02:00
Sanderhoff-alt a7d1f26f98 fix(api): prevent PATCH bank from creating banks (#2391)
Treat PATCH /v1/default/banks/{bank_id} as update-only by using a
non-creating bank profile lookup and returning 404 when the bank is
missing.

Add a regression test proving the endpoint does not create a bank as a
side effect.
2026-06-25 12:31:11 +02:00
Sanderhoff-alt 0673131a80 fix(api): keep dry-run extract from creating banks (#2394)
Use the non-creating bank-profile lookup when dry-run extraction
resolves the optional narrator name. A preview endpoint promises no
persistence, so probing a missing bank must not insert a bank row.

Add a regression test that calls dry-run extraction against a missing
bank and verifies the bank still does not exist afterwards.
2026-06-25 12:30:13 +02:00
Sanderhoff-alt 6e02a0829f fix(hooks): keep uv lockfile frozen during lint (#2397)
Run the pre-commit uv sync and workspace uv run commands with
--frozen so linting uses the checked-in lockfile without rewriting it
during ordinary code changes.

This avoids local uv resolver freshness checks producing unrelated
uv.lock diffs while preserving explicit dependency update workflows.
2026-06-25 12:29:13 +02:00
EvoandNicolò Boschi bc813692c6 fix(openai): propagate reasoning_tokens into TokenUsage for OpenAI-compatible providers (#2378)
* fix(openai): propagate reasoning_tokens into TokenUsage for OpenAI-compatible providers

Follow-up to merged #2356, which shipped TokenUsage.thoughts_tokens but only
wired the gemini provider. The OpenAI-compatible backend (the most-used class:
OpenAI o-series/gpt-5, groq, deepseek-r1, plus NousLLM/FireworksLLM subclasses)
never read completion_tokens_details.reasoning_tokens and never passed
thoughts_tokens, so it reported 0 for every OpenAI-compatible reasoning model.

Extract reasoning_tokens with a 0-safe getattr chain (mirroring the existing
cached_tokens extraction and the gemini wiring) in both call() and
call_with_tools(), and pass thoughts_tokens (plus cached_tokens for
call_with_tools) into TokenUsage / LLMToolCallResult. Providers without
completion_tokens_details (non-reasoning models, Ollama native) keep 0.

Scoped to the OpenAI-compatible provider; anthropic_llm.py folds thinking into
output_tokens with no separate reasoning sub-count, left as optional follow-up.
Adds provider-level regression tests for call() and call_with_tools().

* fix(openai): make output_tokens visible-only so it doesn't double-count reasoning

OpenAI-compatible completion_tokens INCLUDES reasoning_tokens (verified live:
o4-mini completion=83, reasoning=64), but the TokenUsage contract and the
Gemini provider treat output_tokens/total_tokens as visible-only with
reasoning surfaced separately in thoughts_tokens. Subtract thoughts_tokens
from output_tokens (and total_tokens in call()) so cost attribution doesn't
double-count reasoning. Add a convention test pinning the invariant.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-25 11:33:16 +02:00
Nicolò Boschi 701de3293d test: eagerly import torch in conftest to fix shard flake (#2376)
test-api shard 2/3 intermittently failed collection of dozens of tests
with 'RuntimeError: function _has_torch_function already has a docstring'.

Root cause: the first import torch in a worker process happened lazily
from inside concurrent/async code (embeddings.initialize() ->
sentence_transformers -> transformers -> torch, and cross_encoder's
ThreadPoolExecutor). torch/overrides.py's C-level _add_docstr is not
re-entrancy-safe, so under concurrency torch/overrides.py could execute
twice and raise, failing collection of every test on the shard.

Fix: import torch once at conftest import time (single-threaded, before any
event loop or thread pool), so the registration happens exactly once per
xdist worker. Guarded for slim/no-torch environments.
2026-06-25 10:56:53 +02:00
Ben 9dafadc7eb release(eve): v0.1.0 2026-06-24 14:51:08 -04:00
Ben d0b77f5bee feat(eve): add Eve agent-framework MCP connection helper (#2280)
* feat(eve): add Eve agent-framework MCP connection helper

Add @vectorize-io/hindsight-eve: a thin helper that wraps Eve's
defineMcpClientConnection to wire an Eve agent into a Hindsight MCP
server in one line, pre-filling the endpoint, model-facing description,
and bearer auth with env-var defaults (HINDSIGHT_MCP_URL,
HINDSIGHT_API_KEY, HINDSIGHT_MCP_BANK_ID).
2026-06-24 14:48:38 -04:00
DK09876andClaude Opus 4.8 7194f98b19 feat(windsurf): add Windsurf (Codeium) integration via MCP (#2358)
* feat(windsurf): add Windsurf (Codeium) integration via MCP

Config-only CLI that wires the Hindsight MCP server into Windsurf's
~/.codeium/windsurf/mcp_config.json (mcpServers, remote serverUrl + auth
header) and writes an always-on recall/retain rule to
.windsurf/rules/hindsight.md (trigger: always_on). Cascade then has
recall/retain/reflect and uses them automatically.

- hindsight_windsurf: config, mcp_config (strict-JSON parse-or-print),
  rules (dedicated sentinel-marked file), cli (init/status/uninstall)
- 25 unit tests + gated live-MCP-endpoint E2E
- CI job, release + changelog registries, docs page, icon, README row

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

* style(windsurf): apply ruff format to cli.py

lint.sh runs 'ruff format'; collapse the --rules-path add_argument to one
line so verify-generated-files passes.

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

* fix(windsurf): use official Windsurf logo for the integration icon

Replace the placeholder abstract mark with the official Windsurf logo
(simple-icons, CC0), matching the real-brand-logo convention used by the
other integration icons.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-24 09:58:55 -07:00
Derek Bouius 34ba3c676e blog(retain): structuring chat logs for optimal ingestion (#2375)
* blog(retain): structuring chat logs for optimal ingestion

Add a concept guide on shaping conversation transcripts for Hindsight's
retain: one item per conversation (document_id upsert / append), speaker
labels, context-driven world-vs-experience attribution, timestamp
anchoring, and dropping system prompts / injected memories. Grounded in
the retain API docs and de-facto integration conventions.

* blog(retain): add length/latency, streaming, and links/attachments guidance

Incorporate real user Q&A: document length isn't the constraint (the tail
of long transcripts isn't dropped), segment by recall latency not size,
buffer a few turns when streaming (per-user ingest limit), and set
expectations on links (reference text, not fetched) and attachments
(no file ingest; store in S3 and link).
2026-06-24 09:18:04 -04:00
Evo 0379b4c823 fix(deps): raise hindsight-litellm LiteLLM floor (#2382)
* fix(deps): raise hindsight-litellm LiteLLM floor

* fix(deps): raise hindsight-litellm LiteLLM floor
2026-06-24 11:21:02 +02:00
Parafee41 422e0fd809 Warn for unstable standalone worker ids (#2383) 2026-06-24 11:20:35 +02:00
DK09876andClaude Opus 4.8 91bf32842e feat(github-copilot): add GitHub Copilot (VS Code) integration via MCP (#2299)
Adds hindsight-copilot: long-term memory for GitHub Copilot in VS Code, using
Copilot agent mode's native MCP support (HTTP servers) — no bridge.

`hindsight-copilot init`:
- merges a Hindsight HTTP MCP server into .vscode/mcp.json (servers.hindsight),
  JSON-safe (prints a snippet if the file is JSONC), and
- writes a recall/retain rule into .github/copilot-instructions.md, which
  Copilot applies to every chat in the workspace.

Resolves the ask in #1588. Mirrors the Zed/OpenHands MCP-config pattern.

- hindsight_copilot package: config, mcp_config (.vscode/mcp.json writer),
  instructions (copilot-instructions.md rule), cli (init/status/uninstall)
- 25 deterministic tests (mcp.json merge incl. preserving servers/inputs +
  JSONC fallback, instructions rule block) + gated requires_real_llm MCP
  handshake E2E
- CI job, release registration (VALID_INTEGRATIONS + changelog generator),
  docs page, registry entry, icon (octicons), README row

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-23 16:39:12 -07:00
Ben e4afa5a61b blog: Persistent Memory for the Vercel AI SDK in Five Tools (#2374)
* blog: Persistent Memory for the Vercel AI SDK in Five Tools

Add a dedicated integration post for @vectorize-io/hindsight-ai-sdk.
Covers the five memory tools (retain, recall, reflect, getMentalModel,
getDocument), the semantic-vs-infrastructure input split, setup, and
generateText/streamText/ToolLoopAgent/Next.js usage.
2026-06-23 14:37:27 -04:00
Nicolò Boschi 680305aea4 fix(worker): warn when worker_id unset inside a container (#2359) (#2366)
Default worker_id falls back to socket.gethostname(), which inside Docker/
Kubernetes is the random container hostname and changes on every container
recreation. recover_own_tasks() only reclaims tasks whose worker_id matches
the current worker, so tasks left in 'processing' under the old hostname are
never recovered — consolidation and other async ops can get stuck forever.

Add detect_container_runtime() and log a prominent warning at worker start
when HINDSIGHT_API_WORKER_ID is unset and a container runtime is detected,
pointing operators to set a stable worker id.
2026-06-23 17:36:14 +02:00
Nicolò Boschi 9e06237e40 Instrument recall trace so phase metrics account for total duration (#2361) (#2371)
_search_with_retries only recorded ~10-15% of total_duration_seconds as
named phase metrics; the rest sat in un-instrumented blocks (backend
acquisition, combined scoring, chunk/source-fact/entity enrichment,
result serialization). Add a phase metric for each, and split the
combined-scoring work out of the reranking metric (which captured its
duration before scoring ran).

Mark the per-method retrieval splits, pool waits, and trace_finalize as
diagnostic (they overlap parallel_retrieval or fall outside the total
window) so they are excluded from the coverage sum.

Adds test_trace_phase_coverage asserting the non-diagnostic phases sum to
total without exceeding it.
2026-06-23 15:03:42 +02:00
Sanderhoff-alt 8e66c397a9 fix(memory-defense): correct displayed pattern count (#2369) 2026-06-23 15:03:20 +02:00
Nicolò Boschi f7c7a62e5f feat(llm): multi-LLM failover & round-robin via indexed config (#2365)
* feat(llm): multi-LLM failover & round-robin via indexed config

Configure extra LLMs by index (HINDSIGHT_API_LLM_<n>_*) alongside the
unindexed primary, then route across them with HINDSIGHT_API_LLM_STRATEGY
(JSON): {"mode":"failover"} or {"mode":"round-robin"} with optional
per-member "weights" for unbalanced rotation. Each operation can override
the global chain with a RETAIN_/REFLECT_/CONSOLIDATION_ prefix.

A general, provider-agnostic alternative to the LiteLLM Router and a more
extensible replacement for the single-secondary failover approach.

- config.py: LLMMemberConfig/LLMStrategyConfig dataclasses, indexed-member
  + strategy parsing, new HindsightConfig fields (credential, server-level).
- engine/multi_llm.py: MultiLLMProvider mirrors the LLMProvider surface so it
  drops into with_config()/ConfiguredLLMProvider and _provider_impl passthrough;
  smooth weighted round-robin; failover passes through OutputTooLongError and
  cancellation; strict-primary/soft-secondary verify_connection.
- memory_engine.py: _build_llm wraps each of the 4 LLM slots; no-config path
  returns the plain LLMProvider unchanged.

Batch retain runs on the primary member only (documented).

* docs: regenerate hindsight-docs skill reference for multi-LLM config
2026-06-23 14:48:25 +02:00
Nicolò Boschi c056edaa90 chore(embed): sync bundled env.example with repo-root .env.example (#2373)
The Atlas Cloud provider entries were added to the repo-root .env.example
but not re-copied to the embed bundle, failing the
test_bundled_template_matches_repo_root sync test.
2026-06-23 14:30:23 +02:00
Nicolò Boschi 63a92bef5f fix(cli): pass u64 limit/offset to regenerated client (#2370)
The Rust client was regenerated with limit/offset typed as Option<u64>
(unsigned, minimum 0 in the OpenAPI spec), but the api.rs wrappers still
passed Option<i64>, breaking `cargo build` (and the test-rust-cli /
test-doc-examples CI jobs). Cast the values to u64 at each call site
(list_documents, list_memories, list_entities, get_graph, list_tags),
matching the existing pattern already used for list_documents.
2026-06-23 14:11:54 +02:00
Nicolò Boschi 1533c0915d chore(docs-skill): regenerate references for Atlas Cloud provider (#2372)
The Atlas Cloud LLM provider was added to the docs but the generated
docs-skill references were not regenerated, leaving verify-generated-files
red on main. Regenerate models.md and faq.md.
2026-06-23 14:11:51 +02:00
Nicolò Boschi 8eb2937cdb test(graph-maintenance): reproduce concurrent-insert deadlock on the queue (#2368)
Deterministic, DB-level regression guard for the deadlock fixed in #2353.
Two concurrent transactions insert overlapping graph_maintenance_queue keys in
opposite order (with a barrier between the two per-row locks) and Postgres
aborts one with DeadlockDetectedError; the sorted-order companion test shows a
shared lock order eliminates the cycle. Unlike #2353's tests — which only assert
the Python list handed to execute() is sorted — this exercises the actual lock.
2026-06-23 14:11:39 +02:00
Nicolò Boschi d9a372a92e chore(entity-resolver): remove dead resolve_entity/_create_entity/link_unit_to_entity (#2367)
These per-entity methods have no live callers — the retain/PATCH paths all go
through the batched resolve_entities_batch + flush_pending_stats, which already
sort their writes for consistent lock ordering. The dead _create_entity carried
an unsorted 'entities ON CONFLICT ... DO UPDATE' that looked like a concurrent
deadlock site (it isn't, since it's unreachable). Removing the dead code so it
stops misleading readers/reviewers.

_update_cooccurrence is removed too — its only caller was the dead
link_unit_to_entity.
2026-06-23 14:11:34 +02:00
Evoandr266-tech 199ae146ab fix(gemini): avoid duplicate structured schema prompt (#2277)
Co-authored-by: r266-tech <[email protected]>
2026-06-23 14:11:29 +02:00
Chris BartholomewandNicolò Boschi b4874672fa feat(tokens): propagate cached + thoughts tokens through return contexts (#2356)
* feat(tokens): propagate cached + thoughts tokens through return contexts

The Gemini 2.5+ family (and any future provider that combines prompt caching
with reasoning tokens) reports four distinct token counts on every response:

  - prompt_token_count        (total input)
  - candidates_token_count    (visible output)
  - cached_content_token_count (subset of input served from prompt cache)
  - thoughts_token_count      (reasoning tokens, billed at output rate)

The provider already records the last two on the Prometheus
``hindsight.llm.tokens.{cached_input,thoughts}`` counters, but the values
stop at the metrics layer — every return context (TokenUsage,
LLMToolCallResult, TokenUsageSummary, RetainResult) only exposes the
top-level input/output split. As a result:

  * a downstream metering extension can't attribute prompt-cache hit-rate
    per operation (only globally via Prometheus aggregates), and
  * reasoning-token spend is invisible to ``output_tokens`` because the
    provider keeps it out of candidates_token_count. A workload that
    "looks cheap" by visible output can be silently expensive if the
    model is doing long reasoning chains.

This change threads the two fields through end-to-end:

  - ``TokenUsage`` gains ``thoughts_tokens`` (cached_tokens already
    existed); ``__add__`` sums it so multi-iteration agentic-loop
    aggregation works.
  - ``LLMToolCallResult`` gains ``cached_tokens`` + ``thoughts_tokens``.
  - ``TokenUsageSummary`` (returned by ``run_reflect_agent``) gains
    both fields and ``run_reflect_agent`` accumulates them at every
    call site (main tool loop + structured-output extraction + 4
    edge-case completion branches).
  - ``_generate_structured_output`` now returns a 5-tuple
    ``(output, in, out, cached, thoughts)``; the 6 unpack sites in the
    reflect agent are updated together.
  - ``RetainResult`` gains optional ``llm_cached_input_tokens`` and
    ``llm_thoughts_tokens`` fields; ``memory_engine`` populates them
    from the aggregated ``TokenUsage``. Defaults stay ``None`` for
    engines that don't surface the data so existing metering extensions
    are unaffected.
  - The Gemini provider — which was already reading the four token
    counts from the SDK response — now returns ``thoughts_tokens`` on
    both the ``call`` and ``call_with_tools`` paths, and the existing
    ``cached_input_tokens`` value reaches ``LLMToolCallResult``.

Backward compatibility: every new field defaults to 0 (or None for the
RetainResult dataclass), so any caller built before this change keeps
working. Provider impls that don't surface these counts simply propagate
zeros — the structured Prometheus counters were already optional in
``record_llm_call``.

Adds focused tests (``test_token_usage_cached_thoughts.py``, 6 cases)
pinning the propagation through every return type and the aggregation
behavior. Existing reflect-agent + Gemini provider tests (87 cases) pass
unchanged.

This is a pure plumbing change — no metrics are renamed, no behavior is
gated, no flags are added.

* chore: regenerate clients + openapi spec for thoughts_tokens field

Picks up the new TokenUsage.thoughts_tokens field added in the parent
commit. Generated by:

  ./scripts/generate-openapi.sh
  ./scripts/generate-clients.sh

Plus ``ruff format`` over the two reflect/ source files to match the
project's enforced formatting style.

No hand edits in any generated file.

* chore: regenerate skills/hindsight-docs/references/openapi.json

* fix(reflect): return StructuredOutputResult instead of widened tuple

_generate_structured_output's return contract had drifted: the success
and no-fields branches returned a 5-tuple while the except branch still
returned a 3-tuple. All six call sites unpack five values, so any
structured-output failure would crash reflect with a ValueError instead
of degrading gracefully.

Replace the multi-item tuple return with a typed StructuredOutputResult
(per project rule: no multi-item tuple returns), making the arity
mismatch impossible and the failure path safe. Add a regression test.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-23 13:23:39 +02:00
lucaszhu-hueandClaude Opus 4.8 f8d277697d feat: add Atlas Cloud as an OpenAI-compatible LLM provider (#2362)
Atlas Cloud (https://www.atlascloud.ai) exposes an OpenAI-compatible
chat/completions endpoint, so it slots into the existing
OpenAICompatibleLLM path exactly like deepseek / zai / opencode-go.

Set `HINDSIGHT_API_LLM_PROVIDER=atlas` to route fact extraction,
reflection and consolidation through Atlas Cloud. The base URL defaults
to https://api.atlascloud.ai/v1 and the default model is
deepseek-ai/deepseek-v4-pro (a reasoning model — give it enough
max_tokens, >= 512).

Changes:
- engine/llm_wrapper.py: register "atlas" in create_llm_provider(),
  LLMProvider.valid_providers, and the default base_url map
- engine/providers/openai_compatible_llm.py: register "atlas" in
  valid_providers, default base_url, and the API-key-required check
- config.py: PROVIDER_DEFAULT_MODELS["atlas"] = deepseek-ai/deepseek-v4-pro
- hindsight-embed control center: add Atlas Cloud to the provider wizard
- docs: add Atlas Cloud to llmProviders.json (drives the providers grid
  and table) and a config example in developer/models.mdx
- README + .env.example: document the new provider

Verified end-to-end: instantiated the atlas provider through Hindsight's
own create_llm_provider() and made a live call() to
deepseek-ai/deepseek-v4-pro (HTTP 200, valid content + token usage).

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-23 13:03:16 +02:00
EvoandNicolò Boschi 4db8a12362 fix(http): reject negative limit/offset on list endpoints with 422 instead of raw Postgres 500 (#2357)
* fix(http): reject negative limit/offset on list endpoints with 422 instead of 500

Several user-facing GET list endpoints declared limit/offset without ge
constraints, so a negative value flowed straight into Postgres LIMIT/OFFSET
(emitted with no max(0, ...) clamp), which raises 'LIMIT/OFFSET must not be
negative'. The generic `except Exception -> HTTPException(500, str(e))` then
turned a client input error into a 500 that also leaked the raw Postgres error
string.

Add Query(ge=...) constraints (limit ge=0, offset ge=0) on the affected
endpoints (graph, memories/list, documents, tags, entities, entities/graph),
matching the ge constraints already enforced on the sibling list endpoints
(document-chunks, directives, async-ops, audit) so FastAPI returns a clean 422
at the boundary. ge=0 rejects only negatives and preserves limit=0 (a valid
empty page), so there is no behavior change for any previously-valid request.

* chore: regenerate OpenAPI spec and clients for ge=0 pagination constraints

Adds minimum:0 to limit/offset params across openapi.json, docs-skill spec,
Go openapi.yaml, and Python clients; lint reformats the new test.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-23 11:31:27 +02:00
Chris Bartholomew cabcb3bb0b fix(graph-maintenance): sort unit_ids in enqueue to eliminate insert deadlock (#2353)
`enqueue_graph_maintenance` is called inside the same transaction as the
mutation that produced its `unit_ids` list (see `enqueue_relink_victims`
after a memory update, document delete, etc.). The INSERT it issues takes
a short-lived row-level lock per `(bank_id, unit_id)` for the
unique-key check (`ON CONFLICT DO NOTHING` on Postgres, the
`IGNORE_ROW_ON_DUPKEY_INDEX` hint on Oracle).

Under load, two concurrent transactions on the same bank can produce
overlapping `unit_ids` sets in different orders — most easily reproduced
by two concurrent `PATCH /v1/default/banks/{bank_id}/memories/{id}`
requests where the victim sets (surviving units linking to the patched
unit) intersect. When the two transactions try to acquire their per-row
locks in opposite orders, Postgres detects the cycle and aborts one
transaction with `asyncpg.exceptions.DeadlockDetectedError`, which the
FastAPI layer surfaces as an opaque 500.

Fix: sort `unit_ids` inside both `PostgreSQLOps.enqueue_graph_maintenance`
and `OracleOps.enqueue_graph_maintenance` before issuing the INSERT.
With a total order over the lock set, deadlock is mathematically
impossible — both transactions queue cleanly on the first conflicting
row, then proceed in lockstep.

The only public caller (`enqueue_relink_victims` in
`hindsight_api/engine/graph_maintenance.py`) doesn't rely on insertion
order, so this is a pure correctness improvement with no API-visible
effect. The abstract contract docstring already said "Order is
unspecified" — implementations now happen to pick a deterministic
order, but that's an internal invariant, not part of the public
contract.

Tests:
- `tests/test_enqueue_graph_maintenance_ordered.py` (new):
  - `test_pg_enqueue_graph_maintenance_inserts_in_sorted_order` —
    captures the array passed to `conn.execute` from a deliberately
    shuffled input and asserts it is sorted.
  - `test_oracle_enqueue_graph_maintenance_inserts_in_sorted_order` —
    same assertion against `conn.executemany`'s tuples.
  - Two empty-input tests pin the early-return short-circuit (no INSERT
    when `unit_ids == []`).
- Verified existing `tests/test_graph_maintenance.py` still passes
  (14/14) — the relink-victim enqueue and drain semantics are unchanged.

Compatibility: identical on both dialects. No schema changes. No
externally-visible behavior change beyond the deadlock no longer
firing.
2026-06-23 11:26:27 +02:00
Nicolò Boschi 5f0b715517 feat(recall): add prefer_observations to dedupe raw facts superseded by observations (#2311)
Recalling `observation` alongside `world`/`experience` can return the same
information twice — once as a raw fact and once folded into an observation
consolidated from it. The opt-in `prefer_observations` flag drops any raw fact
that a returned observation lists in its `source_memory_ids`, so the observation
supersedes it. Dedup is by provenance (exact id membership), not semantics, and
runs before recall truncation so freed slots backfill — keeping the result count
at the requested budget.

Disabled by default (opt-in). Internal callers — notably consolidation, which
needs the raw facts it folds into observations — leave it off.

Exposed on the full client surface: the maintained Python (`recall`/`arecall`)
and TypeScript (`recall`) wrappers, the Rust CLI (`--prefer-observations`), the
regenerated OpenAPI + low-level Python/TS/Go/Rust SDKs, the control-plane proxy +
types, and the generated docs skill. Includes docs and deterministic
provenance-based tests (engine + both wrappers).
2026-06-23 11:15:30 +02:00
Nicolò Boschi 0ba613c3ce fix(recall): allow exact filtering of untagged/global observations (#2295) (#2364)
* fix(recall): allow exact filtering of untagged/global observations (#2295)

An empty tag set with tags_match="exact" now selects only untagged
(global-scope) observations — the scope that observation_scopes="shared"
consolidation writes to. Previously empty/absent tags meant "no filter"
in every mode, so there was no way to recall only global observations
when mixing shared and tagged scopes.

- tags.py: in exact mode, empty/absent tags emit an untagged-only clause
  (tags IS NULL OR tags = '{}') with no bind param, across the flat SQL
  builders, Python post-filter, and compound tag-group leaves. All other
  modes keep treating empty/absent tags as "no filtering".
- link_expansion_retrieval.py: always run filter_results_by_tags so the
  exact-empty/global scope is applied (it's a no-op otherwise).
- http.py + regenerated clients/docs: document the exact-empty scope.
- Tests: SQL builders (flat + compound, param-offset preserved), Python
  post-filter, and a recall API test asserting only untagged memories
  return for tags=[] + tags_match="exact".

* chore(docs-skill): regenerate references for untagged exact-scope recall

Regenerated skills/hindsight-docs/references via generate-docs-skill.sh so the
docs-skill mirror matches the updated recall/observations docs (and the canonical
configuration table). Unblocks verify-generated-files.
2026-06-23 11:07:49 +02:00
Chris Bartholomew 04703d2153 fix(async-op): return 404 when bank doesn't exist instead of raw FK 500 (#2352)
`_submit_async_operation` always INSERTs into `async_operations`, which has
an FK to `banks(bank_id)`. Callers that race against bank deletion, or that
derive bank IDs before creating the bank (an integration that submits
`/consolidate` on a freshly-named bank before its CREATE has been issued),
hit `asyncpg.exceptions.ForeignKeyViolationError` out of the INSERT. The
FastAPI endpoints' generic `except Exception` then surfaces it as an opaque
500 — but the root cause is a client misuse, not a server fault.

Add a bank-existence precheck at the top of the INSERT path in both branches:

- `dedupe_by_bank=True` already runs `SELECT 1 FROM banks WHERE bank_id = $1
  FOR NO KEY UPDATE` (for serialization, issue #1842). Switch it from
  `execute` to `fetchval` so the rowcount also gates existence — preserves
  the lock semantics, just adds a check on the returned value.
- `dedupe_by_bank=False` (scoped submits) previously had no lock and no
  check; add a plain `SELECT 1 FROM banks` for existence only.

When the bank is missing, raise `OperationValidationError(404)`. The
endpoint's existing `except OperationValidationError` clause already
converts that to `HTTPException(status_code=e.status_code, detail=e.reason)`
— no API-layer changes needed.

Tests:
- 2 regression tests for `submit_async_consolidation` (unscoped + scoped)
  against a missing bank — assert OperationValidationError with status_code=404.
- 1 pin test for `submit_async_graph_maintenance`, which has its own
  pre-INSERT short-circuit (empty queue → no_work=True) that already
  avoided the FK error.

Verified that the existing dedup atomicity tests
(`test_consolidation_submit_atomic_dedup.py`,
`test_consolidation_retry_dedup_by_bank.py`) still pass — the lock
semantics on the dedupe branch are unchanged.
2026-06-23 10:56:44 +02:00
Miguel de Benito Delgado 20da6d7609 [opencode] Add suport for HINDSIGHT_RETAIN_TAGS (#2306)
* [opencode] Add suport for HINDSIGHT_RETAIN_TAGS

* Fix readme formatting
2026-06-23 10:53:28 +02:00
Evo 387c09e91e fix(config): validate disposition_* range on bank-config write (#2348) (#2349)
PATCH /v1/{tenant}/banks/{id}/config validated field names only, never
scalar type/range, so an out-of-contract disposition_skepticism/literalism/
empathy (float, 0-1 scale, or int outside 1-5) was json.dumps-ed into JSONB
and later injected into a strict DispositionTraits(int, ge=1, le=5) -- a
single malformed bank 500s GET banks for the whole tenant. Add a write-side
_validate_disposition_updates raising ValueError (route maps ValueError->400),
mirroring _validate_recall_budget_updates, plus a unit test. None is allowed
as the clear-override sentinel (overlay falls back to the legacy column, so
null can't poison the list).

Closes #2348.
2026-06-23 10:50:40 +02:00
Eldar Shlomi cbce937042 fix(anthropic): route strict structured output through forced tool_use instead of prompt-injection (#1002) (#2339)
Fixes #1002
2026-06-23 10:44:46 +02:00
Evo 246803bcfe fix(mcp): omit reflect directives_applied alongside tool_trace/llm_trace by default (#2342)
* fix(mcp): omit reflect directives_applied with tool_trace/llm_trace by default

directives_applied is built by the engine 'for the trace' and carries full
directive text, but the include_trace pop block (added in #2242) only removed
tool_trace/llm_trace, so it leaked unconditionally with no opt-out. The REST
API never serializes it. Gate it behind the same include_trace flag to complete
#2242's default-omit-trace contract.

* test(mcp): assert reflect omits directives_applied unless include_trace
2026-06-23 10:24:29 +02:00
Evo 625c331e80 docs(api): correct ReflectResult.based_on key names (#2338)
The in-process engine builds based_on with keys world, experience,
opinion, observation, "mental-models" (hyphen), directives
(memory_engine.py). ReflectResult's Field description named the key
"mental_models" (underscore) and omitted "observation", and the
json_schema_extra example had the same drift — so a consumer doing
based_on["mental_models"] hits KeyError and never learns the
"observation" bucket exists. The maintainer's own http.py comment
already notes the key is hyphenated.

Fixes the description and example to the real keys. Leaves the dead
'opinion' key untouched (handled by #2323/#2335). The separate wire
model ReflectBasedOn is unaffected.
2026-06-23 10:21:34 +02:00
Evoandr266-tech f21944d789 fix(stats): invalidate bank stats cache on unit/document deletes and observation clears (#2337)
* fix(stats): invalidate bank stats cache on unit/document deletes and observation clears

delete_bank invalidates the 60s-TTL BankStatsCache after mutating counts,
but delete_memory_unit, delete_document, clear_observations, and
update_document (on tag-change observation deletion) did not, so
get_bank_stats served pre-mutation counts for up to a minute.

Follow-up to #2315 which hardened the cache primitive but left the
mutation call sites untouched. Invalidation is best-effort (guarded),
matching the other post-commit side-effects in these methods.

Adds tests/test_bank_stats_cache_invalidation.py covering the deletion
paths with a pinned long TTL so the regression is deterministic.

* style: apply ruff format to satisfy verify-generated-files

The verify-generated-files CI job was red because `ruff format` reformats two lines that were committed unformatted:
- wrap the long `logger.warning(...)` call in memory_engine.py
- collapse the `test_delete_document_invalidates_stats_cache` signature

No logic change; this is purely the `uv run ruff format` output. Thanks to @koriyoshi2041 for the precise diagnosis.

---------

Co-authored-by: r266-tech <[email protected]>
2026-06-23 10:21:00 +02:00
Jesus cornelio 0672fba279 fix(claude-code): use realpath for directoryBankMap symlink resolution (#2324)
* fix(claude-code): use realpath for directoryBankMap symlink resolution

os.path.normpath does not resolve symlinks, so a cwd reached via a symlink
silently fails to match a directoryBankMap entry and falls through to the
fallback bank. Replace normpath with realpath on both sides of the comparison
so that a symlinked cwd correctly matches its canonical directory.

Fixes #2312

* test(claude-code): add symlink regression test for directoryBankMap
2026-06-23 10:20:23 +02:00
Evo 53a52afe8b docs(python-client): drop removed 'opinion' fact type from recall()/arecall() (#2323)
v0.8.0 (#1917) removed the 'opinion' fact type; the recall()/arecall()
docstrings still listed it while reflect()/areflect() in the same file
were already corrected.
2026-06-23 10:20:07 +02:00
Nicolò Boschi a2166ee4ff feat(recall): configurable recency decay function (linear/exponential/none) (#2318)
* feat(recall): configurable recency decay function (linear/exponential/none)

The recency boost in apply_combined_scoring hard-coded a linear decay over an
arbitrary 365-day window. Make the age->freshness curve configurable:

- linear (default, unchanged): straight decay to a 0.1 floor over a window now
  exposed as HINDSIGHT_API_RECENCY_DECAY_LINEAR_WINDOW_DAYS (365).
- exponential: 0.5 ** (days_ago / halflife); half-life is the age at which the
  signal is neutral. Smooth, no hard cutoff.
  HINDSIGHT_API_RECENCY_DECAY_HALFLIFE_DAYS (90).
- none: disables the recency boost entirely.

Selected via HINDSIGHT_API_RECENCY_DECAY_FUNCTION. Static config (read via
get_config() at the recall call site, mirroring recall_strategy_boosts).

* fix(test): accept new recency-decay kwargs in scoring stub; regen docs skill
2026-06-23 10:16:29 +02:00
Evo 26bfd2ece4 docs(integrations): drop removed 'opinion' fact type from recall_types/fact_types (#2335)
The 'opinion' fact type was removed in v0.8.0 (#1917). The recall API now rejects it:
  - response_models.py: VALID_RECALL_FACT_TYPES = frozenset(['world', 'experience', 'observation'])
  - http.py (recall + reflect): fact_types: list[Literal['world', 'experience', 'observation']] | None
  - models.py: CheckConstraint("fact_type IN ('world', 'experience', 'observation')")

Ten integration SDK packages still advertised 'opinion' as a valid recall_types/fact_types
value in public tool docstrings, one inline comment, and two README tables, so an agent
copying them passes a value the API 422-rejects. Completes the ripple started by #2198 /
#2302 / #2323 across the hindsight-integrations/* tail (text only, no logic change).
2026-06-23 10:08:26 +02:00
Evo 0d60f0c638 fix(release): build Linux CLI on ubuntu-22.04 (glibc 2.35) instead of glibc-2.39 runners (refs #2321) (#2330) 2026-06-23 10:05:09 +02:00
Derek Bouius 735172f806 chore(deps): drop diskcache from crewai via instructor 1.15.3 (#2325)
instructor 1.12.0 hard-depended on diskcache <=5.6.3, which has an
unpatched pickle-deserialization RCE (CVE-2025-69872 / GHSA-w8v5-vhqr-4h9v;
no fixed version exists). instructor 1.13+ moved diskcache behind an
optional `diskcache` extra, so upgrading to 1.15.3 removes it from the
resolution entirely.

- instructor 1.12.0 -> 1.15.3
- diskcache 5.6.3 removed from the lock
2026-06-23 10:02:09 +02:00
Evo 2c2a20b290 docs(models): sync anthropic default model to claude-haiku-4-5 alias (#2326)
The runtime default for the anthropic provider is the self-updating alias
`claude-haiku-4-5` (config.py PROVIDER_DEFAULT_MODELS, enforced by
tests/test_provider_default_models.py), but the Models docs advertised the
date-pinned snapshot `claude-haiku-4-5-20251001`. A pinned snapshot and a
self-updating alias differ for pricing/retirement, and the page contradicted
hermes.md (which already says `claude-haiku-4-5`).

Sync the canonical sources (llmProviders.json default-model table +
models.mdx examples) to the alias and regenerate the docs skill mirror.
2026-06-23 10:01:52 +02:00
Evo 1a09a9cccd feat(reranker): detect Intel XPU for local cross-encoder acceleration (#2328)
Mirror the XPU device-detection block #2260 added to LocalSTEmbeddings into
the byte-identical LocalSTCrossEncoder twin, so the local reranker also uses
Intel Arc XPU instead of silently falling back to CPU. Guarded by
hasattr(torch, 'xpu') + is_available(); no-op on CUDA/MPS/CPU.
2026-06-23 10:01:25 +02:00
Evo f183b09b93 docs(admin-cli): document run-db-migration --skip-extension-reconcile and --embedding-dimension (#2327)
The run-db-migration Options table listed only `--schema`, but the command
also exposes two operator-facing flags (hindsight_api/admin/cli.py):

- `--embedding-dimension` — enforce an expected embedding dimension after
  migrations (omit to skip the dimension sync).
- `--skip-extension-reconcile` — added in #2309; skip the post-migration
  vector/text-search index reconcile to speed up no-change re-migrations across
  many tenant schemas when the backend is unchanged.

Add both rows to the canonical Options table and regenerate the docs skill
mirror.
2026-06-23 10:00:54 +02:00
Nicolò Boschi 5543992d7d fix(control-plane): make max upload size configurable (#2313) (#2319)
The Next.js auth middleware buffers proxied request bodies and truncates
anything over its default 10MB limit before /api/files/retain can parse
the multipart form, so single uploads >10MB silently fail with
"Failed to parse body as FormData".

Set experimental.proxyClientMaxBodySize, defaulting to 100MB to match the
dataplane's HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE_MB default and
overridable via the new HINDSIGHT_CP_MAX_UPLOAD_SIZE env var (size string
or byte count).
2026-06-23 09:55:02 +02:00
Ben dcabd76911 blog(hermes): Hindsight as one-click desktop memory provider (#2350)
* blog(hermes): announce Hindsight as one-click desktop memory provider
2026-06-22 10:59:52 -04:00
Ben 1a51184a32 docs(hermes): add standalone Hermes Desktop integration page (#2351)
Split the desktop-app setup into its own integration: a new 'Hermes
Desktop' gallery card + page (/sdks/integrations/hermes-desktop) covering
the in-app config flow (select Hindsight in Settings, fill Mode/API key/
API URL/Bank ID/Recall budget) with the two UI screenshots. Cross-linked
with the CLI/plugin Hermes page; the Hermes page keeps a tip pointing to
the desktop guide.
2026-06-22 10:01:10 -04:00
Derek Bouius c7e5095a86 chore(deps): bump dify-plugin to 0.9.1 to fix requests alert (#2320)
dify-plugin 0.8.0 pinned requests>=2.32.3,<2.33.dev0, which held requests
below the 2.33.0 security patch (GHSA for .netrc credential leak). Upstream
dify-plugin 0.9.1 now requires requests>=2.33.1, lifting the cap.

- dify-plugin 0.8.0 -> 0.9.1
- requests 2.32.5 -> 2.34.2
2026-06-22 10:22:40 +02:00
Evo f187d32351 deps(security): bump langsmith floor to >=0.8.18 (GHSA-f4xh-w4cj-qxq8) (#2341)
LangSmith SDK TracingMiddleware arbitrary server-side file read (HIGH),
fixed in 0.8.18; current >=0.6.3 floor permits vulnerable 0.6.3-0.8.17.
Same Transitive-dependency-security-fixes block as the urllib3/cryptography/
authlib/python-multipart floors; no uv.lock in this dir so no re-resolve.
2026-06-22 10:20:08 +02:00
Ben ee81c65e4b blog(openhands): OpenHands persistent memory via native MCP (#2316)
* blog(openhands): add OpenHands persistent memory post

Walkthrough of the Hindsight OpenHands integration: native Streamable-HTTP
MCP server wired into config.toml (recall/retain/reflect tools) plus a
recall/retain rule written into AGENTS.md so the agent recalls at task
start and retains durable facts. Covers Cloud + self-host setup, the CLI
commands (init/status/uninstall), and per-project banks via --bank-id.
Co-branded cover image.
2026-06-19 10:30:22 -04:00
par_amour ccd3eb24c9 fix(cache): prevent stale bank stats after invalidation (#2315) 2026-06-19 16:07:09 +02:00
Nicolò Boschi 51cb32896f perf(migrations): skippable extension reconcile + drop unused global vector index (#2309)
Expose --skip-extension-reconcile on run-db-migration (gates the per-tenant ensure_* reconcile, default off) and stop ensure_vector_extension from creating the unused global memory_units vector index for per-bank backends (verified via EXPLAIN; scann unaffected).
2026-06-19 15:58:12 +02:00
Nicolò Boschi af42382983 fix(tests): eliminate test-api shard cross-test contamination (vchord cache, tenant schemas, maintenance routine TOCTOU) (#2310)
* fix(tests): reset config cache after vchord vector-extension tests to stop cross-test contamination

The ANN tests in test_link_utils.py monkeypatch
HINDSIGHT_API_VECTOR_EXTENSION (e.g. to "vchord"). That env var is read
through the process-global config cache (get_config()), and monkeypatch
reverts only the env var on teardown — not the cache. Once get_config()
caches "vchord", it persists for the rest of the xdist worker.

Every subsequent bank-creating test on that worker then builds per-bank
vector indexes with `USING vchordrq` against the pgvector-only test DB
and fails with:

    asyncpg.exceptions.UndefinedObjectError: access method "vchordrq" does not exist

cascading across dozens of unrelated tests in the test-api shard
(test_list_documents, test_maintenance_routines, test_mental_models,
test_observations, ...). Because the leak depends on which worker first
populates the cache, the failure looked like a flaky, shard-specific
infra problem.

Fix: add an autouse fixture to the class that clears the config cache
before and after each test, so the cache is rebuilt from the current
env per test and "vchord" can't leak out.

* fix(tests): create multi-tenant maintenance schemas atomically

test_maintenance_multitenant provisions 100 tenant schemas by running
CREATE SCHEMA + 5×CREATE TABLE per schema. Each statement autocommitted,
so there was a window where a schema existed with only some of its
tables. The global maintenance routines (public.schemas_with_expired_rows
/ banks_needing_consolidation) discover schemas by table presence and are
exercised concurrently by test_maintenance_routines on another xdist
worker against the shared test DB. They would query a not-yet-created
table in a half-built schema and fail with:

    asyncpg.exceptions.UndefinedTableError: relation "mt<hash>_NNN.memory_units" does not exist

Wrap the whole provisioning in a single transaction so the schemas
become visible to other connections only once fully built.

* fix(maintenance): skip schemas that vanish mid-scan in maintenance routines

public.banks_needing_consolidation() and public.schemas_with_expired_rows()
snapshot the schemas owning a target table from pg_class, then run a dynamic
query against each schema in turn. That is a TOCTOU race: a schema (or its
tables) can be dropped between the snapshot and the per-schema query — a tenant
being deleted, a tenant migration recreating tables, or (in the test suite) the
multi-tenant maintenance test creating/dropping ~100 schemas concurrently with
test_maintenance_routines on the shared DB. The query then aborts the whole
routine with:

    relation "<schema>.memory_units" does not exist
    relation "<schema>.audit_log" does not exist

Forward migration c7e9f1a3b5d2 redefines both routines (CREATE OR REPLACE,
public/base-run gated, PG-only) so each per-schema query runs in its own
subtransaction that skips the schema on undefined_table / invalid_schema_name /
undefined_column instead of failing the scan.

Adds a deterministic regression test (schema with memory_units but no banks
table) for the skip path.

* fix(tests): clear config cache after none-provider engine build to stop chunks-mode leak

test_memory_defense._make_minimal_engine() builds a MemoryEngine inside a
patch.dict that sets HINDSIGHT_API_LLM_PROVIDER=none. Constructing the engine
calls get_config(), repopulating the process-global config cache from the
patched env — and provider="none" forces retain_extraction_mode="chunks". When
patch.dict restores the env, the cache still holds the "none"/chunks config.

It then leaks to every later test on the same xdist worker: their retains run
in chunks mode (raw text, NO entity extraction), so unrelated assertions fail —
notably the test_observations entity tests ("John/Alice/Nexora entity should
exist"), which presented as a flaky, shard-specific failure (whichever entity
test landed on the poisoned worker).

Drop the config cache after the patched env is restored so the next get_config()
rebuilds from the real env. Reproduced deterministically:

    pytest test_memory_defense.py::test_engine_memory_defense_shares_ext_ctx \
           test_observations.py::test_entity_extraction_on_retain
    # before: entity test FAILED (Insert unit_entities: 0 pairs)
    # after:  passed
2026-06-19 15:32:03 +02:00
Evo 955b0c523c docs(monitoring): document worker operation metrics (#2296) 2026-06-19 15:07:28 +02:00
Evo 80281e2543 docs: drop removed 'opinion' fact type from MCP tool docstrings and quickstart (#2302)
The 'opinion' fact type was removed (alembic
g2h3i4j5k6l7_remove_opinion_fact_type; models.py CheckConstraint now allows
only 'world', 'experience', 'observation'), but a couple of agent-facing
surfaces still advertised it:

- hindsight-api-slim/hindsight_api/mcp_tools.py: the list_memories and
  clear_memories docstrings tell agents to filter `type` by 'world',
  'experience', or 'opinion'. An agent following the docstring now passes an
  invalid fact-type filter.
- hindsight-docs cookbook quickstart: the "Memory Types" list still presents
  'Opinion' as a current type ("four networks").

Replace 'opinion' -> 'observation' in the four MCP docstrings and drop the
removed Opinion entry from the quickstart memory-types list (four -> three
networks).
2026-06-19 15:06:42 +02:00
Derek Bouius bb4dd4f393 chore(deps): resolve high/medium/low Dependabot alerts (#2303)
High:
- undici 7.24.x -> 7.28.0 (root override; cloudflare-oauth-proxy via miniflare override) — GHSA-vmh5-mc38-953g / GHSA-pr7r-676h-xcf6

Medium/Low (bulk):
- aiohttp -> 3.14.1 across 22 uv locks (root + 21 integrations)
- idna -> 3.18 (haystack), pypdf -> 6.13.3 (superagent)
- esbuild -> 0.28.1 (chat, opencode, obsidian, cloudflare-oauth-proxy)

Holdouts (upstream-pinned or no patch; left as-is, dev-only or non-applicable):
- esbuild (root): [email protected] pins esbuild ^0.27.0; advisory is dev-server file-read on Windows, not our bundling usage
- postcss (root): vendored by [email protected]
- js-yaml (root): [email protected] uses the v3 safeLoad API; forcing v4 breaks the docs build
- uuid (root): sockjs (webpack-dev-server, dev-only); v3/v5/v6 buf advisory not applicable to v4 usage
- http-proxy-middleware (root): webpack-dev-server, dev-only; npm reports no fix
- requests (dify): dify-plugin==0.8.0 pins requests>=2.32.3,<2.33.dev0
- diskcache, nltk, torch: no upstream patch available
- pipecat-ai: requires major-version (<1.0 -> 1.x) code migration
2026-06-19 15:06:25 +02:00
Nicolò Boschi ba5ddd59af fix(retain): make chunk_text idempotent so raised structured chunk size doesn't fail retains (#2301) (#2308)
Setting HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE above
HINDSIGHT_API_RETAIN_CHUNK_SIZE and retaining a JSONL/conversation doc
with a line/turn over the chunk size crashed with:

    asyncpg.exceptions.CardinalityViolationError:
        ON CONFLICT DO UPDATE command cannot affect row a second time

The streaming retain pipeline pre-chunks each document once (one
chunk_index per piece) and then re-chunks every piece during extraction,
stamping all sub-chunks of a piece with that one chunk_index. When the
structured cap exceeds the chunk size, a pre-chunk could legitimately
exceed the re-chunk budget, so it re-split into several sub-chunks that
all derived the same chunk_id = {bank}_{doc}_{index} and collided in a
single upsert batch.

Fix makes chunk_text idempotent — re-chunking any chunk it returns is a
no-op:
- A lone JSON object (one JSONL line handed back) is kept whole up to the
  structured limit instead of falling through to plain-text splitting.
- Oversized turns/lines are fragmented within min(structured_limit,
  max_chars) so no fragment exceeds the re-chunk budget.

Adds idempotency unit tests and an end-to-end regression test.
2026-06-19 11:21:33 +02:00
DK09876 aab7032071 feat(aider): add Aider integration (session-bracketing memory wrapper) (#2297)
hindsight-aider wraps the aider CLI: recalls project memory before each session (injected via --read) and retains the transcript after. Bank per git repo.
2026-06-18 13:52:38 -07:00
DK09876 adb6dcd683 release(openhands): v0.1.0 2026-06-18 13:21:00 -07:00
DK09876 ae93c93182 release(continue): v0.1.0 2026-06-18 13:20:38 -07:00
Ben 1c0c53c062 blog(agent-framework): Total Recall — persistent memory for Microsoft Agent Framework (#2293)
* blog(agent-framework): add Microsoft Agent Framework persistent memory post
2026-06-18 11:23:04 -04:00
Ben 731add1fdf fix(docs): correct agrasandhany integration icon and ownership (#2294)
* fix(docs): use GitHub icon for agrasandhany integration

The agrasandhany gallery entry reused the Obsidian logo. Its repo lives
on GitHub, so point it at a GitHub mark instead.

* fix(docs): mark agrasandhany as community integration

It's authored by external contributor yugandhar-maram, not the Hindsight
team — switch type official->community and credit the author.
2026-06-18 16:31:56 +02:00
Nicolò Boschi a7f82453a3 test(retain): serialize multichunk sub-batch coverage test on worker_tests xdist group (#2272)
* test(retain): serialize multichunk sub-batch coverage test on worker_tests xdist group

test_subbatch_multichunk_coverage.py's async case submits via
submit_async_retain, which inserts parent/child rows into async_operations.
test_worker.py drives its own WorkerPoller.claim_batch() against the same pool,
so on different xdist workers the two files steal each other's pending rows.
Add the shared xdist_group("worker_tests") marker (matching
test_async_batch_retain.py and the other async-queue tests) so they serialize
on one xdist process. Follow-up to #2269.

* test(worker): scope claim_batch count assertions to the test's own bank

The xdist_group("worker_tests") marker only serializes the tagged
async-queue test files among themselves. It cannot stop test_retain.py
(not tagged) from scheduling a 'consolidation' async_operation in the
public schema while a worker poller test runs — WorkerPoller.claim_batch()
scans the whole schema, so that stray op gets claimed and the global
'assert len(claimed) == N' counts it (observed: assert 3 == 2 in
test_poller_discovers_tenants_dynamically).

Filter claimed tasks to the test's own bank_id before counting, matching
the existing 'my_claims' convention already used by ~10 tests in this
file. Covers the remaining global-count assertions in the public-schema
poller tests; the max_slots cap test and the isolated custom-schema test
are unaffected (their global counts are robust by construction).
2026-06-18 12:06:12 +02:00
Nicolò Boschi 2bd6be8d85 docs: changelog and blog post for v0.8.3 (#2290)
* docs: changelog and blog post for v0.8.3

* docs: drop Richer MCP Tools section from 0.8.3 blog
2026-06-18 11:31:33 +02:00
Nicolò Boschi e1014cc790 Release v0.8.3
- Update version to 0.8.3 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.8
2026-06-18 11:13:37 +02:00
Kuba OdiasandClaude Opus 4.8 da2125cf13 feat(metrics): instrument async worker completion path with operation metrics (#2253)
* Instrument async worker completion path with operation metrics

The async worker never emitted hindsight_operation_operations_total /
_duration_seconds — record_operation() was only called from the synchronous
API layer. In prod, retain/reflect/consolidation run through the async worker,
so the Operations dashboard showed no retain activity and there was no
Prometheus signal for async throughput, latency or success/failure.

Emit operation metrics from the worker on terminal outcomes:
- Add MetricsCollector.record_operation_result(): direct (non-context-manager)
  recording with an explicit success label, for paths that need success control
  rather than the exception-based record_operation() CM. The CM now delegates
  to it (no behaviour change, no duplication).
- In WorkerPoller._execute_task_inner, record source="worker" with success=true
  on normal completion and success=false on failure. Deferrals (DeferOperation)
  and retries (RetryTaskAt) are not terminal and are deliberately not counted.
- Normalise the retain operation_type variants (batch_retain,
  file_convert_retain) onto operation="retain" so worker completions share the
  API path's series, which the Operations dashboard keys off.

This makes async retain visible on the dashboard and gives a Prometheus signal
for async worker throughput and success/failure.

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

* Harden worker metric: record outside executor scope + cover defer/retry

W1: recording the success metric inside the executor try meant a metrics
failure could be caught by the broad except Exception and mark a completed
task as failed. Record on terminal outcomes outside the exception scope and
guard the call so instrumentation can never flip terminal task state.

W2: add no-DB tests for _execute_task_inner asserting completion/failure emit
the metric (with success true/false, retain normalised) and that
DeferOperation/RetryTaskAt do not.

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

* style: apply ruff format

Satisfy verify-generated-files: blank line after _metric_operation_label and
single-line record_operation_result test call, per ruff format.

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

* docs: correct reflect coverage in worker metric comment

reflect runs only on the synchronous API path (execute_task has no reflect
branch), so operation="reflect" never emits with source="worker". Reword the
comment to list retain/consolidation and the other worker task types instead.

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

* metrics(worker): scope success label to completion-throughput, not failure-rate

Address review: the worker success label infers success from raise/no-raise, but
memory_engine.execute_task swallows deterministic failures (file_convert_retain,
non-retryable errors) — it marks the op failed and returns normally — so those
record success=true. Rather than re-engineer execute_task to thread status back,
narrow this metric's documented meaning to a completion-throughput signal and
defer authoritative failure visibility to the now-merged
hindsight_async_operations{status="failed"} gauge (#1987), which reads each
operation's final DB status.

- Reword the poller comment: success=false means the task raised to the poller
  (unexpected / retry-exhausted); deterministic self-handled failures are not
  captured here — point operators at the failed gauge.
- Add test_executor_self_handled_failure_records_success_by_design to lock the
  intentional behavior so any future change to the inference is deliberate.

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

* test(worker): fold self-handled-failure case into the completion test

The separate test_executor_self_handled_failure_records_success_by_design
asserted nothing the completion test didn't: at the poller boundary a
self-handled failure is indistinguishable from a clean completion (both return
normally), and with the executor mocked there is no real mark-failed / DB status
to observe. Remove the duplicate and document the intentional scoping in the
renamed test_executor_returning_normally_records_success docstring instead.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-18 11:05:58 +02:00
Sanderhoff-alt f4bac2d41d fix(mcp): restore custom tool instructions (#2288)
Restore HINDSIGHT_API_MCP_INSTRUCTIONS for HTTP MCP servers.
Append the extra guidance only to retain and recall tool
descriptions, matching the original local MCP behavior without
changing reflect or other management tools.
2026-06-18 10:40:58 +02:00
Sanderhoff-alt 39abf0ad3f fix(tests): wait for testcontainers port mappings (#2283) 2026-06-18 10:35:37 +02:00
Sanderhoff-alt 8426b0c359 fix(tests): isolate backsweep migration pg0 state (#2282) 2026-06-18 10:33:26 +02:00
Derek Bouius f4a0a31f70 chore(deps): fix critical/high Dependabot alerts (#2278)
Resolve all 50 fixable critical/high Dependabot alerts across the monorepo.

Python (uv.lock):
- starlette 1.0.1 -> 1.3.1, python-multipart -> 0.0.32, pyjwt -> 2.13.0,
  tornado -> 6.5.7, urllib3 -> 2.7.0 across root + integration projects.
- cryptography -> 49.0.0 (GHSA-537c-gmf6-5ccf, bundled-OpenSSL OOB read).
  Lifted the hindsight-api-slim <47 cap: 47/48/49 verified importing and
  running RSA sign/verify cleanly on linux/arm64 (Docker on Apple Silicon)
  and native arm64 macOS; the SIGILL of pyca/cryptography#14733 does not
  reproduce on current tooling (upstream issue closed unconfirmed).
- Root and haystack uv.lock pick up uv lockfile revision 3 (the format the
  rest of the repo's locks and CI's setup-uv@v7 already use).

npm:
- shell-quote -> 1.8.4 (critical); ws -> 7.5.11 / 8.21.0; vite -> 8.0.16
  across root + integrations; embed control-center UI vite ^5 -> ^6.4.3
  (build verified); n8n form-data override -> ^4.0.6.
- zapier: overrides for form-data, serialize-javascript, tar, tmp,
  yeoman-environment (dev-only zapier-platform-cli tree); npm audit clean.

Not fixed (no safe path):
- nltk (llamaindex, pipecat): no patched release exists upstream (<=3.9.4).
- pipecat-ai (pipecat): fix needs 1.2.0 but the integration is pinned <1.0
  pending a module-restructure migration.
2026-06-18 09:32:25 +02:00
DK09876 65862c4fef feat(openhands): add OpenHands integration (native MCP config + recall/retain rule) (#2276)
Long-term memory for OpenHands via native Streamable-HTTP MCP: hindsight-openhands init wires the Hindsight MCP server into config.toml + a recall/retain rule in AGENTS.md.
2026-06-17 12:46:20 -07:00
Ben ef548833fd blog(freshness): Freshness-Aware Memory — knowing when a belief has gone stale (#2267)
* blog(freshness): add freshness-aware memory post

Concept deep-dive on how Hindsight tracks belief currency: the per-observation
freshness trend (new/strengthening/stable/weakening/stale, computed from evidence
timestamps over 30/90-day windows by density ratio) and the consolidation-lag
signal (up_to_date/slightly_stale/stale from pending memories), plus how the
reflect loop uses both to verify stale beliefs against raw facts.
2026-06-17 15:28:46 -04:00
DK09876andClaude Opus 4.8 55f70e1d27 fix(docs): make integrations.json strict-valid (drop trailing comma)
The last entry had a trailing comma, so build-docs' 'Check integrations'
step (strict JSON.parse) failed on main and every PR. Drop it.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-17 10:33:03 -07:00
DK09876 b8cfddd7b6 release(zed): v0.1.0 2026-06-17 10:30:00 -07:00
DK09876andClaude Opus 4.8 52cb9a2bae chore(dev): register continue/zed/openhands in changelog generator
These new integrations were added to VALID_INTEGRATIONS / CI but not to the
generate-changelog registry, so release-integration.sh failed at the changelog
step. Add their package names so releases can be cut.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-17 10:29:53 -07:00
DK09876 539101af38 feat(zed): add Zed editor integration (MCP context server + recall/retain rule) (#2153)
MCP-only Zed integration: hindsight-zed init wires the Hindsight MCP server into Zed's settings.json (via mcp-remote) plus a recall/retain rule in AGENTS.md. Validated end-to-end in real Zed.
2026-06-17 10:28:07 -07:00
DK09876 efa37cb15f release(opencode): v0.2.6 2026-06-17 08:57:36 -07:00
BenandClaude Opus 4.8 faaa97d4a0 docs(observations): stop claiming a per-observation freshness trend (#2271)
The "computed freshness trend (stable/strengthening/weakening/new/stale)"
described across the developer docs maps to code in
reflect/observations.py that is unreferenced — not wired into recall,
reflect, or the API, and absent from the OpenAPI schema. It is not a
surfaced feature, so the docs overstated it.

Replace those claims with the freshness behavior that IS shipped: when
newer memories haven't been consolidated yet, reflect treats the affected
observations as stale and verifies them against raw facts. Touches
developer/index, observations, configuration, api/recall, and
best-practices, plus the regenerated skills/hindsight-docs mirror.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-17 17:28:35 +02:00
yugandhar-maram 70804fe2c6 Update integrations.json (#2268)
Add agrasandhany integration
2026-06-17 17:28:12 +02:00
Parafee41 ae2532b165 Hide Windows netstat port probes (#2263) 2026-06-17 17:26:18 +02:00
Nicolò Boschi 81865bf873 fix(retain): stop dropping chunks when an oversized doc splits into multi-chunk sub-batches (#2269)
Ingesting a large single document (~88k chars) dropped most of its body — and
any fact past the first slice — when retained. Two bugs, both only triggered when
an oversized item is split into sequential sub-batches whose slices each re-chunk
into several extraction chunks (the default config: batch tokens 10k → ~30k-char
slices, re-chunked at 3k → ~10 chunks/slice):

1. chunk_index offset (sync + async). retain_batch_async advanced the
   per-document chunk_index cursor by re-chunking item["content"] AFTER the
   orchestrator had consumed (popped) it. chunk_text("") returns [""] (count 1),
   so the cursor moved by 1 per sub-batch instead of by the real chunk count;
   later slices restarted ~1 slot in, colliding chunk_id = {bank}_{doc}_{index}
   and overwriting earlier chunks via upsert. Fix: count the slice's chunks
   before handing it to the orchestrator, while content is still present.

2. whole-document recovery skip (async only). All sub-batches of one submitted
   operation share one operation_id; the first slice stamps the document into
   result_metadata.facts_committed_document_ids. The crash-recovery fast-path
   then saw every later slice's document already "committed" and skipped
   extraction entirely, so only the first slice survived. Fix: only take the
   whole-document skip when the call starts the document at chunk 0
   (chunk_index_offset == 0); a non-zero offset means this call continues a
   document another sub-batch already started. Per-chunk hash recovery
   (existing_chunk_hashes) still provides crash-safety for those chunks.

The existing #1888 coverage tests use RETAIN_BATCH_TOKENS=100 (a ~300-char
budget, under the chunk size) so every slice collapses to ONE chunk, which masks
both bugs. New test_subbatch_multichunk_coverage.py sizes the body so each slice
fans out to ~6 chunks, with globally-unique tokens (no chunk-hash dedup), and
asserts full coverage + contiguous chunk_index + a needle planted in a late slice
across BOTH the sync (retain_batch_async) and async (submit_async_retain) paths.
2026-06-17 17:25:09 +02:00
Nicolò Boschi 9e47759347 test(retain): add retain_structured_chunk_size to quota-defer test config mock (#2265)
extract_facts_from_text reads config.retain_structured_chunk_size (passed
to chunk_text), but the test's SimpleNamespace mock only set
retain_chunk_size, so the test raised AttributeError instead of exercising
the quota-defer path. Add the field (None = plain chunking) to fix it.
2026-06-17 15:58:26 +02:00
Nicolò Boschi d8665d7ab0 test(openclaw): update agent_end hook tests for stripped context system message (#2266)
#1968 moved routing metadata out of the transcript (it no longer prepends a
'[context]' system message) and into the retain API context field, but left
three agent_end integration assertions on the old shape:
- transcript no longer starts with a {role:'system', '[context]...'} entry
- message_count reflects the structured turn length without the system pad
  (1 for a single-user turn, 2 for the last user+assistant turn)

Updates index.test.ts's sibling integration tests to match.
2026-06-17 15:40:32 +02:00
Evo 9bde15331e docs: document MCP trace and precheck content length (#2264) 2026-06-17 15:19:20 +02:00
Nicolò BoschiandSveinbjörn Geirsson 2fb2de1aa8 feat(embeddings): detect Intel XPU for local embedding acceleration (#2260)
Extend local device detection so sentence-transformers can use an Intel
XPU (e.g. Arc A770) when a torch XPU build is loaded, falling back to CPU
otherwise. Split out from #2233.

Co-authored-by: Sveinbjörn Geirsson <raudbjorn@github>
2026-06-17 14:43:57 +02:00
Evoandr266-tech d68bd07423 Add Gemini service tier config (#2251)
* Add Gemini service tier config

* Format generated Gemini service tier files

---------

Co-authored-by: r266-tech <[email protected]>
2026-06-17 14:37:26 +02:00
Yunan Wang 44972d3215 fix(mcp): omit reflect tool_trace/llm_trace from responses by default (#2242)
The MCP `reflect` tool returned the full `reflect_async` result, which
includes `tool_trace` and `llm_trace` — the entire internal agent loop,
including full mental-model text. A default reflect response measured
59,657 chars (text 5,987 + tool_trace 52,711), silently consuming tens
of KB of MCP-client context on every call, while the REST API omits the
trace by default.

Add a symmetric `include_trace: bool = False` flag (mirroring the
existing `include_based_on`); the trace becomes opt-in for debugging.
Applied to both the multi-bank and single-bank reflect registrations,
with a regression test covering both.
2026-06-17 12:32:39 +02:00
de1tyandNicolò Boschi aa308ad201 fix(openclaw): strip runtime metadata from memory content (#1968)
* feat(openclaw): pass retain context guidance to prevent routing metadata misattribution

Hindsight's fact extraction LLM was misinterpreting routing identifiers
(sender open_id, bank ID, channel, provider) as semantic actors, project
names, or organizations. After many conversation turns, the bank name
(e.g. saber-prod) would override the actual project being discussed
(e.g. x-power-cli).

This adds interpretation guidance via the retain API 'context' field:
- New DEFAULT_RETAIN_CONTEXT constant explains that [context] block
  sender/channel/provider are routing identifiers, not human names
- Bank IDs, session keys, agent IDs, thread IDs, and tags are also
  marked as operational routing identifiers, not project names
- Assistant-role first-person statements are attributed to the AI
- Context is passed through the full chain: buildRetainRequest →
  scopeClient.retain → Hindsight SDK API
- RetainQueue persists and flushes context correctly
- Backfill CLI also passes context
- New 'retainContext' config option allows customization

includeSenderContext behavior is unchanged; the [context] block remains
in transcript content, but extraction LLM now knows how to interpret it.

7 files changed, 97 insertions(+).

* fix(openclaw): remove platform-specific examples from DEFAULT_RETAIN_CONTEXT

* test(openclaw): harden retain context handling

* fix(openclaw): strip runtime metadata from memory content

* refactor(openclaw): remove dead session-context surface

Following the removal of transcript context-prepending, drop the now-unused
formatRetentionSessionContext / RetentionSessionContext and the ignored
prepareRetentionTranscript session-context parameter (and the discarded
object built at the live call site). Remove the inert includeSenderContext
config option (no longer read) from the type, manifest schema, and UI label.
Collapse the session-context tests to two regression guards asserting that
retained JSON/text content carries no context header.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-17 12:29:22 +02:00
Timur KhairutdinovandTimur Khairutdinov cb73790c27 fix(api): apply bank-config disposition + mission overlay in list_banks (#2101)
list_banks now overlays resolved bank config (reflect_mission + disposition_*) on top of the legacy banks.disposition/banks.mission columns, matching get_bank_profile so the list and get paths agree for a bank.

Overlay extracted into a shared helper returning a ResolvedDispositionMission dataclass. Config is resolved in one batch (single banks.config query + one tenant resolve) via ConfigResolver.get_bank_configs(), avoiding an N+1 of per-bank config resolves.

Co-authored-by: Timur Khairutdinov <[email protected]>
2026-06-17 12:27:39 +02:00
Matthew JacksonandNicolò Boschi b1fe23fbe4 feat(metrics): expose async-operation queue + consolidation backlog as gauges (#1987)
* feat(metrics): expose async-operation queue + consolidation backlog as gauges

The bank-stats endpoint already computes operations_by_status,
pending_consolidation and failed_consolidation, but only as a point-in-time
HTTP response per bank. There's no way to trend or alert on "is the worker
keeping up?" / "is the knowledge base caught up?" from Prometheus.

This adds three observable gauges, fed by a 30s background-refresh cache (the
same pattern as the existing db-pool gauges, so the /metrics scrape path stays
synchronous):

- hindsight_async_operations{operation_type,status} -- worker queue depth for
  non-terminal states. pending = queued backlog (e.g. retain / consolidation),
  processing = in-flight, failed = stranded. Terminal states (completed,
  cancelled) are deliberately excluded: a gauge of finished work grows without
  bound and says nothing about current load. The processing series is the only
  signal that surfaces a hung operation holding a worker slot.
- hindsight_consolidation_backlog -- source memories (experience/world) not yet
  consolidated into observations (pending_consolidation).
- hindsight_consolidation_failed -- source memories whose consolidation
  permanently failed, recoverable via the consolidation recovery endpoint
  (failed_consolidation).

The SQL is lifted from the bank-stats endpoint and is index-backed
(idx_async_operations_status, idx_memory_units_unconsolidated). Per-bank labels
are gated behind the existing metrics_include_bank_id flag (off by default);
when off, counts aggregate per tenant/schema, bounding cardinality to a handful
of series. All queries are PostgreSQL-specific (FILTER, information_schema),
consistent with this collector already being bound to an asyncpg pool.

* review: address feedback on backlog metrics

- Split the consolidation backlog into two separate COUNT(*) queries, each with
  a WHERE matching a partial-index predicate exactly (idx_memory_units_
  unconsolidated / idx_memory_units_consolidation_failed), instead of one
  aggregate with two FILTERs that seq-scans the whole memory_units table on
  every 30s refresh across every schema. GROUP BY bank_id still composes
  (bank_id is each index's lead column).
- Type the gauge cache keys as NamedTuples (_AsyncOpKey, _BacklogKey) instead of
  raw tuples.
- Hoist `import asyncio` to module scope (was imported inside two methods).
- Document that _backlog_task is process-lifetime and intentionally not
  cancelled (no teardown hook to hang it on).
- Add tests for the per_bank=True path (bank_id in the cache key + GROUP BY
  bank_id in the SQL + bank_id gauge attribute) and assert the backlog queries
  are index-matched, not FILTER scans.

* fix(metrics): force index scan for the consolidation backlog count

Splitting the consolidation count into two index-predicate-matched COUNT(*)
queries fixed the failed count (index-only scan) but NOT the backlog count.
Verified on a 114k-row memory_units via EXPLAIN ANALYZE: the backlog query still
seq-scans (~92 ms) because `consolidated_at IS NULL` is true for ~40% of the
table (every observation has a null consolidated_at), so the planner misjudges
selectivity and won't use idx_memory_units_unconsolidated even though the
predicate matches it exactly. ANALYZE doesn't change the plan (structural, not
stale stats); `enable_seqscan=off` confirms the index is usable (~0.1 ms).

Run the backlog count in a scoped transaction with SET LOCAL enable_seqscan=off
to force the partial-index scan (verified ~0.07 ms, transaction-scoped, no
leak). The failed count needs no nudge — consolidation_failed_at IS NOT NULL is
rare, so its index is chosen on cost.

* feat(metrics): gate consolidation backlog gauges behind config flag (off by default)

Add HINDSIGHT_API_METRICS_BACKLOG_ENABLED (default false). The
async-operation queue + consolidation backlog gauges run periodic
per-schema COUNT queries on a background task, so they are now opt-in
rather than always-on when a db pool is set.

* chore: sync embed env template + prettify paperclip README after main merge

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-17 12:27:28 +02:00
Chris Bartholomew ce81217381 feat(extensions): expose Content-Length on PrecheckContext (#2247)
Add an optional `content_length: int | None` field to `PrecheckContext`
and populate it from the request's `Content-Length` header in the
`_precheck_dep` FastAPI dependency wired by the billable POST routes.

Surfacing the header lets a precheck make size-aware decisions — for
example, computing an upper-bound cost estimate (`bytes / tokens-per-byte
* per-op-rate`) and rejecting before the body is read or deserialised —
without changing the contract that the precheck runs before body parse.

The field is optional with a default of `None`, so existing
`OperationValidatorExtension` implementations and `PrecheckContext`
construction sites are unaffected. `None` also remains the value when
the header is absent (e.g. chunked transfer encoding) or unparseable;
`0` is preserved as a known empty body.

Adds three tests in `TestPrecheckHttpWiring`:
- header populated → validator sees the int
- empty POST body → validator sees `0`, not `None`
- header missing → validator sees `None`
2026-06-17 12:23:38 +02:00
haodonp 94619ce52b fix(consolidation): handle single-value source_fact_ids from LLM (#2240)
Some LLMs return source_fact_ids as a string instead of a list when there is only one source ID. Add field_validator on both  _CreateAction and _UpdateAction to auto-wrap into a single-element list.

Relates-to: #1656
2026-06-17 12:22:53 +02:00
Yunan WangandNicolò Boschi 27aa6bbf46 feat(mcp): add ToolAnnotations (read-only/destructive hints) to MCP tools (#2243)
* feat(mcp): add ToolAnnotations (read-only/destructive hints) to MCP tools

All MCP tools registered with bare @mcp.tool() and exposed no annotations,
so clients (claude.ai, Notion, …) could not group read vs write tools,
surface a destructive-action warning for delete_bank / clear_memories, or
auto-approve safe reads.

Add a _tool_annotations() helper that classifies each tool as read-only,
destructive, or plain write, and apply it to every registration.
openWorldHint=False throughout (closed memory store). Pure metadata — no
behavioural change.

reflect is classified as a (non-destructive) write because it can form and
persist opinions during synthesis; flip it to readOnlyHint=True if the
engine never persists on reflect.

* fix(mcp): classify reflect as read-only (engine persists nothing)

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-17 12:19:41 +02:00
Chris Bartholomew acf4d5c860 log(consolidation): show call count + avg for each timing phase (#2238)
The consolidation summary log used to print only the total time per phase:

    [4] Timing breakdown: recall=15.425s, llm=43.181s, embedding=0.301s

This makes it easy to misread the "recall=15s" line as a single slow query
when it is actually the sum of many sequential sub-calls (e.g. 100 internal
recalls at ~150ms each). Add a call counter to ConsolidationPerfLog and
include both the count and a per-call average when count > 1:

    [4] Timing breakdown: recall=15.425s (100 calls, avg=154ms),
                          llm=43.181s (12 calls, avg=3598ms),
                          embedding=0.301s (3 calls, avg=100ms),
                          db_write=0.829s

Operators triaging "the recall phase took 15s" can now tell at a glance
whether the cost is one slow query or many fast ones, which leads to very
different diagnostic paths. Single-call timings keep the existing terse
format (no `(1 calls, ...)` clutter).

Backward-compatible: timing_counts is a new attribute; existing accessors
on `timings` and `llm_calls` keep their current semantics.
2026-06-17 12:17:27 +02:00
Kuba OdiasandClaude Opus 4.8 551932991d fix(litellm): hard-cap completions with asyncio.wait_for so a hung call can't block forever (#2224)
* fix(litellm): cap completions with asyncio.wait_for so a hung call can't block forever

The LiteLLM provider issued completions as a bare `await self._acompletion(...)`.
The only timeout was the `timeout=` kwarg handed to `litellm.acompletion()`,
which is not always honored (e.g. a connection held open with no token
progress). When that happens the coroutine awaits indefinitely, holding the
worker slot and a concurrency-semaphore permit for the lifetime of the process.

Fact extraction fans these calls out through `asyncio.gather`, so a single
hung straggler stalls the whole operation even though its sibling calls
returned — completion throughput collapses to zero while sibling calls keep
succeeding, which makes the failure mode hard to diagnose.

Wrap the request in `asyncio.wait_for(timeout=self.timeout)` in both `call`
and `call_with_tools` (mirroring the Gemini provider, which already does this)
and treat the resulting `TimeoutError` as a normal retryable attempt, so the
task can retry or fail cleanly and release its slot. The existing
`asyncio.gather(..., return_exceptions=True)` callers absorb the timeout with
no extra handling.

Also thread an optional `timeout` through `create_llm_provider` and
`LLMConfigWrapper` into the LiteLLM/Bedrock/Router providers so the cap is
configurable; `None` keeps the existing 300s default (never `None`, which
would make `wait_for` wait forever).

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

* fix(litellm): make the hard-timeout cap configurable and converge timeout handling

Builds on the asyncio.wait_for cap added for the LiteLLM family:

- Wire LLMProvider.from_env() to read HINDSIGHT_API_LLM_TIMEOUT (default
  DEFAULT_LLM_TIMEOUT = 120s). The cap was threaded through the constructors but
  never set by from_env(), so it silently defaulted to 300s and was not
  configurable. This matches how the OpenAI-compatible provider already reads the
  same var. Also fix the stale openai-compatible docstring that claimed 300s.

- Converge timeout handling: litellm's own Timeout and the outer wait_for
  TimeoutError are armed at the same deadline but previously flowed through
  different except blocks (generic vs dedicated), so which one tripped was a race
  producing different log lines and backoff. Catch both in one block so they
  share a retry policy and log line; log the exception class name so the firing
  mechanism stays visible.

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

* refactor(litellm): hoist litellm Timeout import to module level

Address PR review (r3421670469): litellm is a hard dependency already imported
in __init__, so the per-call function-local `from litellm.exceptions import
Timeout` in call/call_with_tools is unnecessary. Hoist to a module-level import,
matching how gemini_llm imports its SDK.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-17 12:08:49 +02:00
Sanderhoff-alt 5ee53c512f feat(api): add optional MarkItDown OCR support (#2145)
MarkItDown advertises image extensions, but without OCR config it can
fail screenshots or scanned images with low-level no-content errors.

Add server-level MarkItDown OCR config that is off by default and
independent from HINDSIGHT_API_LLM_*. When OCR is enabled, the OCR API
key, base URL, and model are required explicitly.

Wire those settings into MarkItDown's llm_client support with a built-in
OCR prompt. Image uploads now fail fast with actionable errors when OCR
is disabled or required settings are missing.

Docs and front-end copy explain that image OCR depends on server config
and requires an OpenAI-compatible OCR/vision endpoint.

Closes #927
2026-06-17 12:07:58 +02:00
Justas ŠireikaandClaude Opus 4.8 4efa204727 fix: template bank-id path segment in HTTP metric endpoint label (#2191)
http_metrics_middleware normalizes only UUID and pure-numeric path
segments, so non-numeric bank ids (e.g. user-123, tenant-acme) survive in
the /banks/<id> segment of the `endpoint` metric label. Each distinct bank
then becomes a never-evicted OTel series, growing process memory unboundedly
on every per-bank request.

Same unbounded-OTel-cardinality class as #850 (fixed in #898 for the
record_operation bank_id attribute), via a code path #898 did not cover.

Extract endpoint normalization into a pure, unit-tested normalize_http_endpoint()
helper in metrics.py (next to get_token_bucket) that also templates the
/banks/<id> segment, and call it from the middleware.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-17 12:02:04 +02:00
EABandClaude Fable 5 c3bb647640 claude-code: case-insensitive directoryBankMap matching on Windows (#2183)
derive_bank_id compared os.path.normpath(cwd) against the map keys with
==, which is case-sensitive — but on Windows the drive-letter case of the
cwd a session reports depends on the launcher: PowerShell and git-bash
hand child processes an UPPERCASE drive (C:\...) while the VS Code
extension spawn reports lowercase (c:\...). cmd.exe preserves whatever
case was typed. A directoryBankMap entry can therefore silently miss for
some launchers and fall through to the default bank, with no error —
sessions quietly land in the wrong memory bank.

Fix: wrap both sides in os.path.normcase, which lowercases and normalizes
separators on Windows and is a documented no-op on POSIX — so POSIX path
matching stays case-sensitive (pinned by a new test) and Windows matching
becomes launcher-independent (pinned by a new test that fails without
this change).

Co-authored-by: Claude Fable 5 <[email protected]>
2026-06-17 12:00:54 +02:00
Eldar ShlomiandClaude Opus 4.8 12851bc7ee fix(claude-code-mcp): resolve venv interpreter in Windows Scripts/ layout (#2066)
run_mcp.sh's resolve_py() probed only <venv>/bin/python and
<venv>/bin/python.exe. A standard Windows CPython venv (python.org
installer, Windows Store Python, `py -m venv`) puts the interpreter at
<venv>/Scripts/python.exe, so resolve_py returned empty, the launcher
fell through to venv re-creation, and re-creation failed whenever
python/python3 were not on the spawning process's PATH (issue #1758, 3a).

Add a Scripts/ elif branch and update the now-misleading "venv create
failed" message to mention both layouts. POSIX behaviour is unchanged.

Adds a hermetic pytest that invokes the real bash resolve_py against a
fabricated venv tree: RED on the Scripts/ layout before this change,
plus a bin/ regression guard for the POSIX path.

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-17 11:58:45 +02:00
Nicolò Boschi a5f4d30ea6 fix(openclaw): always session-scope retained documents (#2259)
retainDocumentScope was always meant to be 'session'; the 'turn' option
just disabled document accumulation. Remove the config field entirely so
retains always use a stable per-session document id (falling back to
per-turn ids only on legacy APIs that lack update_mode: 'append').
2026-06-17 11:55:51 +02:00
Misha DenilandNicolò Boschi 0c9bc765ce Honor CODEX_HOME for Codex auth.json location (#1874)
Codex authentication previously hardcoded ~/.codex/auth.json in several
places. Route all Codex auth/LLM/embeddings paths through a single
default_codex_auth_file() helper that honors the CODEX_HOME environment
variable (matching the upstream @openai/codex CLI), falling back to
~/.codex when unset or empty.

Adds tests for the resolution logic and hardens an existing embeddings
test against CODEX_HOME leaking in from the environment.

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-17 11:45:01 +02:00
Nicolò Boschi cd34efa596 chore(ci): disable Dependabot version updates (#2257)
Remove .github/dependabot.yml to stop Dependabot from opening
automated version-update PRs (github-actions ecosystem).

Note: Dependabot security updates are controlled by a repository
setting, not this file, and must be disabled separately in repo
settings if desired.
2026-06-17 11:44:15 +02:00
formatme 2b521c3a09 fix(api): defer provider quota reset retries (#2194) 2026-06-17 11:43:52 +02:00
Nicolò Boschi 9681d96195 Add Python client get_version helper (#2256)
Adds HindsightClient.get_version()/aget_version() convenience wrappers for
the existing /version endpoint, re-exports VersionResponse for typed callers,
and tests both paths against a mocked MonitoringApi.

Python parity for #2252 (TypeScript getVersion). Fixes #2248.
2026-06-17 11:38:15 +02:00
Evo a32ecfeb33 docs(paperclip): document dynamicBankId / bankId / user granularity (#1761) (#1803)
* docs(paperclip): document dynamicBankId / bankId / user granularity

* docs(paperclip): mirror dynamicBankId / bankId / user granularity in integration README
2026-06-17 11:30:26 +02:00
Evo bf73a1dfbe docs(embed): document control center commands (#2151) 2026-06-17 11:28:41 +02:00
Evo ca2ce5c16d fix(api): reject empty/whitespace content in dry-run extraction before the LLM call (#2246)
* fix(api): reject empty/whitespace content in dry-run extraction before the LLM call

* test(api): assert dry-run extraction rejects empty content (422)
2026-06-17 11:25:58 +02:00
grimmjoww578andClaude Opus 4.8 7b17da7a0c Strip reasoning tags on non-structured output + unclosed blocks (#2195)
The reasoning-tag strip in OpenAICompatibleLLM.call() only ran inside the
`if response_format is not None:` (structured/JSON) branch. The `else:` branch
that returns free-form, non-structured output (e.g. consolidated mental-model
markdown) returned the raw provider content with no strip at all. Reasoning
models that emit their chain-of-thought in the response body — confirmed with
MiniMax-M3 — therefore leaked `<think>...</think>` verbatim into stored mental
models.

Additionally, every existing strip regex used the lazy `<tag>.*?</tag>` form,
which requires a closing tag. When output is truncated mid-thought the closing
tag never arrives, so a dangling `<think>` slipped through even on the JSON path.

Fix:
- Factor a module-level `_strip_reasoning_tags(text)` helper covering the full
  tag set (think, thinking, thought, reasoning, |startthink|...|endthink|).
- For each tag, strip closed blocks (`<tag>...</tag>`, DOTALL) and then any
  remaining unclosed block (`<tag>.*` to end-of-string).
- Call it from BOTH branches: the structured path (replacing the inline regex
  block) and the free-form path (which previously had no strip).

Adds tests/test_strip_reasoning_tags.py covering closed/unclosed blocks, all
tag styles, multi-line and multi-block input, and the real-world mental-model
markdown contamination case.

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-17 11:25:34 +02:00
Parafee41 ea8d88057b Add TypeScript client version helper (#2252) 2026-06-17 11:19:35 +02:00
DK09876 cf3bcee89c chore(control-plane): format bank-selector.tsx (lint drift from #2212) (#2250)
Applies eslint/prettier formatting that #2212 missed, unblocking verify-generated-files for all open PRs. No behavior change.
2026-06-16 18:34:11 -07:00
Ben 2d08352429 release(composio): v0.1.0 2026-06-16 16:00:03 -04:00
Ben 1c9ba0e659 feat(composio): add Composio integration (Hindsight memory as custom tools) (#2180)
* feat(composio): add Composio integration (Hindsight memory as custom tools)

Exposes Hindsight retain/recall/reflect as Composio in-process custom tools via
register_hindsight_tools(). The Hindsight bank for each call is the Composio
session's user_id, so one registered tool set isolates memory per user
automatically. Also ships memory_instructions() for pre-recall system-prompt
injection (Composio doesn't auto-inject context).

- hindsight_composio/: tools.py, config.py (dataclass + env fallback), errors.py.
- tests/: 50 tests using a FakeComposio (mirrors the real tool decorator +
  SessionContext) + mocked Hindsight client — exercises the framework wiring.
- CI: test-composio-integration job (uv build/sync/ruff/pytest) + path filter.
- Gallery card + doc page + official Composio icon; release-integration.sh entry.

* fix(composio): register in changelog generator + test memory_instructions

- Add composio to generate_changelog.py INTEGRATIONS dict (release would
  otherwise fail at the changelog step; it was only in release-integration.sh).
- Add TestMemoryInstructions covering formatting, max_results cap, empty/error
  fallback, tag passthrough, and missing-config error.

* address review: Literal config types, typed generics, debug log, real-LLM E2E

- Type budget as Literal[low|mid|high] and tags_match as Literal[any|all|
  any_strict|all_strict] across config + tools (matches autogen/continue)
- Parameterize bare list -> list[Any] on register_hindsight_tools
- _ensure_bank: logger.debug the swallowed create_bank failure so a real
  auth/network error is visible rather than only surfacing later on retain
- Add requires_real_llm E2E bucket exercising retain/recall/reflect through
  the (input, ctx) tool call path against a live Hindsight server; exclude
  from PR CI via -m 'not requires_real_llm'
2026-06-16 15:56:53 -04:00
Ben a25b027759 blog(obsidian): Chat With Your Obsidian Vault, grounded in your notes (#2237)
* blog(obsidian): add Obsidian persistent memory post

Walkthrough of the Hindsight Obsidian plugin: one-way vault sync into a
memory bank, grounded chat panel backed by reflect with note citations
and a reasoning disclosure, implicit vault/folder/date scoping, and the
"vault stays the source of truth" design rule. Includes a beta/BRAT
install callout (plugin v0.1.2) and Cloud vs self-hosted setup.
2026-06-16 15:53:21 -04:00
Evoandr266-tech 0ca0226d36 fix(control-plane): expose "shared" observation scope in the Add Document UI (#2212)
* fix(control-plane): expose "shared" observation scope in the Add Document UI

#2202 added the 'shared' observation-scopes mode and synced it across the server,
HTTP model, retain types, and every client (incl. the control-plane client type in
api.ts), but missed the GUI component itself, so users could not select 'shared'
from the Add-Document form. Wire it through bank-selector.tsx: state union, dropdown
item, request build, and a dedicated preview line ('shared' is tag-independent and
maps to a single global scope [[]] server-side). No locale/generated-file changes.

* fix(control-plane): translate shared observation scope copy

---------

Co-authored-by: r266-tech <[email protected]>
2026-06-16 16:41:25 +02:00
Sanderhoff-alt 60fea4c254 docs: fix stale documentation links (#2221)
Update integration and installation documentation URLs so they point
to the public Hindsight docs site instead of stale Cloud docs paths.
2026-06-16 16:18:29 +02:00
Sanderhoff-alt dff223f552 docs: update configuration guidance (#2228) 2026-06-16 15:55:41 +02:00
Nicolò Boschi 3b3f9de291 revert(worker): trust schemas_with_pending_work() result, drop per-poll re-scan (#2236)
Reverts the stale-routine fallback added in #1666. That change re-ran the
per-schema EXISTS scan whenever the default schema was absent from the
routine result — i.e. on every idle poll, since public is almost always in
scope and usually has no pending work. The scan covered ALL schemas, so it
reintroduced the exact N-query storm the routine exists to avoid, precisely
in the large multi-tenant deployments the optimisation targets.

The routine is now trusted wholesale: any schema it does not return is
treated as having no work this cycle (its documented contract). The #1555
concern (an operator routine scoped to tenant_% starving a single-tenant
public deployment) is addressed by guidance instead: do not install the
routine in single-schema deployments — the per-schema fallback is a single
cheap EXISTS check that covers public correctly and cannot starve.

The only piece kept from #1666 is the harmless public->None normalisation
of the routine's output, so a returned 'public' still counts as work.
2026-06-16 15:53:38 +02:00
Nicolò Boschi e6b2e4cb3e test: unregister engine span recorder on fixture teardown (#2229) (#2231)
LLM-trace recorders live in a process-global registry and providers fan every
call out to ALL registered recorders. The engine test fixtures' teardown gated
`mem.close()` — the only thing that unregisters the recorder — on
`mem._pool and not mem._pool._closing` and swallowed exceptions, so when close()
was skipped or raised before the unregister step, the recorder leaked. A leaked,
still-enabled recorder from an earlier test then recorded a later test's LLM
calls into the shared DB, making test_disabled_writes_no_rows flaky
(`assert 6 == 0`).

Route all four engine fixtures through a `_teardown_memory_engine` helper that
always unregisters the recorder in a `finally` (idempotent — no-op when close()
already did it). Add a fast regression test asserting the registry is left clean
even when close() is skipped.
2026-06-16 15:30:46 +02:00
Nicolò Boschi 3fae76e392 fix(migrations): merge two divergent alembic heads (#2234)
#2209 (d4f6a8c2e1b3, drop archive embedding column) and #<links-index>
(2071c7518f88, add memory_links index) were authored off the same parent and
merged in parallel, leaving the DAG with two heads. `alembic upgrade head`
is ambiguous in that state and CI's test_single_head fails for everyone.

Add a no-op merge revision unifying both heads.
2026-06-16 15:13:43 +02:00
Nicolò Boschi 2f075dedea feat(tags): officially surface tags_match=exact across UI, docs, clients (#2230)
The `exact` set-equality match mode landed in the API + generated clients
in #2149 but was never exposed in the control plane, documented, or added
to the hand-maintained SDK wrappers. This completes the feature.

Control plane: add `exact` to the TagsMatch type/unions and to the
tags_match dropdowns in think-view, search-debug-view, and both
mental-model trigger forms; add translated labels to all 10 locales.

Docs: document `exact` in the recall tags_match table + tag_groups,
the reflect tags value list, and the observations scope-listing guide;
regenerate the docs skill mirror.

Clients: add `exact` to the hand-maintained Python and TypeScript wrapper
Literals/unions and docstrings (generated clients already had it; Rust is
generated from openapi.json at build time).

Supersedes #2159.
2026-06-16 14:58:23 +02:00
Nicolò Boschi 08cfa5d369 test(control-plane): validate t() keys resolve against the catalog (#2232)
Add a vitest guard that walks every src .ts/.tsx file, resolves each
useTranslations("ns") binding, and asserts every static t("key") /
t.rich("key") reference maps to a leaf key in en.json. This closes the
gap between the two existing i18n checks: messages.test.ts only compares
locale catalogs against each other (a key missing from *every* catalog,
en included, passes parity), and find-untranslated.ts does the inverse
(flags strings *not* wrapped in t()). Neither walked from a t() call
site back to the catalog, so a missing key only surfaced as a runtime
next-intl error in the browser.

Runs under the existing `npm test` step in the build-control-plane CI
job, so no workflow change is needed.

The guard immediately surfaced 14 keys referenced by the curation
feature (#1976) but missing from all 10 catalogs (filterActive,
filterInvalidated, invalidatedHint, invalidatedFactsTitle, and the
memoryDetailPanel curation*/editField* set). Add translations for all
locales so the suite is green. Supersedes #2226, which patched only
filterActive.
2026-06-16 14:42:17 +02:00
Nathaniel Clay ArnoldandNicolò Boschi 0135fa39c9 fix(mcp): give update_memory/invalidate_memory non-empty descriptions (#2215)
* fix(mcp): give update_memory/invalidate_memory non-empty descriptions

update_memory and invalidate_memory (added in #1976) used an f-string as
their docstring:

    f"""{_EDIT_DOC}
    Args:
        ...
    """

An f-string is an expression, not a string literal, so Python never assigns
it to the function's __doc__ (it stays None). FastMCP derives a tool's
description from __doc__, so both tools — and their bank_id variants — were
registered with an empty description.

Amazon Bedrock's Converse API rejects any toolSpec whose description is an
empty string, so every Bedrock request that advertised these tools failed
mid-stream (surfacing to clients as a generic 'internal error occurred while
processing the stream'). Providers that tolerate empty descriptions were
unaffected, which is why this only showed up on Bedrock.

Fix: pass the shared doc constant explicitly via @mcp.tool(description=...),
matching how retain/recall already register, and keep a plain-literal
docstring for the Args section. Add a regression test asserting every
registered tool exposes a non-empty description (both registration paths).

* test(mcp): statically reject @mcp.tool definitions without a description

AST-parse mcp_tools.py and fail if any @mcp.tool-decorated function
lacks both a description= kwarg and a real string-literal docstring
(an f-string docstring leaves __doc__ None). Complements the runtime
description test by also covering flag-gated tools and pointing at the
offending line; needs no engine mocking.

* chore(lint): enable ruff B021 (f-string used as docstring)

Catches the f-string-docstring footgun repo-wide at lint time — the
root cause of the empty update_memory/invalidate_memory descriptions.
Clean across hindsight-api-slim; tests/** are excluded from lint so the
static test guards that surface instead.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-16 14:22:20 +02:00
Nicolò Boschi 4d799b5629 test: pin migration-remaining-bankid tests to one xdist worker (#2225)
test_migration_remaining_bank_id_text.py runs two tests that share a
module-scoped pg0 instance on a fixed port (5568). CI runs pytest with
`--dist loadgroup`, which — with no xdist_group on the module — can scatter
those two tests across workers that each instantiate the module fixture and
race to provision the SAME instance. That surfaced as recurring flakes:
"Instance already running", a pg_type UniqueViolation (concurrent CREATE
EXTENSION during migrate-to-head), and "server closed the connection".

Pin the module to a single worker with a shared xdist_group so the fixture
provisions the instance exactly once. Test-only change.
2026-06-16 13:42:14 +02:00
Ben 5e71cebc82 fix(docs): deflake memories.py example by draining async ops between curation steps (#2152)
The memories.py API doc example ran update_memory edit → edit-fields →
invalidate → restore back-to-back on the same unit. Each edit re-embeds and
re-consolidates in the background (a tracked consolidation op), so a later
step could race that work and 404 on a unit mid-rewrite — the restore
intermittently failed with "Memory unit not found". The fixed sleep(3) after
the seed retains was also unreliable under CI load with a live LLM.

Replace the sleep with a wait_for_idle() helper that polls list_operations
until the bank has no pending/processing operations, and drain between each
curation step. All waits sit outside the [docs:...] blocks, so the rendered
documentation snippets are unchanged.
2026-06-16 13:40:54 +02:00
Evo a92e25bcf5 fix(api): gate dry-run extraction behind the operation precheck (#2211)
POST .../memories/dry-run-extract (added in #2205, enabled by default) makes a
real LLM call but was the only enabled-by-default LLM-billable route with no
OperationValidator gating. Wire Depends(precheck_for("dry_run_extract")) like
retain/recall/reflect/mental_model_*/files_retain, and move the feature-flag
check into a dependency declared before the precheck so a disabled route still
returns 404 first. No behavior change when no validator is configured.
2026-06-16 13:40:26 +02:00
8927ab73ae perf(api): index memory_links.bank_id on PostgreSQL (#2223)
* perf(api): index memory_links.bank_id on PostgreSQL

bank_id was added to memory_links in c5d6e7f8a9b0 so bank-scoped reads could
filter the link table directly instead of joining memory_units (an 18s+ JOIN on
large banks), but it landed without an index, so every bank_id = $1 predicate
still sequential-scans the whole table.

Add the missing btree, built CONCURRENTLY inside an autocommit_block with IF NOT
EXISTS for idempotency across retries and re-migrated tenant schemas. The Oracle
baseline (o1a2b3c4d5e6) already creates idx_ml_bank_id on memory_links(bank_id);
this brings the PostgreSQL dialect in line. PG-only by design, so the Oracle
slot is intentionally absent.

* fix(migration): drop invalid leftover index before recreating bank_id index

CREATE INDEX CONCURRENTLY can leave an INVALID index behind if a prior
build is interrupted (lock conflict, disk pressure, signal). IF NOT EXISTS
would then skip recreation, leaving bank_id queries on a seq scan forever.
Drop only an invalid leftover of this name (never a healthy index) before
the concurrent (re)build, mirroring b8c9d0e1f2a3.

* perf(api): composite (bank_id, link_type) index + drop dead entity filter

The stats endpoint's bank-scoped link query is
  SELECT link_type, COUNT(*) ... WHERE bank_id = $1 GROUP BY link_type
A composite (bank_id, link_type) index serves the filter, grouping and
count as an index-only scan, vs a bank_id-only index that still heap-reads
every row to recover link_type. link_type is low-cardinality so the extra
column barely grows the index.

Also remove the now-dead 'link_type <> entity' predicate from the stats and
graph-expansion queries: entity edges were deleted from memory_links and are
no longer written (migration e9b2c7d1f3a4); they're derived on demand from
unit_entities. Removing the predicate also lets the composite index cover
the stats query.

---------

Co-authored-by: zommiommy <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-16 12:29:42 +02:00
Sanderhoff-alt d0c54560ad feat(api): improve Chinese temporal query parsing (#2220)
Move explicit period extraction out of DateparserQueryAnalyzer. The analyzer
now delegates range parsing to temporal_periods, which keeps the public API and
non-Chinese rules while Chinese-specific rules and boundary handling live in
chinese_temporal_periods.

Handle simplified and traditional Chinese expressions for relative days,
weeks, months, years, weekends, half-year periods, Chinese month names,
quarters, and rolling past/future windows.

Separate precise point expressions from fuzzy range expressions so phrases such
as 两天前, 前两天, 几天前, and 一两周前 map to the intended constraint shape
instead of relying on dateparser fallback behavior.

Keep open future starts such as 明天起, 下周起, and 三天后开始 unconstrained
because the API only represents closed ranges.

Guard Chinese matching so non-CJK queries skip the Chinese regex path, while
Chinese substring checks avoid treating ordinary names and words as temporal
constraints.
2026-06-16 12:20:59 +02:00
Nicolò Boschi 946af18c91 fix(curation): drop the embedding column from invalidated_memory_units (#2209) (#2210)
* fix(curation): drop embeddings from invalidated_memory_units archive (#2209)

Invalidating a memory copied the live row — including its embedding —
into the invalidated_memory_units archive via INSERT … SELECT. After an
embedding-model switch, the live tables are re-dimensioned but the
archive is not, so the move failed with "expected 384 dimensions, not
1536".

The archive is cold storage and never a recall surface, so it has no
business keeping an embedding. Instead of also migrating its dimension,
stop storing the embedding there at all:

- invalidate: project NULL into the embedding slot on the move
- revert: recompute the embedding from text/dates/entities (mirroring
  how an edit re-embeds) so the reverted unit is searchable again

This makes the archive's embedding-column dimension irrelevant, so a
model switch can no longer trip a dimension mismatch. A forward
migration clears any embeddings earlier versions already stored.

* refactor(curation): drop the archive embedding column instead of NULLing it

Make "the invalidated_memory_units archive holds no embedding" a
schema-enforced invariant rather than a convention the move queries must
remember. The embedding column is dropped (migration d4f6a8c2e1b3, PG +
Oracle), so:

- invalidate moves every memory_units column EXCEPT embedding into the
  archive
- revert moves them back (live embedding defaults to NULL) and recomputes
  the embedding from text/dates/entities

This structurally prevents #2209 — there is no archive vector to fall out
of sync with the live model's dimension, so a model switch can't reintroduce
the dimension mismatch via a future code change. DROP COLUMN is metadata-only
on both dialects.

The archive's readers (get_memory_unit/list enumerate columns; export does
SELECT * then strips derived columns) never referenced embedding, so nothing
breaks.

* refactor(curation): never create the archive embedding column

Remove the embedding column at its creation sites rather than creating it
and dropping it afterward:

- PG: c9a1b2d3e4f5 drops the LIKE-inherited embedding right after cloning
  invalidated_memory_units from memory_units
- Oracle: the baseline CREATE TABLE no longer lists the embedding column

The forward drop migration (d4f6a8c2e1b3) stays as a no-op (DROP … IF EXISTS /
Oracle ORA-00904 swallow) on fresh databases and does the real drop on
databases created before the column was removed here.
2026-06-16 11:57:09 +02:00
Evo abc1439675 fix(skill-docs): convert all admonition keywords in the docs→skill generator (#2218)
The MDX→skill converter in scripts/generate-docs-skill.sh only handled
:::tip / :::warning / :::note, and each rule required an inline title.
So :::info and :::caution admonitions — and any title-less opener (e.g.
a bare :::note) — were left as raw `:::` markdown in the CI-enforced
agent-facing skill mirror (skills/hindsight-docs/references/**), where the
generic `:::\s*\n` cleanup then ate the closing fence and the admonition
body bled into the following section.

Most visibly, #2202 added a :::caution "shared vs [[]] vs []" warning to
retain.mdx, which now renders as broken raw markdown in retain.md.

Teach the converter every supported keyword (tip/note/warning/info/caution)
with an optional inline title, mapping each to a blockquote (title-less
openers fall back to the capitalized keyword). Regenerated the skill mirror;
this also repairs pre-existing :::info/:::caution/title-less leaks across the
core API reference docs.

Note: source files that are plain .md (e.g. configuration.md) are copied
verbatim by the generator rather than run through this converter, so their
admonitions are unaffected here — happy to extend the converter to that
copy path in a follow-up if desired.
2026-06-16 11:12:20 +02:00
DK09876 c05ab9103f feat(continue): add Continue.dev integration via HTTP context provider (#2213)
Adds hindsight-continue: Hindsight memory for Continue.dev via its native http context provider (@hindsight recall) plus an optional MCP-server + rules setup. Includes the adapter package, tests against Continue's HTTP contract + a gated E2E, CI job, release registration, docs, and registry entry.
2026-06-15 14:38:39 -07:00
Ben 359b2bc762 feat(zapier): add Hindsight Zapier app (actions + REST Hook triggers) (#2119)
* feat(zapier): add Hindsight Zapier app (actions + REST Hook triggers)

A Zapier Platform CLI app that brings Hindsight memory into Zaps.

Actions:
- Retain Memory (create) -> POST /v1/default/banks/{bank}/memories
- Recall Memories (search) -> POST .../memories/recall
- Reflect (search) -> POST .../reflect

Triggers (instant, via Hindsight's webhook API — subscribe POSTs /webhooks,
unsubscribe DELETEs it):
- Retain Completed, Consolidation Completed, Memory Defense Triggered

Auth: API key as Bearer token, Cloud default with self-hosted override; the
Bank field is a dynamic dropdown from GET /v1/default/banks.

Built on zapier-platform-core 19; 'private': true so the npm release path can
never publish it (Zapier publishing is manual via zapier push/promote, not
release-integration.yml — and zapier is intentionally NOT in VALID_INTEGRATIONS).

Adds test-zapier-integration CI job (npm install -> zapier validate -> npm test)
and a repo README row. 15 mocha/nock unit tests; 'zapier validate' is
structurally clean.

Docs-site gallery card + doc page + icon are a follow-up (need the official
Zapier brand asset; omitted here to keep build-docs green).

* fix(zapier): make apiKey optional for no-auth self-hosted + prettier-clean

- authentication.js: apiKey now optional (required: false). The middleware only
  adds the Bearer header when a key is present, so you can connect to a
  self-hosted instance running without auth by leaving it blank; Cloud still
  requires a working key (blank -> 401 fails the connection test).
- README: document self-hosted / localhost usage, the optional key, and correct
  the CLI binary name to 'zapier-platform' (v19 renamed it from 'zapier'); show
  the .env approach so 'zapier invoke' needs no global install.
- Run prettier across the integration (fixes pre-existing format drift that was
  failing verify-generated-files on this branch).

zapier validate still structurally sound; 15 tests pass.

* fix(zapier): correct reflect answer field + recall output shape (found via live test)

Extensive live testing against Hindsight Cloud surfaced two response-shape bugs
the mocked unit tests missed (they mocked the wrong shapes):

- reflect: the synthesized answer is in the response's `text` field, not
  `answer`. searches/reflect read `data.answer` (undefined), so a Zap got no
  answer. Now reads `data.text` and surfaces it as `answer`. Test mock fixed to
  use the real `text` field so it actually guards this.
- recall: results carry no numeric `score`, and the fact-type field is `type`
  (not `fact_type`). Corrected the sample + outputFields so the Zap editor only
  advertises fields that actually populate; test mock made realistic.

Verified live end-to-end: auth, bank dropdown, retain (full + minimal), recall
(real fact extraction), reflect (now returns the grounded answer), and the
webhook subscribe/list/delete lifecycle. 15 unit tests pass; zapier validate clean.

* docs(zapier): correct .env auth-field prefix to authData_ in README

zapier invoke reads .env auth fields with the authData_ prefix (e.g.
authData_apiKey, authData_apiUrl), not bare apiKey/apiUrl. Confirmed against a
working local .env during live testing.

* docs(zapier): add integrations gallery card + doc page

- gallery entry in integrations.json (id zapier, official, category framework)
- doc page docs-integrations/zapier.md (actions + REST Hook triggers, setup)
- official Zapier logo at static/img/icons/zapier.png

check-integrations passes (forward: entry → doc page); JSON valid; prettier-clean.

* feat(zapier): verify webhook HMAC signatures + optional async retain (review notes 2 & 4)

#2 — Webhook signature verification (was: relying only on Zapier's unguessable URL):
- performSubscribe now generates a random 32-byte secret and registers it with
  the webhook; the secret is stored in subscribeData.
- perform verifies the X-Hindsight-Signature: sha256=<hmac> header (HMAC-SHA256
  of the raw body) and rejects mismatches. (Corrected the header name — the API
  sends X-Hindsight-Signature, not X-Webhook-Signature; body is delivered
  byte-for-byte via content=, so the recomputed HMAC matches.)

#4 — Optional 'Process asynchronously' toggle on Retain (default false). Lets
users with very large content avoid Zapier's action timeout; pairs with the
Retain Completed trigger.

17 unit tests pass (added valid/invalid signature cases); zapier validate clean.
2026-06-15 16:44:04 -04:00
Ben 78b1df8d8a blog(gemini-spark): Gemini Spark persistent memory via MCP (#2208)
* blog(gemini-spark): add Gemini Spark persistent memory post

Walkthrough of the config-only Hindsight + Gemini Spark integration:
agent-initiated recall/retain over MCP (no plugin host, no hooks),
Hindsight Cloud direct path vs self-hosted OAuth proxy, setup for both
the Antigravity desktop mcp_config.json and the antigravity.yaml manifest.
2026-06-15 13:50:12 -04:00
Parafee41 989f30e215 fix typescript shared observation scope (#2207) 2026-06-15 17:37:41 +02:00
Nicolò Boschi d382b340f0 feat(api): dry-run fact extraction endpoint (preview, no persistence) (#2205)
Add POST /v1/default/banks/{bank_id}/memories/dry-run-extract — a
read-only tool that previews what the retain step would extract from
text WITHOUT changing the bank: extraction only, no entity resolution,
links, embeddings, or persistence. The "dry-run-extract" path makes the
non-mutating nature explicit.

Returns a dedicated DryRunExtractionResult: the candidate facts plus the
aggregated LLM token usage. Each fact (ExtractedFact) is a subset of the
memory-unit shape — only what a fresh extraction produces: text,
fact_type, occurred_start/end, entities[] (raw, unresolved names).

Every prompt-affecting setting is overridable per call (retain_mission,
extraction_mode, custom_instructions, chunk_size, entity_labels,
entities_allow_free_form, llm_output_language) plus the narrator
(agent_name), so a candidate config can be A/B'd against the bank's
current one. The reference date field is named `timestamp` to match the
retain item payload. The engine authenticates the tenant before reading
any bank-scoped config.

Gated by a static server-level flag HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT
(default true). Since extraction makes a real LLM call, set it to false
to remove the endpoint (returns 404) on cost/abuse-sensitive deployments.

Control plane: a "Dry-run extraction" dialog opened from the Memory Bank
actions menu (text input + raw JSON output, side-by-side).

Regenerated OpenAPI spec + Python/TypeScript/Go client SDKs.
2026-06-15 16:25:31 +02:00
Sanderhoff-alt 59d5319b84 feat(retain): make structured chunk size configurable (#2139)
Add retain_structured_chunk_size as an explicit retain chunking knob
for structured inputs. When unset, structured inputs follow the
effective retain_chunk_size instead of the hidden 1.5x overflow factor.

Thread the setting through retain extraction, append/prepend chunking,
bank config resolution, templates, MCP docs, maintained clients,
generated OpenAPI artifacts, the control-plane retain strategy UI, and
the Rust CLI set-config command.

Validate retain_chunk_size and retain_structured_chunk_size as positive
integers while allowing either value to be smaller. Keep the existing
retain_max_completion_tokens check scoped to retain_chunk_size.

Preserve upstream validation details for client errors through the
control-plane proxy so UI alerts and toasts can show concrete
configuration errors without exposing server-side failure details.

Update chunking, config, hierarchical config, template, MCP, client
payload, control-plane serialization, SDK-response, API-client, and
retain UI validation tests for the new behavior.
2026-06-15 15:51:51 +02:00
Nicolò Boschi 460fc63d9b feat(migrations): parallelize tenant schema migrations (#2203)
Add HINDSIGHT_API_MIGRATION_CONCURRENCY to migrate tenant schemas concurrently (each in its own spawn process; per-schema work stays sequential). NullPool on migration engines bounds per-worker connections. Validated at 20k schemas: no-op resweep ~60min->~11min (5x) at concurrency=12, peak 29 connections, 0 errors. Default 1 (sequential).
2026-06-15 15:39:42 +02:00
Nicolò Boschi e20a36c959 feat(api): omit null fields from JSON responses where wire-safe (#2204)
* feat(api): omit null fields from JSON responses where wire-safe

API responses included every optional field as `"x": null`. Install a
custom route class (ExcludeNoneRoute) that enables response_model_exclude_none
for routes whose response model has no required-and-nullable field, so those
nulls are dropped.

Routes whose model carries a required-nullable field (e.g. DocumentResponse
.content_hash, OperationResponse.error_message) keep emitting nulls — omitting
a key the OpenAPI `required` set declares would break strict generated clients
(the Rust progenitor client decodes those without serde defaults). Detection is
recursive over nested models/generics, so it stays correct as models evolve.

The OpenAPI schema is unchanged (exclude_none is runtime-only), so the spec and
all generated clients are byte-identical and existing clients remain compatible.
Verified the published 0.8.2 client deserializes recall/retain/reflect/list
responses against a server running this change.

* test(api): tolerate omitted null fields in response assertions

Responses now omit null optional fields (ExcludeNoneRoute). Update the four
tests that read these keys via direct indexing to use `.get(...) is None`,
which holds whether the key is absent or explicitly null:
- operations progress (OperationStatusResponse / OperationsListResponse)
- bank-health latency_ms (when LLM not configured)
- reflect based_on (null when facts not requested)
- bank export bank/mental_models/directives (empty bank)
2026-06-15 13:50:01 +02:00
formatmeandNicolò Boschi 13f3e081b7 fix(api): mental model delta refresh (prompt size, JSON, consolidation) (#2170)
* fix(api): shrink mental model delta LLM prompts for provider limits

Delta refresh (scope mental_model_delta_ops) was sending the full
structured document, reflect synthesis, and every fact ever merged into
based_on. That payload grew on each refresh and triggered Z.ai HTTP 400
code 1261 (Prompt exceeds max length).

- Send only facts from the current reflect to the structured-delta LLM;
  accumulated based_on remains stored for audit.
- Budget and truncate user prompt sections (~24k cl100k tokens default).
- Use compact JSON for the current document block.
- Keep normal APIStatusError retries for 1261.

Tests: prompt budget, retry behavior, delta plumbing assertion update.

* chore(control-plane): knip ignore react-dom (Next.js peer, no direct import)

* fix(api): delegate with_config on ConfiguredLLMProvider

Consolidation calls _consolidation_llm_config.with_config(...) after an
initial with_config bind; without delegation, Python raised TypeError for
bank_id/operation kwargs on the wrapper class.

Add regression test for re-bind trace attribution.

* fix(api): robust mental model delta JSON + lint sync

- parse_delta_operation_list: parse_llm_json + balanced-object extract
- Prompt: JSON escaping rules for glm-style invalid output
- Ruff format on touched files (verify-generated-files)
- Tests: test_delta_operation_parse.py

* fix(api): skip invalid delta ops instead of full-synthesis fallback

When the model omits required fields (e.g. replace_block without index),
validate operations one-by-one and apply the rest. Tighten structured-delta
prompt on index requirement.

* fix(api): drop dead with_config, guard delta facts + all-invalid ops

- Remove redundant ConfiguredLLMProvider.with_config: the __getattr__ proxy
  already forwards to LLMProvider.with_config (which accepts bank_id), and
  every caller (_retain/_reflect/_consolidation_llm_config) is an LLMProvider,
  so the method was never reached. Drop its test (passed against main too).
- Add regression test locking the delta supporting-facts fix: only THIS
  refresh's facts go to the structured-delta prompt, while based_on still
  accumulates all facts for grounding. Fails on the pre-fix code.
- Harden parse_delta_operation_list: when the model emits ops but every one
  fails validation, raise DeltaAllOpsInvalidError so the caller falls back to a
  full rewrite instead of applying zero ops and silently dropping new facts.
  A genuine empty operations array stays a valid no-op.
- knip.json: restore trailing newline (prettier).

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-15 13:40:18 +02:00
EvoandNicolò Boschi 20f1a77ea0 fix(db): widen remaining live bank_id columns to TEXT on PostgreSQL (#2106 follow-up) (#2175)
* fix(db): widen remaining live bank_id columns to TEXT on PostgreSQL (#2106 follow-up)

* fix(db): drop mental_model_versions from bank_id widen migration

mental_model_versions is created in j5e6f7g8h9i0 but dropped (DROP TABLE
... CASCADE) in o0j1k2l3m4n5 and never recreated on the upgrade path, so
it does not exist at head. ALTER TABLE mental_model_versions therefore
raised UndefinedTable and -- because migrations run inside the
lifespan-startup transaction -- rolled the whole migration back, bricking
API startup (the exact failure class this repair targets).

Widen only the live tables that exist at head and still carry VARCHAR(64)
bank_id: directives and mental_models. Update the test accordingly (it
previously could not pass: the head migration crashed before any
assertion, and the now-removed FK insert referenced the dropped table).

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-15 12:55:45 +02:00
Nicolò Boschi 0a046f97ae feat(consolidation): add "shared" observation_scopes keyword (#2202)
* feat(consolidation): add "shared" observation_scopes keyword

Add a "shared" value for observation_scopes that resolves to a single
global, untagged scope ([[]]). Memories consolidate into one observation
regardless of their tags, while the tags stay on the source facts for
recall filtering.

This is the supported way to deduplicate observations across volatile
per-call provenance tags (e.g. per-session ids): with combined/per_tag,
a unique session tag puts every retain in its own scope, so near-identical
facts never dedup and accumulate one observation per session. "shared"
keeps recall and the dedup probe on the same (empty) scope, fixing
consolidation quality rather than only the duplicate count.

- consolidator: _resolve_obs_tags_list -> [[]], _resolve_write_scopes -> [frozenset()]
- API/engine type literals + OpenAPI + regenerated Python/TS/Go/Rust clients
- CP client type kept in sync
- docs: retain.mdx 'shared' section (+ shared vs [[]] vs [] caveat),
  observations.mdx dedup pointer; regenerated hindsight-docs skill mirror
- tests: unit scope-resolution + e2e parallel-consolidation scope correctness

* chore(opencode): apply prettier formatting to plugin.test.ts

Pre-existing lint drift unrelated to this PR — CI's verify-generated-files
job reformats all integrations (LINT_ALL_INTEGRATIONS) and flagged this file.
Folding the one-line reflow in here to get the gate green.
2026-06-15 12:50:17 +02:00
156a543cf6 docs(reflect): align reflect_async docstring with read-only tool set (#2200)
The docstring listed a 'learn: Create/update mental models with new insights'
step that is not wired into the reflect agent. reflect_async only hands the
agent read tools (search mental models, recall, search observations, expand),
so reflect synthesizes an answer from stored memories and persists nothing.
Update the docstring to match the actual implementation.

Co-authored-by: Kuba Odias <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-15 12:10:56 +02:00
Evo c3970cfd21 fix(metrics): don't count a client-disconnect cancellation as a failed operation (#2185)
run_cancellable_on_disconnect (added in #2127/#2131 for #2122) converts the
engine's OperationCancelledError into HTTPException(499), which propagates out
through record_operation's blanket `except Exception: success = False`, so every
abandoned recall/reflect was counted as a failure on hindsight.operation.total.

Exclude a client cancellation from the counter entirely (neither success nor
failure), detecting it via the exception's __cause__ chain so an unrelated 499
is still recorded as a failure. Adds regression tests.
2026-06-15 12:10:39 +02:00
Miguel de Benito Delgadoandmdbenito 9500a6bf43 fix(opencode): drop utility re-exports from plugin entry to satisfy legacy loader (#2193)
OpenCode's legacy plugin loader (getLegacyPlugins) iterates Object.values(mod)
and calls every function export as a Plugin factory. It deduplicates by
reference, so default and HindsightPlugin (same fn) are fine. But the entry
also re-exported loadConfig and deriveBankId, which the loader invokes as
plugins and registers as hooks — returning a string and a config object
respectively, neither of which is a valid hooks object.

Earlier versions of the dist (e.g. 0.2.1) additionally exported
DEFAULT_HINDSIGHT_API_URL as a string constant, which the loader would call
as a function and crash on with 'Plugin export is not a function'. That was
fixed in 0.2.2 by dropping the string re-export, but the function re-exports
remained and would still produce silently-wrong hook objects on every session.

The plugin itself imports loadConfig and deriveBankId directly from their
submodules, so removing the re-exports is backwards-compatible: no internal
callers change, no public API is removed (these were undocumented
convenience re-exports), and the default export remains a callable function
for direct import.

Add a regression test that asserts the entry has exactly two function
exports, both pointing at the same reference. This is the legacy-loader
invariant: anything else will be incorrectly invoked.

Closes the use case for the local plugins/hindsight.js wrapper required to
load the package as an npm plugin.

Co-authored-by: mdbenito <[email protected]>
2026-06-15 12:10:14 +02:00
Evo 3d6b19af59 fix(search): use effective-time fallback (mentioned_at, occurred_end) for recency scoring (#2197)
* fix(search): use effective-time fallback for recency scoring

* test(search): cover mentioned_at/occurred_end recency fallback

* style: apply ruff format (collapse multi-line ternaries within 120c)

Clears verify-generated-files CI: ruff format collapses the recency
effective-time fallback (reranking.py) and a main-drift one-liner in
consolidator.py that both fit the 120-char line length.
2026-06-15 12:09:36 +02:00
Evo 99fe231b36 docs(litellm): replace removed opinion fact-type with observation (#2198)
* docs(litellm): replace removed opinion fact-type with observation

* style: apply ruff format to consolidator.py (CI generator-sync)

verify-generated-files requires committed files match ruff format output;
collapses a main-drift multi-line ternary that fits the 120-char limit.
2026-06-15 12:09:03 +02:00
Evoandr266-tech 9be7f59c03 docs(models): register the nous provider so the Models page lists it (#2128)
#2102 added the native Nous Portal provider to config.py
PROVIDER_DEFAULT_MODELS and the models.mdx prose, but not to
hindsight-docs/src/data/llmProviders.json -- the single source of truth
that renders the Models page provider grid, default-models, and
capabilities tables -- so Nous is documented in prose but invisible on
the canonical Models grid.

Add the nous entry and regenerate the CI-enforced skill mirror via
scripts/generate-docs-skill.sh. Same pattern as #1911 (fireworks).

Co-authored-by: r266-tech <[email protected]>
2026-06-15 12:05:01 +02:00
Ben 42e72601f4 blog: Cursor persistent memory (editor + CLI in one post) (#2171)
* blog: add Cursor persistent memory post covering both integrations

One post covers both new integrations:
- hindsight-cursor (editor, first-party): plugin hooks + MCP server,
  with the Cursor 3.x additionalContext workaround via workspace
  rules-file fallback
- hindsight-cursor-cli (CLI, community-built by @Korayem): four
  lifecycle hooks (sessionStart, beforeSubmitPrompt, stop, sessionEnd)

The angle of the post is that both surfaces can share a single bankId
and switch between editor and CLI mid-task without losing context.

Every claim sourced from the integrations' README files:
- Editor: install commands, sessionStart/stop hooks, MCP config, the
  Cursor 3.x bug + rules-file workaround, useRulesFileFallback flag,
  bankId default = "cursor"
- CLI: four-hook table, install command, ~/.cursor/hooks.json shape,
  Cursor CLI v0.45+ requirement, bankId default = "cursor-cli",
  dynamicBankGranularity including gitProject

Test stamps from both integrations on current main:
- hindsight-cursor: 82 passed, 4 skipped
- hindsight-cursor-cli: 87 passed, 0 skipped

Cover is a placeholder (Codex art) for now; swap before merging.


* blog(cursor): swap placeholder cover for Hindsight x Cursor card
2026-06-12 15:17:38 -04:00
Nicolò Boschi 4835bf73d2 docs: add Memory Defense + missed items to 0.8.2 blog post (#2173) 2026-06-12 18:05:42 +02:00
Nicolò Boschi a38fc3453c docs: changelog and blog post for v0.8.2 (#2172)
* docs: changelog and blog post for v0.8.2

* docs: clarify per-bank cost attribution is opt-in via env flag
2026-06-12 17:53:50 +02:00
Nicolò Boschi 6f59a09479 Release v0.8.2
- Update version to 0.8.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.8
2026-06-12 17:44:54 +02:00
Nicolò Boschi ea45930949 fix(docs): correct Memory Defense link after dir conversion (#2077) 2026-06-12 17:42:47 +02:00
Ben 9a5aecd178 release(agent-framework): v0.1.0 2026-06-12 08:40:23 -04:00
Nicolò Boschi d81486ff9b fix(control-plane): stop double-fetching graph data on bank view (#2168)
* fix(control-plane): stop double-fetching graph data on bank view

The DataView component had two effects that each loaded graph data — one
keyed on factType/bank/document/chunk, one on tag/scope filters. Both run
on initial mount, so every bank/tab view fired /api/graph twice (issue
2158).

Collapse them into a single auto-loader. When the context changes we drop
the now-meaningless observation scope and feed the cleared value straight
into the same reload, guarding the setSelectedScope(null) echo render with
a ref so the reset never produces a second fetch.

Refs #2158

* fix(control-plane): make graph auto-load idempotent

Add a fetch-signature guard so identical consecutive auto-loads collapse
to a single /api/graph request. This defeats React's mount-effect
double-invoke (dev StrictMode, client-side navigation) and any redundant
re-render that would otherwise re-issue the same query — verified with
Playwright that switching fact-type tabs now fires exactly one request
per view (was two on tab clicks in dev).

Manual reloads (search, load-more, consolidation poll) call loadData
directly and intentionally bypass the guard.

Refs #2158
2026-06-12 12:49:51 +02:00
Evo f6710963e6 fix(consolidation): honor an observation scope limit of 0 (no new observations) (#2163)
The per-scope observation limits added in #2140 document 0 as 'no new
observations', but the two call-site guards used '> 0', so a configured limit of
0 left remaining_observation_slots=None and _build_response_model(None) built an
unconstrained model -- limit:0 behaved like unlimited, the inverse of intent.

Make the guards '>= 0' (matching the truncation guard which already uses '>= 0'),
short-circuit the count query for the 0 case, and add a regression test asserting
a scope cap of 0 creates no new observations.
2026-06-12 11:56:47 +02:00
Ben a63a0c0e70 docs(api): list all 3 supported webhook event types in CreateWebhookRequest (#2155)
The event_types field description said 'Currently supported: consolidation.completed',
but the API actually emits and accepts all three events:
- retain.completed (memory_engine.py)
- consolidation.completed (memory_engine.py)
- memory_defense.triggered (retain/orchestrator.py)

Update the description accordingly and propagate through the regenerated OpenAPI
spec + the embedded copies in the go/python/typescript clients. Description-only
change; no behavior change.
2026-06-12 11:55:51 +02:00
Nicolò Boschi 30fb287d10 fix(api): normalize torch default dtype to float32 after concurrent model init (#2167)
transformers' dtype context manager (entered by SentenceTransformer /
CrossEncoder / from_pretrained) does a non-thread-safe save/restore of the
process-global default dtype. When an fp16 embedding model and an fp32
reranker/query-analyzer load in parallel during MemoryEngine.initialize(), an
unlucky interleave can leave the global default stuck at float16. Every later
encode() then emits NaN vectors that pgvector rejects ("NaN not allowed in
vector") on MPS, or raises "c10::Half != float" on CPU -- non-deterministically
across restarts.

Keep the model loads fully parallel and, once asyncio.gather() has joined every
load thread, normalize the global default dtype back to float32 -- the inference
state a healthy boot already converges to. The reset is race-free (all threads
have finished) and only touches torch if a local provider actually loaded it.

Fixes #2162
2026-06-12 11:51:07 +02:00
Nicolò Boschi f0802b826b chore(ci): enforce unused imports/vars + advisory dead-code scan (#2144)
* chore(ci): enforce unused imports/vars + advisory dead-code scan

Enable ruff F401 (unused imports) and F841 (unused variables) -- previously
ignored as "too noisy" -- across hindsight-api-slim, hindsight-dev, and
hindsight-embed, and clean up the resulting violations. These are now blocking:
lint.sh auto-removes them and the verify-generated-files CI job fails on any
leftover diff.

Add an advisory dead-code scan for what the linter cannot see -- whole unused
Python functions (vulture) and orphaned files/exports/dependencies in the
control plane (knip):
- scripts/hooks/check-unused.sh runs both locally
- new non-blocking check-unused-code CI job surfaces findings on PRs
- hindsight-control-plane/knip.json tunes out toolchain false positives

vulture stays advisory because its function/argument heuristics false-positive
on FastAPI/SQLAlchemy/Pydantic patterns; knip can be flipped to blocking once
the control-plane dead code (PR #2135) lands.

* chore(ci): make knip blocking on unused files/deps; remove dead deps

#2135 deleted tooltip.tsx but left @radix-ui/react-tooltip in package.json, and
react-chrono / three were never imported. Remove all three, and declare
@radix-ui/react-visually-hidden (used in directive-detail-modal but unlisted).

With the control-plane tree now clean, the check-unused-code job runs
`knip --include files,dependencies,unlisted` as a BLOCKING step. vulture and
knip's unused-exports check (the shadcn/ui surface is kept intentionally) stay
advisory.
2026-06-12 11:06:31 +02:00
Chris Latimer 87448b1616 fix(webhook) missing fields in payload 2026-06-12 00:14:47 -06:00
Chris Latimer f304ce7e01 feat(webhooks): SIEM enrichment fields on MemoryDefenseEventData
Adds five optional fields to MemoryDefenseEventData so downstream extensions
(e.g. hindsight-cloud) can surface the per-decision context SIEM operators
need to act on a leaked-secret webhook: severity, the API key that submitted
the retain, fingerprinted hit previews for correlation against credential
inventories, and pointers into the audit trail.

Backward-compatible: all five fields default to None and OSS's built-in
regex defense leaves them unset, so existing OSS receivers see no shape
change. Receivers should treat absence as "not provided" rather than "no
match" — the OSS path still populates matched_types as before.

The hit preview is wrapped in a new MemoryDefenseHit model whose docstring
pins the rule that preview must be a fingerprinted rendering of the value,
never the raw secret. Validates that both detector and preview are present
to guard against extensions accidentally posting the raw value as the only
field.

Closes the gap that motivated keeping a separate memory_defense.violation
event in cloud before the recent consolidation: cloud can now ship the
same SIEM-actionable payload through the canonical memory_defense.triggered
envelope.

feat(webhooks): populate hits[] with fingerprinted previews on OSS

Builds on the schema added in the previous commit by populating the
SIEM-relevant hits field from the OSS regex defense. SIEM receivers
now get a per-match preview (e.g. ghp_AAAA...AAAA) for every redaction
the OSS extension fires, in addition to the existing matched_types list.

Three changes:

1. New _fingerprint_value helper produces a length-aware redaction-
   identifiable rendering of a matched value:
   - length < 6:  returns "[redacted]" (avoid leaking material on
     short matches like an isolated -----BEGIN... marker)
   - length 6-15: first-2 + ellipsis + last-2
   - length > 15: first-4 + ellipsis + last-4
   The raw value never appears in the output.

2. apply_redaction returns a hits list alongside matched_types - one
   entry per matched substring (so two GitHub tokens produce two hits
   rather than collapsing into a single label). Hits threaded through
   RedactionResult -> DefenseDecision -> MemoryDefenseEventData via the
   orchestrator's fire helper.

3. _fire_memory_defense_webhook translates the decision's raw hit dicts
   into MemoryDefenseHit entries. None when the decision carries no
   per-hit data so receivers can distinguish "no preview info" from
   a hypothetical empty list.

Test plan:
- New unit tests for _fingerprint_value across all three length
  buckets (parametrized) plus apply_redaction shape: per-match
  fingerprinted previews, raw value never present, multiple matches
  of the same pattern produce multiple hits.
- Extended test_screen_redacts_secret to assert the regex extension
  passes hits onto DefenseDecision.
- Extended test_retain_fires_webhook_on_redact to assert the wire
  payload carries hits[].
- Helper _memory_defense_webhook_events now orders most-recent-first
  so events[0] always reflects the latest delivery.
- Full test_memory_defense.py + test_webhooks.py: 100 passed.
- ruff + ty: clean.

feat(webhooks): more useful message
2026-06-11 23:27:41 -06:00
DK09876 c6db44b101 fix(test): update hierarchical-config count for observation_scope_limits (#2156)
#2140 (per-scope observation limits) added observation_scope_limits to
_CONFIGURABLE_FIELDS but didn't bump the count tripwire in
test_hierarchical_fields_categorization, so it asserts 38 while the real count
is 39 — failing test-api deterministically on every open PR.

The field is correctly configurable (a per-bank behavioral override). Bump the
count to 39 and add an explicit assertion for the field, matching the test's
documentation pattern.
2026-06-11 15:36:50 -07:00
Chris Latimer 7d57711e75 Merge remote-tracking branch 'origin/main' into feat/parser-accept-cloud-detectors 2026-06-11 14:23:56 -06:00
Chris Latimer 4d369a9a89 fix(broken tests) 2026-06-11 14:13:53 -06:00
Ben e24614db22 blog: Haystack persistent memory (drop-in tools + auto-recall wrapper) (#2147)
* blog: add Haystack persistent memory integration post

Walkthrough of hindsight-haystack — two integration modes:
- create_hindsight_tools() returning a list[Tool] for an Agent
- HindsightMemoryWrapper, a Toolset subclass with auto_recall and
  auto_retain that runs the memory work before/after each turn.
Plus the three memory primitives (retain/recall/reflect) and the
include_* flags to drop any subset.

Every concrete claim verified against the README and
hindsight_haystack/tools.py:
- Package name + version (0.1.0)
- Python >= 3.10, haystack-ai >= 2.12.0, hindsight-client >= 0.4.0
- Exported names from __init__.py
- create_hindsight_tools() and HindsightMemoryWrapper signatures
- "Use toolset.run(agent, ...) not agent.run(...) for auto behavior"
- configure() shape and acceptable kwargs

Underlying integration unit tests: 83/83 passing (3 e2e skipped for
lack of API keys in CI sandbox).

Cover is a placeholder (Codex art) for now; swap before merging.
2026-06-11 15:16:25 -04:00
Ben f5a6c300f1 feat(agent-framework): Hindsight memory for Microsoft Agent Framework (no MCP) (#1989)
* feat(agent-framework): add Hindsight memory integration via context provider

Persistent memory for Microsoft Agent Framework (the successor to Semantic
Kernel) without MCP. HindsightProvider is a ContextProvider whose before_run
recalls relevant memories and injects them into the agent's instructions, and
whose after_run retains the conversation. Reuses the LlamaIndex integration's
client/config pattern and the hindsight-client Python SDK.

Targets the agent-framework-core 1.x before_run/after_run + SessionContext
contract (verified against the installed package since the API has churned).
15 unit tests subclass the real ContextProvider so drift fails loudly, plus a
gated e2e. Includes CI job, release + changelog + docs wiring, and an icon.

* chore(agent-framework): refresh lock to agent-framework-core 1.8.1 (verified no API drift)

* fix(agent-framework): drop unused per-op timeout constants

TIMEOUT_RETAIN/TIMEOUT_RECALL/TIMEOUT_BANK were defined but never used: the
hindsight-client SDK sets one timeout on the constructor and has no per-call
timeout argument, so per-op values can't be wired in. Keep the single
constructor-level TIMEOUT_DEFAULT and document why. Addresses review feedback.
2026-06-11 13:57:20 -04:00
Ben 443cce8146 docs(integrations): use the Gemini logo for Gemini Spark, not the generic MCP icon (#2146)
The Gemini Spark gallery card showed the generic MCP paperclip icon
(/img/icons/mcp.png). Every other named-product integration uses its
own brand mark, so swap in the official Google Gemini 2025 sparkle
(public-domain logo from Wikimedia Commons, {{PD-textlogo}}).
2026-06-11 13:47:44 -04:00
Ben 54e1242193 docs(superagent): add prerequisites to integration quick start (#2137)
* docs(superagent): add prerequisites to integration quick start

The quick-start example calls Superagent guard/redact on the first
retain (both on by default), so it fails immediately without the
required keys. The page documented none of them. Add a Prerequisites
section covering SUPERAGENT_API_KEY and OPENAI_API_KEY, clarify the
hindsight_api_url endpoint (self-hosted vs Cloud), and note that
Superagent's hosted guard-model endpoints are currently unreliable.

* docs(superagent): default the quick start to Hindsight Cloud

Drop the explicit localhost URL so the example uses the package's
default Cloud endpoint (https://api.hindsight.vectorize.io), add
HINDSIGHT_API_KEY to the prerequisites, and show self-hosting as the
opt-in alternative.
2026-06-11 13:43:06 -04:00
Ben 9197498b70 blog: 763,365 downloads in 30 days: Hindsight crosses 1M (#2134)
* blog: hindsight-client passes 1,000,000 downloads
2026-06-11 13:41:25 -04:00
Nicolò Boschi b08f43496a feat(observations): enumerate + filter + visualize observation scopes (#2149)
* feat(observations): enumerate + filter observations by scope

Add an exact (set-equality) tag match mode, a list_observation_scopes
engine method + GET /observations/scopes endpoint, and a scope filter in
the control-plane Observations tab (list + graph views). A scope is the
exact tag set an observation was consolidated under; the empty set is the
global/untagged scope. Regenerated OpenAPI + clients + docs skill.

* fix(i18n): add missing memoryDetailPanel curation* keys

invalidate-memory-dialog.tsx references memoryDetailPanel.curationInvalidateTitle/
Explain/ReasonPlaceholder/Cancel/Invalidate, but these keys were never added to any
locale (the parity test passed because all 10 locales lacked them equally), so the
invalidate dialog logged IntlError: MISSING_MESSAGE and rendered raw key names.
Add all five strings across the 10 locales. Pre-existing gap, unrelated to scopes.

* fix(observations): keep scope-filter trigger single-line for long/multi tags

The scope dropdown trigger relied on SelectValue, which clones the selected
item's wrapping pill layout; a multi-tag or long-tag scope (e.g. [session:2,
user:nicolo]) wrapped to two lines and overflowed the fixed-height control.
Render a compact, single-line, truncating summary in the trigger instead,
keeping the full pills only in the open dropdown list.

* feat(documents): capture observation_scopes in retain_params, show in detail dialog

observation_scopes passed at retain time was only persisted per source fact
(memory_units), never on the document, so the document detail dialog couldn't
show which scoping was requested. Capture it into documents.retain_params in
_build_retain_params (alongside context/event_date/metadata) and surface it as
a top-level field on the get_document response. The control-plane document
detail dialog now shows an 'Observation scopes' row (mode badge or scope chips).

New-documents-only by design: existing docs have no captured value and show
nothing. Note: this also clarifies that all_combinations on 2 tags correctly
creates 3 scopes — the transient '2' is async consolidation still in flight.

* feat(observations): live consolidation refresh + scope clusters on the constellation

Two UX improvements to the observations view:

1. Live refresh while consolidating. The 'In Sync' badge previously read a
   one-shot, up-to-60s-cached stat, so it could show green while observations
   were still materializing (each scope is a separate consolidation pass). The
   view now polls every 4s while pending_consolidation > 0, silently refreshing
   the observations, scope list, and badge in place until consolidation settles.

2. Group-by-scope clustering on the Constellation. A new 'Group by scope' toggle
   lays observations out around per-scope centroids (instead of the id-hash ring),
   colors each scope distinctly, and wraps each scope's nodes in a translucent,
   labeled convex-hull blob — so overlapping tag scopes read as visual clusters.
   Adds clusterKeyFn/clusterColorFn/clusterLabelFn props to Constellation and an
   inline monotone-chain convex hull; suppresses the heat legend while clustering.

* fix(observations): cap scope dropdown height to the viewport

With many scopes the scope filter dropdown grew past the bottom of the screen.
Cap its height at min(60vh, --radix-select-content-available-height) so it fits
the space below the trigger and scrolls for the rest, instead of overflowing.

* feat(observations): make the scope filter a searchable combobox

Replace the plain Select with a Popover + Command (cmdk) combobox so scopes can
be searched by typing — matching the tag filter's search UX — which matters once
a bank has many scopes. Uses a substring filter over each scope's tags (not
cmdk's fuzzy default, which over-matches scattered letters). Keeps the compact,
single-line, height-capped trigger; selection still applies exact-scope filtering.

* chore(cli): mark list_observation_scopes UI-only in coverage manifest

The new scope-enumeration endpoint powers the control-plane scope filter/clusters
and isn't a useful end-user CLI command, so add it to the [skip] list (matches
the other UI-only endpoints) to satisfy check-cli-coverage.

* fix(tests): import TokenUsage from response_models

#2135 removed the TokenUsage re-export from llm_wrapper, but test_load_large_batch
and test_retain still imported it from there, breaking test collection across the
API test jobs. Import it from response_models (where it's defined), matching every
other test.
2026-06-11 19:33:37 +02:00
Chris Latimer f15b93f5cb fix(broken tests) 2026-06-11 11:15:04 -06:00
Nicolò Boschi 5b9027ef16 Merge remote-tracking branch 'upstream/main' into feat/parser-accept-cloud-detectors 2026-06-11 18:45:10 +02:00
Nicolò Boschi b385393b6d feat(consolidation): per-scope observation limits (#2140)
Add an `observation_scope_limits` config field that overrides the bank-wide
`max_observations_per_scope` on a per-scope basis. Each rule maps a scope
pattern (a list of fnmatch tag-globs) to a limit; a consolidation scope
matches under *exact cover* — every tag matched by a glob and every glob
matched by a tag — so `["shared"]` caps the `{shared}` scope without affecting
`{run_1, shared}`, and `["run_*", "shared"]` caps the combined scope only.
The first matching rule wins; scopes matching no rule fall back to
`max_observations_per_scope`.

- config: new `HINDSIGHT_API_OBSERVATION_SCOPE_LIMITS` (JSON), hierarchical
  (per-tenant/bank overridable)
- consolidator: resolve the cap per scope at slot computation; wildcards live
  only in the resolution layer so the SQL count stays exact and indexed
- exposed on `BankTemplateConfig`; regenerated OpenAPI + clients
- unit tests for rule parsing, exact-cover matching, and resolution
2026-06-11 18:27:43 +02:00
Nicolò Boschi 8e6dc5fcd7 fix(control-plane): drop empty meeting-note message that failed locale guard
The enterprise discovery panel used an empty-string en.json value as a
"render nothing for English" sentinel, but the locale catalog guard
(tests/messages/messages.test.ts) bans empty leaf values. Remove the
memoryDefenseEnterpriseMeetingNote key from all locales and the conditional
render — it was low-value copy (a language disclaimer on a demo CTA) and the
source of the build-control-plane / test-hindsight-all failures.
2026-06-11 18:06:24 +02:00
Nicolò Boschi b5f97418cf refactor(memory-defense): accept any detector name instead of a fixed union
The parser no longer gates rules[*].on against a hardcoded detector list.
Unknown detectors are silent no-ops in the OSS extension anyway (only
sensitive_data is screened), so pinning the OSS roster to cloud's just forced
an OSS bump for every new cloud detector to avoid 422-ing a write it never
interprets. on now only has to be a non-empty string; dispatch and
entitlement stay the loaded extension's job.

Also soften the enterprise discovery panel's emerald styling to a more
refined low-saturation tint.
2026-06-11 17:40:29 +02:00
Sanderhoff-alt c96106cc01 chore: remove dead code and stale config (#2135)
Remove unreferenced backend helpers, stale UI/docs components, and
unused imports across the API, control plane, clients, and integrations.

Drop obsolete consolidated-observation helpers and unused scoring code,
clean orphaned React/docs components, and remove stale Radix dependencies.

Align release scripts, Helm docs, lockfiles, generated clients, and
current API examples with the package and endpoint surface still in use.
2026-06-11 17:12:03 +02:00
Nicolò Boschi 4032b27912 feat(embed): local control center web app (#2132)
A persistent, localhost-only control center web app bundled in hindsight-embed:
LLM config wizard, raw .env editor (with effective-only view), daemon +
control-plane Start/Restart/Stop with live API/UI health, editable per-profile
API/UI ports + component versions, daemon + control-plane log tail, profile
delete, deep-linking, token-gated /api/* (CSRF-safe), localhost everywhere.

UI built with Preact + Tailwind (Vite), output committed to static/ and served
by the embed's stdlib http.server (no Node at runtime, offline). Ports moved
from metadata.json into each profile's .env (HINDSIGHT_API_PORT /
HINDSIGHT_EMBED_CP_PORT). CI job verifies the bundle builds + is wired.
2026-06-11 17:09:35 +02:00
Ben e8884bc0a0 docs: remove broken gitcgr code-graph badge (#2133)
The gitcgr.com SSL certificate has expired, so the code-graph badge
image (added in #648) renders as a broken-image icon in the README for
all visitors. Remove it since the third-party service appears defunct
and we don't control the cert.
2026-06-11 17:06:28 +02:00
Chris Latimer 259c82ec01 feat(memory-defense): accept full 7-detector vocabulary in parser
The OSS regex extension still only enforces sensitive_data, but the parser
should accept the full known detector vocabulary so cloud-shape policies
(prompt_injection, size_anomaly, protected_keys, detect_secrets,
base64_decode, llm_screen) pass through the OSS PATCH layer unchanged.
Dispatch + entitlement enforcement happen in the loaded extension; an
on-name the active extension doesn't implement is a silent no-op.

Adds test_parse_policy_accepts_full_detector_union covering all 7 names.
The reject-unknown-detector test still passes for unrelated values like
"nope".
2026-06-11 09:04:04 -06:00
Ben b83f621611 fix(docs): credit cursor-cli integration to its community author (@Korayem) (#2109)
The Cursor CLI integration was contributed by Salem Korayem (@Korayem) in
PR #1975, but the integrations gallery listed it as official/Hindsight Team.
Flip it to a community entry attributed to the author.
2026-06-11 09:16:31 -04:00
Nicolò Boschi 19e607b287 fix(api): make recall/reflect disconnect cancellation actually work (#2131)
The cancellation merged in #2127 never fired in production. Live testing
(curl --max-time against a real server) showed abandoned recall/reflect
requests still ran to completion; 0 cancellations under a 4000-request storm.

Two root causes, both found by black-box testing + ASGI probes:

1. Request.is_disconnected() is broken behind BaseHTTPMiddleware. This app
   installs two @app.middleware("http") handlers (BaseHTTPMiddleware), which run
   the route in a child task behind anyio memory streams, so http.disconnect
   never reaches the route's Request and is_disconnected() returns False forever.
   The #2127 watcher therefore never tripped. (Reproduced in isolation: one
   no-op @app.middleware("http") flips detection from working to broken.)

   Fix: ClientDisconnectCancellationMiddleware, a pure-ASGI middleware installed
   OUTSIDE the BaseHTTPMiddleware layer where it owns the real receive channel.
   It drains receive in a background pump, trips a CancellationToken on
   http.disconnect, and stashes it on the ASGI scope. Only wraps recall/reflect
   (small JSON bodies); everything else passes straight through.

2. Even once the token tripped, recall did not cancel: _search_with_retries
   wraps its whole body in a broad except Exception and re-raises as RuntimeError,
   burying OperationCancelledError. Fix: re-raise OperationCancelledError ahead of
   the broad handlers (both the search body and the connection-retry loop).
   Kept OperationCancelledError as a plain Exception (not BaseException) on
   purpose: BaseException dodges the broad handlers but also slips past the reflect
   agent's isinstance(result, Exception) gather handling and crashes it.

run_cancellable_on_disconnect now just reads the scope token onto RequestContext;
the polling is_disconnected() watcher is gone.

Verified live (real server, real corpus): RECALL CANCELLED and REFLECT CANCELLED
fire; a 30s socket-closing storm produced 397 recall + 80 reflect cancellations
with 40/40 canary recalls served, health all 200, 0 errors, full recovery.
Reflect cancellation is best-effort between agent iterations (a disconnect during
the final-answer LLM call is not interruptible), analogous to the rerank stage.
2026-06-11 12:52:38 +02:00
Nicolò Boschi bc83eacc7b chore(embed): drop unused HINDSIGHT_EMBED_BANK_ID + fix HINDSIGHT_EMBED_LLM_* docs (#2130)
* chore(embed): remove unused HINDSIGHT_EMBED_BANK_ID config var

`HINDSIGHT_EMBED_BANK_ID` was collected (env + interactive/non-interactive
configure), persisted to the profile .env, printed, and round-tripped through
config dicts, but never consumed: the daemon env-builder and run_cli ignore
the `bank_id` key, the memory commands (`memory retain|recall|reflect <bank>`)
take the bank as a required positional arg, and hindsight-api only reads
`HINDSIGHT_API_*` vars. The only reader was a test assertion.

Removes the prompt/read/persist sites in cli.py, the doc rows in the embed
README and sdks/embed.md, and updates the two tests that referenced it.

* chore(embed): fix HINDSIGHT_EMBED_LLM_* docs to the real HINDSIGHT_API_LLM_*

The embed docs/README documented `HINDSIGHT_EMBED_LLM_API_KEY` (marked
Required), `_PROVIDER`, and `_MODEL` as the user-facing LLM config, but no
code reads those names — the CLI honors `HINDSIGHT_API_LLM_*` / `OPENAI_API_KEY`,
`configure` writes the `HINDSIGHT_API_` prefix, and the daemon env-builder
forwards `HINDSIGHT_*` keys verbatim (no EMBED→API rewrite). A user following
the docs literally got "LLM API key is required".

Renames every `HINDSIGHT_EMBED_LLM_*` occurrence to the working
`HINDSIGHT_API_LLM_*` in sdks/embed.md, the embed README, and the two profile
tests (which only assert .env round-trip). Also drops a leftover "memory bank
ID" mention from the README configure description.
2026-06-11 12:37:25 +02:00
Nicolò Boschi 4e7780e593 feat(retain): chunk JSONL at line boundaries (#2126)
* feat(retain): chunk JSONL at line boundaries

Newline-delimited JSON (e.g. session logs) now chunks the same way as
JSON conversation arrays: whole lines are packed into chunks so no line
is split mid-object. This lets JSONL be ingested with mode `append`
without manual coercion to a JSON array.

A single line/turn that overflows the budget is kept whole only up to
1.5x (_CHUNK_OVERFLOW_FACTOR); beyond that it is split as text. The
extractor has no second re-chunk pass, so an unboundedly oversized chunk
would just error at the LLM — this caps the overflow for both the new
JSONL path and the existing conversation-array path.

Closes #2113

* test(retain): assert exact chunk output across text/JSONL/conversation modes
2026-06-11 11:55:39 +02:00
Nicolò Boschi e0221cae6e docs: flag Intel (x86_64) macOS as slim-only in supported-platforms grid (#2115) (#2129)
* docs: flag Intel (x86_64) macOS as slim-only in supported-platforms grid (#2115)

`pip install hindsight-all` on Intel Macs silently backtracks to a
months-old release: every release since 0.4.18 pulls hindsight-api-slim[all],
whose local-ML extra requires torch>=2.6.0 and mlx, neither of which ships
x86_64 macOS wheels. The docs' supported-platforms grid claimed Intel macOS
bare-metal pip was "fully supported", which is false.

- Split the macOS grid row into Apple Silicon (fully supported) and
  Intel/x86_64 (Docker + pg0 , bare-metal pip ⚠️ slim only).
- Mirror the grid into README.md.
- Point Intel-Mac users to hindsight-all-slim / hindsight-api-slim plus a
  hosted embeddings/reranker provider or the in-process ONNX backend
  (which has x86_64 macOS wheels).
- Replace the ad-hoc warnings with one-line pointers to the grid.

Refs #2115

* docs: move Supported Platforms grid to bottom of README

* docs: simplify README platform table to icons, link docs for details
2026-06-11 11:53:16 +02:00
Nicolò Boschi 07c85da988 fix(api): cancel abandoned recall & reflect via cooperative cancellation token (#2127)
* fix(api): cancel abandoned HTTP recall via cooperative cancellation token

Recall ran to completion even after the client disconnected, burning ~2 CPUs
for 60-95s per abandoned request and accumulating toward RECALL_MAX_CONCURRENT
until the instance starved (issue #2122).

Approach: a CancellationToken carried on RequestContext (already threaded into
every engine operation) that the engine checks at recall pipeline stage
boundaries (pre-retrieval, pre-rerank, pre-enrichment), aborting before
dispatching the next expensive stage. The HTTP layer attaches a token that
fires when the client disconnects and maps the resulting OperationCancelledError
to 499.

This is cooperative: it cannot interrupt work already inside a worker thread
(the cross-encoder rerank runs via run_in_executor and cannot be cancelled
once dispatched), but it stops an abandoned recall from progressing into - or
past - that work. The token lives on RequestContext so reflect/consolidation/MCP
and a deadline-based driver can adopt the same checkpoints later.

Scoped to HTTP recall; internal recalls pass no token so checkpoints are no-ops.

* fix(api): extend disconnect cancellation to reflect; share HTTP wiring

Reflect has the same abandoned-work problem as recall (agentic LLM loop +
nested recalls). Thread the same RequestContext cancellation token through it:
the agent loop checks between iterations and the nested recall tool already
checks at its stage boundaries, so an abandoned reflect stops instead of
running every remaining LLM round-trip (issue #2122).

Factor the HTTP wiring into a shared run_cancellable_on_disconnect() helper
used by both the recall and reflect handlers: it attaches the disconnect-driven
token and maps OperationCancelledError to 499, so neither handler duplicates
the try/except.

run_reflect_agent gains an optional cancel_check hook (default None -> inert),
so internal/non-HTTP reflect callers are unaffected.
2026-06-11 11:24:29 +02:00
Mani Saint-Victor 0afa046fc5 fix(migrations): catch CommandError-wrapped ResolutionError in rolling-deployment skip (#2117)
command.upgrade() never raises ResolutionError directly — alembic's
ScriptDirectory._catch_revision_errors wraps it in CommandError, so the
newer-bank rolling-deployment handler never fired and startup died with
a raw traceback. Catch the wrapped form (cause-checked) and route it to
the same warn-and-skip path; unrelated CommandErrors still propagate.

Fixes #2114
2026-06-11 10:33:53 +02:00
DK09876 a1228ec3a7 release(haystack): v0.1.1 2026-06-10 15:45:45 -07:00
DK09876andDK09876 f83fafa45b refactor(haystack): rename HindsightToolset -> HindsightMemoryWrapper (#2118)
The class subclasses Haystack's `Toolset` but is used as an automatic
memory wrapper (auto_recall / auto_retain around an Agent), not a tool
collection. Reusing the `Toolset` name was confusing next to Haystack's
own `Toolset` abstraction — flagged by deepset DevRel in review of the
haystack-integrations gallery entry (deepset-ai/haystack-integrations#505).

Pure rename across the package, tests, README, and docs pages. The class
still subclasses `haystack.tools.Toolset`. No backward-compat alias — the
package is at 0.1.0 with no adoption yet, so the rename is clean.

Co-authored-by: DK09876 <[email protected]>
2026-06-10 15:42:03 -07:00
DK09876 0a74ce3f07 fix(docs): read observation history before curating facts in memories.py (#2120)
memories.py listed memories, then ran edit -> invalidate -> restore on a fact.
Any update_memory call re-consolidates the bank and recreates derived
observations with new ids, so the observation id from the earlier listing was
stale by the time the example called get_observation_history — which the engine
correctly 404s on (NotFoundException), failing test-doc-examples (python) on
every PR.

Move the observation-history read to immediately after list_memories, before
the curate operations, so it uses a live id. No re-consolidation timing
dependency. The existing `if observation is not None` guard still covers the
no-observation case.
2026-06-10 14:24:39 -07:00
Ben 5b5188af82 blog: Flowise persistent memory (three Tool nodes for any chatflow) (#2108)
* blog: add Flowise persistent memory integration post

Walkthrough of the Hindsight Flowise integration — three Tool nodes
(Retain, Recall, Reflect) plus a shared Hindsight API credential.
Every claim verified against source in hindsight-integrations/flowise:
zod schemas, default budget = "mid", default URL, category, the
exposed tool names (hindsight_retain/recall/reflect), and the
DynamicStructuredTool return shape.

Install section is honest about Flowise's distribution model
(upstream monorepo PR, not npm install) rather than promising a
package that doesn't ship that way today.

Underlying integration tests: 17/17 passing (vitest).

Cover is a placeholder (Codex art) for now — swap before merging.

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

* blog(flowise): fix install-section wording — no upstream PR is open

Searched FlowiseAI/Flowise for any open or closed PR matching
"hindsight" / "vectorize" or authored by any Hindsight contributor
(benfrank241, chrislatimer, cdbartholomew, nicoloboschi, DK09876,
fabioscarsi) — zero results. The draft's phrasing implied a PR was
already open and pending merge. Reword to "the eventual distribution
path is an upstream contribution; until those nodes ship in a Flowise
release..." so the post doesn't promise a PR that doesn't exist yet.

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

* blog(flowise): drop the "eventual distribution path" sentence

Tighten the install-section preamble per reviewer feedback. The post
now just tells readers how to install today, without speculating on
where the nodes will eventually live.

Install procedure verified end-to-end:
- Cloned FlowiseAI/Flowise 3.1.2 (commit f4e2794)
- Copied the three Hindsight tool nodes and credential
- pnpm add @vectorize-io/hindsight-client (resolved to 0.8.1)
- pnpm install at the root
- pnpm --filter flowise-components build → SUCCESS
  - tsc completed, gulp finished, no type errors
  - dist/nodes/tools/Hindsight{Retain,Recall,Reflect}/*.{js,d.ts} all emitted
  - dist/credentials/HindsightApi.credential.{js,d.ts} emitted
  - Compiled JS correctly requires @langchain/core/tools,
    @vectorize-io/hindsight-client, and zod

The install path in the post is now build-verified, not just
copy-faithful to the README.

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

* blog(flowise): fix broken link to /developer

The build-docs and verify-generated-files CI jobs failed because the
post linked to /developer, but the developer-docs landing page has
slug: / (it's the docs root, not /developer).

Repoint the "Hindsight API reference" item to /developer/api/quickstart,
which is the actual API entry point and the link other recent posts
use.
2026-06-10 14:21:34 -04:00
Nicolò Boschi 621ab7e66b fix(api): widen history bank_id to TEXT on PostgreSQL (#2106) (#2110)
The split-history migration a7b8c9d0e1f2 declared observation_history.bank_id
(and mental_model_history.bank_id) as VARCHAR(64) on PostgreSQL, but the
backfill source memory_units.bank_id is TEXT (unbounded), as are banks,
documents and entities. Any deployment with a bank_id over 64 chars aborts the
backfill with StringDataRightTruncation; because the migration runs in lifespan
startup inside a transaction, the whole thing rolls back and the API never
comes up — unrecoverable from the running container.

The Oracle path is unaffected (both sides are VARCHAR2(256)), so the fix is
PostgreSQL-only.

- Correct a7b8c9d0e1f2 to create bank_id as TEXT. This recovers deployments
  that *failed*: the migration rolled back, so re-running the fixed DDL
  succeeds. Inert for deployments that already succeeded.
- Add forward-repair migration c3e5a7b9d1f4 (new head) that widens the column
  in place for deployments that already succeeded with the narrow column;
  no-op on already-TEXT columns, so all upgrade paths converge. Mirrors the
  b2d4f6a8c1e3 repair pattern.
- Add a regression test seeding the 78-char bank_id shape from the issue.

Fixes #2106
2026-06-10 18:11:42 +02:00
Nicolò Boschi 72d9881a6a fix(cli): parse get-memory response correctly (#2111)
The `hindsight memory get` command deserialized the API response into a
local `MemoryUnitDetail` struct whose shape had drifted from what
`GET /memories/{memory_id}` (MemoryEngine.get_memory_unit) actually
returns:

- `entities` is a flat list of canonical-name strings, but the struct
  expected a list of `{id, name}` objects, so serde failed whenever a
  memory had entities — surfaced to users as the misleading
  "Invalid API response format".
- the fact type is exposed as `type`, but the struct renamed it to
  `fact_type`, so the Type line always printed UNKNOWN.

The endpoint returns an untyped JSON body in the OpenAPI spec, so the
generated client never validates it and the mismatch only blew up in the
CLI handler. These commands had no test coverage (docs use curl).

Fix the struct to match the response and add regression tests.
2026-06-10 18:07:24 +02:00
Nicolò Boschi de22b606e7 feat(memory): reversible curation — edit/invalidate/revert memory units (#1976)
Edit (text/context/dates/fact_type/entities), invalidate (move to a separate
invalidated_memory_units archive, reversible), and revert raw memory units via
PATCH /memories/{id}. Tracks user edits with edited_at. Control-plane UI, docs
(Memories API page), and multi-language examples included. RFC #1951.
2026-06-10 17:20:59 +02:00
Ben 51d25d84a3 release(obsidian): v0.1.2 2026-06-10 10:59:20 -04:00
Ben c06e85b64f fix(obsidian): clear community-store review for 0.1.2 + asset attestations (#2107)
Obsidian community-store automated review (v0.1.1) flagged three errors and a
warning; this clears them and adds build-provenance attestations.

Errors:
- Manifest description must not include the word 'Obsidian' → reworded to
  '...cites the source notes. Your vault stays the single source of truth.'
- no-static-styles-assignment (chat-view.ts): el.style.height = ... →
  el.setCssStyles({ height }) per the plugin guidelines.
- no-unsupported-api (main.ts): Workspace.revealLeaf requires Obsidian v1.7.2 →
  bump minAppVersion 1.5.0 → 1.7.2 (matches the obsidian@^1.7.2 types we build
  against). versions.json 0.1.2 → 1.7.2.

Warning:
- builtin-modules dep → Node's built-in module.builtinModules in esbuild config;
  dependency removed.

Recommendation (build-provenance attestations for main.js/styles.css):
- Add actions/attest-build-provenance for the Obsidian assets in
  release-integration.yml (+ attestations: write). Assets release in the
  dedicated repo while the build runs here, so verify at owner scope:
  gh attestation verify main.js --owner vectorize-io.

Bumps manifest to 0.1.2. Verified with the bot's own linter
(eslint-plugin-obsidianmd): both code errors clear. build + tsc + 46 tests pass.
2026-06-10 10:58:13 -04:00
Nicolò Boschi 1d1f718ce5 feat(embed): seed .env configs from bundled .env.example template (#2105)
`hindsight-embed configure` and profile creation wrote a bare four-key
file. Seed them from the same `.env.example` shipped in the repo so users
get the full documented option set as commented references.

- Bundle a copy of the repo-root `.env.example` into the package
  (`hindsight_embed/env.example`) so installed/uvx users have the template
  at runtime; a sync test guards against drift.
- Add `env_template.render_config()`: everything is commented out by
  default — only the keys the user explicitly set are active, replaced in
  place (unknown keys appended). This keeps the active config byte-for-byte
  backwards compatible with the old bare file and prevents the template's
  api-server defaults (PORT=8888, OpenAI base URL, gpt-4o-mini, HOST) from
  leaking in and colliding profile ports / forcing the wrong base URL.
- Wire into both `configure` paths and `create_profile`.

Also document that new config flags must update `.env.example` (and re-sync
the bundled embed copy) in the config-addition checklist (CLAUDE.md) and the
code-review checklist.

The hindsight-api side already seeds `.env` from `.env.example`
(`scripts/dev/setup.sh`), so no change there.
2026-06-10 16:46:28 +02:00
Ben a814b97197 release(obsidian): v0.1.1 2026-06-10 09:38:58 -04:00
Ben 61f980f5a3 chore(obsidian): bump manifest + versions to 0.1.1
The release script bumps package.json but not manifest.json/versions.json, and
the community store requires the release tag to equal manifest.json's version.
Bump both ahead of the v0.1.1 release so the dist-repo mirror + BRAT tag match.
2026-06-10 09:38:27 -04:00
Ben fe404efb20 feat(obsidian): grounded-note citations, collapsed-by-default, persisted layout, inline depth (#2104)
Addresses chat-UX feedback:

1. 'Notes retrieved' showed the agent's whole scratchpad (every note any tool
   call touched — ~10 for a one-fact answer). Now shows only the notes the answer
   is grounded on: join based_on.memories (cited facts, no doc id) to the trace's
   recall results (id + document_id) by fact id, deduped by note in citation
   order. Capped at 3 visible with a 'Show all (N more)' toggle. Falls back to the
   full retrieved list only when nothing resolvable was cited (never empty).
2. Notes disclosure now defaults to collapsed (was always-open and noisy).
3. Both disclosures (notes + reasoning) remember the user's last open/closed
   state across sessions via two new persisted settings.
4. Chat depth (reflect budget) is now settable inline in the chat filter bar and
   written back to the persisted default, so the choice sticks.

No API change — match % is intentionally deferred (the score isn't exposed by the
API today). reflect-util.groundedNotes covered by new unit tests; 46 tests pass.
2026-06-10 09:36:05 -04:00
fb16fc4fdd feat(api): per-bank provider cost attribution via OpenAI user field (#1965)
* feat(api): per-bank provider cost attribution via OpenAI user field

Lets operators attribute Hindsight's provider spend per bank.

- Add a `_current_bank_id` engine ContextVar (mirroring the existing
  `_current_schema` pattern) bound in recall_async, retain_async,
  retain_batch_async, and execute_task, with a `get_current_bank_id()`
  accessor. Bindings use a token + finally reset.
- Add `HINDSIGHT_API_LLM_SEND_BANK_AS_USER` (bool, default off). When on,
  outbound OpenAI-compatible LLM and embedding calls are tagged with
  `user=<bank_id>` so downstream cost gateways (OpenRouter usage
  accounting, LiteLLM, Helicone) can key spend per bank. Injection is
  centralized per call_params construction site and never overrides a
  `user` the caller already set.
- Propagate the bank ContextVar into the embedding executor thread:
  generate_embeddings_batch now copies the current context before the
  run_in_executor offload (run_in_executor does not inherit contextvars),
  preserving the existing exception wrapping and 1:1 length validation.
- Make the OpenRouter reranker base URL configurable via
  `HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL` (default unchanged:
  https://openrouter.ai/api/v1/rerank) so rerank can route through a
  metering gateway. The URL is a credential field (not bank-configurable).

Tests cover ContextVar set/reset including on exception, user injection
gated on flag + bank presence + no caller override (chat and tool-calling
paths plus embeddings), real-executor context propagation, and the
configurable rerank base URL.

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

* refactor(engine): bind the bank ContextVar via decorator, not inline try/finally

The inline token/try/finally wraps re-indented the entire bodies of
execute_task, retain_batch_async, and recall_async — ~1,130 lines of
indentation-only churn in the diff for a ~40-line feature.

Replace the four inline bindings with a @_bind_bank_id decorator that
binds _current_bank_id from the method's bank_id argument (or a key in
a dict argument, for execute_task's task_dict) with the same token +
finally-reset semantics. Method bodies return to their original
indentation, shrinking the memory_engine.py diff to +51/-1.

Behavior is unchanged and now directly unit-tested: the decorator gets
its own tests for positional/keyword binding, dict-key extraction,
reset-on-exception, and non-string fallback to None.

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

* refactor(api): dedupe bank-attribution helper into shared module

Collapse the two identical _apply_bank_attribution copies (embeddings + OpenAI-compatible
LLM) into engine/bank_attribution.apply_bank_attribution. Add a docs note that the bank id
is transmitted to the provider as the end-user identifier, and de-pad the new config rows.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-10 15:10:38 +02:00
Nicolò Boschi 82c7df7266 feat(providers): add native Nous Portal provider (Codex-style OAuth, no hermes_cli dep) (#2102)
* feat(providers): add native Nous Portal provider (codex-style OAuth, no hermes_cli dep)

Adds a 'nous' provider that speaks the OpenAI-compatible wire format (thin
subclass of OpenAICompatibleLLM) and authenticates with the rotating,
inference-scoped JWT from a 'hermes portal' login — read natively from
~/.hermes/auth.json, exactly mirroring the Codex provider. No dependency on
the hermes_cli package.

- nous_auth.py: NousAuthManager reads providers.nous OAuth state, decodes the
  JWT exp for proactive refresh, and refreshes via POST {portal}/api/oauth/token
  (x-nous-refresh-token header). Atomic write-back of rotated tokens. Because
  the Hermes auth store is shared with a possibly-running Hermes agent, refresh
  takes the same ~/.hermes/auth.lock flock Hermes uses and re-reads the latest
  refresh_token from disk before exchange (single-use RT reuse-detection safety).
- nous_llm.py: thin subclass; proactive refresh offloaded to a thread so the
  event loop never blocks; one reactive refresh + retry on a 401.
- llm_wrapper.py: register nous in dispatch, validator, no-key set, base-url default.
- tests: auth-store load/refresh/persist/terminal-error + provider wiring (no
  Hermes install or network needed).

* docs(providers): document nous provider + add default model

- config.py: add nous to PROVIDER_DEFAULT_MODELS (deepseek/deepseek-v4-flash)
  so omitting HINDSIGHT_API_LLM_MODEL doesn't fall back to gpt-4o-mini.
- configuration.md: add nous to the provider list + an env example block.
- models.mdx: add a nous example + a 'Nous Portal Setup (Hermes)' section
  covering the 'hermes portal' login, the no-API-key flow, and automatic
  JWT refresh that coordinates with a running Hermes agent.
- skills/hindsight-docs: regenerated bundle from the docs sources.
2026-06-10 14:27:46 +02:00
Nicolò BoschiandChris Latimer a0e6bedcf1 refactor(memory-defense): per-bank regex defense, webhooks, drop dead surface (#2077)
* Implement Memory Guard Lite for OSS

Allow users to prevent token and secret leakage in agent memory.
feat(memory-defense): reject quarantine action in policy parser

refactor(retain): drop quarantine branch from orchestrator

test(retain): remove quarantine-path tests

refactor(memory-defense): remove DefenseAction.QUARANTINE enum value

refactor(api): remove include_quarantined query parameter

refactor(recall): drop include_quarantined parameter from memory engine

test(memory-defense): replace stale parser-reject test with full-union accept test

The previous parametrized test asserted parse_policy() should 422 on any
detector name other than sensitive_data. That contract was deliberately
widened on 2026-06-07 so cloud-style policies pass through api-slim's
parser unchanged. The test was stale; the runtime is correct.

Replaced with test_parse_policy_accepts_full_detector_union, which proves
the actual contract: all 7 detector names are valid in the parser, with
dispatch and entitlement enforcement deferred to the loaded extension.

Memory defense UI

* i18n labels

* Fix tests

* Client changes to fix breaking tests

* Test fixes

* chore: regenerate API clients via generate-clients.sh

The clients were previously hand-generated in a way that diverged from the
project's tooling — including a non-standard hindsight-clients/typescript/client/
directory the generator never produces (the standard output is typescript/generated/),
plus ~150 spurious files.

Revert the entire hindsight-clients/ tree to main and regenerate from the
OpenAPI spec using ./scripts/generate-clients.sh (Rust via progenitor build.rs,
Python/Go via openapi-generator, TypeScript via @hey-api/openapi-ts). The spec
itself is unchanged (a code-regenerated spec is byte-identical to what was
already committed).

Net result is the real API delta only: the new nullable MemoryItem.receipt_uri
field propagated to the Python, TypeScript and Go models.

* refactor(memory-defense): per-bank regex defense, webhooks, drop dead surface

Review cleanup of the memory-defense feature:

- Rename the OSS extension Lite -> Regex (MemoryDefenseRegexExtension,
  memory_defense_regex.py). It is pure regex redaction now.
- Drop the agent_memory_guard (OWASP) dependency entirely — the
  SensitiveDataDetector fallback and to_owasp_policy are gone; nothing
  cloud-tier remains in api-slim.
- Trim the policy to what OSS enforces: { enabled, rules:[{on:sensitive_data,
  action}] }. Removed default_action, protected/immutable namespaces,
  detector_overrides, min_severity, and the unused
  memory_defense_enabled_default server default. Per-bank override stays
  (memory_defense is a configurable field) and the UI writes the trimmed shape.
- Fire a memory_defense.triggered webhook on every non-allow decision (redact
  and block) when one is configured, via the retain orchestrator. Adds
  WebhookEventType.MEMORY_DEFENSE_TRIGGERED + MemoryDefenseEventData. Replaces
  the no-op record_violation hook.
- Block is now actually enforced (drop item / 422 when all blocked) instead of
  being silently downgraded to redact.
- Remove the unused 'status' lifecycle: the add_status migration + its two
  merge migrations, the recall quarantine filter, the status column reads in
  search, and MemoryFact.status. Branch now adds zero migrations (single head).
- Remove receipt_uri from the API (MemoryItem) and clients — it was always
  None and carried no value.

Tests updated/renamed accordingly; OWASP smoke + enabled-default tests removed.

* fix(memory-defense): address code-review findings

- Delete test_migration_status.py (asserted the removed status column/constraint).
- Remove receipt_uri from the Rust CLI (memory.rs + integration_test.rs) — the
  generated client struct no longer has the field, so it wouldn't compile.
- Type the blocked-violations as a BlockedViolation dataclass instead of raw
  dicts (serialized via asdict() in the 422 body); type the webhook helper's
  decision param as DefenseDecision.
- Add an end-to-end test asserting a redact decision queues a
  memory_defense.triggered webhook delivery.
- Drop the unrelated docs/ entry from .gitignore (local scratch, not this PR).

* test(memory-defense): consolidate into a single test_memory_defense.py

Merge the 10 scattered memory-defense test modules (policy parser, regex
engine/screen, redaction benchmark, extension loader, extension-context
wiring, bank-config validation, and the three retain e2e files) into one
test_memory_defense.py, deduping the overlapping unit screen tests and the
duplicated retain redact e2e. 36 tests, same coverage.

* docs(memory-defense): document memory_defense.triggered webhook + block action

- Add memory_defense.triggered to the control-plane webhook event-type selector
  (it was firing but wasn't selectable in the UI).
- Document the memory_defense.triggered event (payload + data fields) on the
  webhooks API page, and link it from the Memory Defense page.
- Document the block action (the page only described redact) and add a
  Notifications section. Regenerate the docs skill copies.

* docs: remove Memory Defense page from version-0.7 (unreleased feature)

The feature was snapshotted into the 0.7 versioned docs by mistake — 0.7 never
shipped Memory Defense. Remove the page and its (sole) Security sidebar category.

* test(memory-defense): assert webhook payload fields + cover block path

- test_retain_fires_webhook_on_redact now parses the queued delivery and
  asserts the MemoryDefenseEventData payload (action/detector/matched_types/
  message + event status), not just that the event type was queued.
- Add test_retain_fires_webhook_on_block: a block decision fires the webhook
  (before the 422 is raised) with action=block. Confirms the delivery persists
  despite the blocked retain returning 422.
- Factor out _memory_defense_webhook_events() helper.

* fix(control-plane): render structured API error details as a string

A blocked retain returns 422 with detail {violations: [{message, ...}]}; the
proxy forwards it as `details` and the client passed that object straight into
the sonner toast, crashing with "Objects are not valid as a React child".
Add describeErrorDetails() to reduce details to a string — joining violation
messages when present (so a Memory Defense block shows e.g. "Sensitive data
pattern matched: aws_access_key"), else JSON-stringifying.

* docs(webhooks): clarify WebhookEvent.status covers the memory_defense action

* feat(memory-defense): record redact/block actions in the audit log

Emit a fire-and-forget 'memory_defense' audit entry for each non-allow decision
(alongside the webhook), with the action/detector/document_id/matched_types in
metadata. Threads the engine's AuditLogger into retain_batch like the webhook
manager; gated by the existing audit_log_enabled switch (off by default).

- Add the memory_defense option to the audit-logs UI action filter + the
  actionMemoryDefense i18n key across all locales.
- Document it on the Memory Defense page and the audit-logging config section.
- Test: a redact retain writes a memory_defense audit row with the expected
  metadata (audit enabled on the test engine).

---------

Co-authored-by: Chris Latimer <[email protected]>
2026-06-10 14:12:46 +02:00
BenandClaude Opus 4.7 fd848a18c1 blog: add truncate markers to oh-my-pi and 10k-stars posts (#2065)
Silences the Docusaurus build warning about untruncated blog posts.
Marker placed after the lead paragraphs so the blog index shows a
clean preview.

Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-06-10 14:08:50 +02:00
Evo a0d91408cf docs: add gemini embedding 2 models (#2090) 2026-06-10 13:58:20 +02:00
Nicolò Boschi 0d55a9b78d feat(api): per-bank LLM connectivity probe (#2034)
Adds POST /v1/default/banks/{bank_id}/health/llm so operators can verify the LLMs a
bank uses for retain / consolidation / reflect actually connect — instead of
consolidation silently stalling when the LLM is unconfigured or unreachable.

- Deliberate (non-polled) probe: one minimal real call per unique LLM config —
  operations sharing a configuration are probed once and the result fanned out.
- Status only per operation: connected / not_configured / auth_failed (rejected —
  usually a wrong or expired API key, the most common failure) / unreachable / timeout.
  Never returns the provider, model, endpoint, API key, or raw provider error (the
  detailed error is logged server-side; the auth category is derived from a 401/403 or
  known auth markers in the error, and leaks nothing).
- Off by default (it makes a real provider call); enable with
  HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH=true. Exposed as features.bank_llm_health on
  /version so the UI hides the action when disabled.
- Engine returns typed dataclasses; the handler holds no SQL and auth is enforced
  in the engine. The probe reuses the bank's per-operation LLM clients.
- Control plane: a "Health" item in the bank Actions menu opens an "LLM connectivity"
  dialog that probes on open (with a re-test button) and shows per-operation status,
  including a clear "Invalid API key" label for auth failures.
- Regenerated OpenAPI + Python/TS/Go clients; i18n across all 10 locales; tests in
  tests/test_bank_health.py.

A broader per-bank GET /health endpoint (#747) was explored but dropped as redundant
with the existing bank stats; only the connectivity probe is net-new.
2026-06-10 13:57:46 +02:00
Nicolò Boschi 90ee101bec fix(reflect): carry directives + language rule into final synthesis prompt (#2100)
The reflect final answer is a separate LLM call whose system prompt dropped the language rule and the bank's directives (they lived only in the agent/reasoning prompt), so weaker models intermittently drifted to English — the mechanism behind flaky multilingual reflect tests. build_final_system_prompt now re-injects the directives section + reminder and a default language rule; HINDSIGHT_API_LLM_OUTPUT_LANGUAGE stays the hard override. Deterministic prompt tests pin the behaviour; real-LLM language tests pass on gemini-2.5-flash and CI-tier gemini-3.1-flash-lite.
2026-06-10 11:10:05 +02:00
27cb1c6843 Support service_tier selection for Amazon Bedrock (#2098)
* feat: add HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER env var

Adds support for setting Bedrock service tier (flex/priority/reserved)
via environment variable, following the same pattern as the existing
Groq and OpenAI service tier support.

- config.py: env constant, default, dataclass field, os.getenv() load
- llm_wrapper.py: bedrock_service_tier param plumbing
- litellm_llm.py: inject service_tier kwarg for bedrock/ models
- configuration.md: table entry + Bedrock example block
- models.md/mdx: Bedrock tip block update

Closes #2072

* Add validation + tests for HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER

- validate() rejects invalid values (e.g. 'standard') with clear error
- Empty string treated as unset (matching llm_output_language pattern)
- Tests: default, flex, priority, reserved, invalid value, empty string

* fix(api): thread bedrock_service_tier from config into LLM providers

The new HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER flag was plumbed through
LLMProvider/create_llm_provider/LiteLLMLLM but nothing ever constructed
an LLMProvider with the resolved config value, so the env var was inert
(service_tier was never injected into the Bedrock call).

- memory_engine.py: pass bedrock_service_tier=config.llm_bedrock_service_tier
  to all four LLMConfig constructions (default/retain/reflect/consolidation)
- llm_wrapper.py: LLMProvider.from_env() reads the env var too, so ad-hoc
  constructions honor it
- test_bedrock_service_tier.py: plumbing tests asserting the tier reaches
  the LiteLLM call kwargs for bedrock/ models, is omitted otherwise, and
  is guarded off non-Bedrock models

* style(test): ruff format test_config_validation.py (fix verify-generated-files)

---------

Co-authored-by: Hermes Agent (Rob) <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-10 10:41:33 +02:00
Nicolò Boschi a942b1c817 feat(api): add Gemini Batch API support for retain fact extraction (#2089)
Adds Gemini Batch API support for retain fact extraction (50% discount, 24h SLA) via the existing HINDSIGHT_API_RETAIN_BATCH_ENABLED flag. GeminiLLM overrides the 4 LLMInterface batch methods, adapting Gemini's upload->create->poll->download flow to the OpenAI-batch shapes the consumer expects (same pattern as FireworksLLM). Gemini-only (Vertex unsupported). Threads usageMetadata into the result body. Live-verified end-to-end. Gemini portion of #1144; consolidation batch is a follow-up.
2026-06-10 10:18:24 +02:00
Nicolò Boschi 0280b3486f feat(ui): export constellation as a shareable SVG poster (#2099)
Add a Share button to the constellation toolbar that downloads the
whole graph as a self-contained SVG poster: dark night-sky background
with a soft glow and plus-grid, the Hindsight logo inlined top-left,
and the nodes/links drawn with the exact canvas formulas (solid heat
dots + hub halos, thin faint colored links) — no labels. Fits the full
graph independent of the live pan/zoom.

Adds exportSvgTitle/exportSvgLabel to all locale message files.
2026-06-10 09:51:03 +02:00
Ben 96910071c9 fix(ci): npm provenance 409 guard + add Obsidian MIT LICENSE (#2094)
* fix(ci): treat npm provenance 409 (tlog) as already-published in release-integration

When a release tag is re-pointed and the workflow re-runs, npm publish --provenance
fails with TLOG_CREATE_ENTRY_ERROR / (409) 'equivalent entry already exists in the
transparency log' because the identical artifact was already logged by the prior run.
The package is already published, so this is benign — widen the already-published
guard to swallow it (alongside the existing 'cannot publish over').

* chore(obsidian): add MIT LICENSE for community-store submission

The Obsidian community-store review bot requires a LICENSE file at the plugin
repo root. The dist repo (vectorize-io/hindsight-obsidian) is mirrored from this
directory via the release workflow, so adding it here propagates on next release.
2026-06-09 16:26:40 -04:00
Evo 68d947b8db docs(integrations): add Gemini Spark page + grid entry (#1779) (#1943) 2026-06-09 16:22:11 -04:00
Ben c815329c14 chore(cursor): ruff-format drift in cursor integration tests (#2095)
The cursor integration test files were committed without ruff formatting,
causing verify-generated-files to fail on every PR branched off main. Run
the formatter to bring them in sync (no logic changes).
2026-06-09 16:15:04 -04:00
Chris Bartholomew 41b6e5746a blog: Hindsight is the fastest-growing open-source AI memory project ever (#2092)
* blog: Hindsight is the fastest-growing open-source AI memory project ever

Equal-age GitHub star analysis (per-star timestamps) plus third-party
validation from OSSCAR (#10 fastest-growing OSS org, ahead of Mem0) and
dope.security (#1 MCP server in enterprise traffic). Adds cdbartholomew
to blog authors.

* blog: add truncate marker, featured image, fix Slack invite link

- Add <!-- truncate --> after the lead (fixes the build warning addressed
  repo-wide in #2065)
- Add featured/social image and hero image
- Replace workspace login URL with the canonical join.slack.com invite

* blog: clean up featured image (remove curve overlapping the headline)

* blog: add captured star-history chart (Hindsight steepest slope); align featured image to brand palette

- Embed a static capture of the overlaid star-history graph in the
  'still accelerating' section; Hindsight shows the steepest slope of
  any project. Replaces the unreliable live-URL embed (rate-limited).
- Recolor the featured/OG card to the Hindsight brand palette
  (#0074d9 -> #009296 gradient, #09090b background) instead of off-palette mint.

* blog: add star-history chart to featured image (text left, chart right)
2026-06-09 15:47:08 -04:00
Ben 85402035ed fix(ci): override git auth with OBSIDIAN_DIST_TOKEN for obsidian mirror push (#2093)
* fix(ci): override git auth header with OBSIDIAN_DIST_TOKEN for mirror push

The previous fix (unsetting the checkout extraheader) wasn't enough: the runner
also authenticates github.com via a git credential helper, so the subtree push
still ran as github-actions[bot] (403 on the dedicated repo). Override the
Authorization header with the dist token via `git -c` — an explicit header
beats both the checkout header and the helper, and propagates to subtree's
internal push via GIT_CONFIG_PARAMETERS.

* fix(ci): unset bot extraheader before overriding with dist token

http.extraheader is multi-valued, so adding our -c header on top of the
checkout's bot header sent two Authorization headers → GitHub 400. Unset the
checkout header first so only the OBSIDIAN_DIST_TOKEN header is sent.

* fix(ci): reset credential helper so only the dist-token header is sent

The runner's git credential helper was injecting the bot Authorization on top
of our extraheader → 'Duplicate header: Authorization' (400). Reset the helper
chain with -c credential.helper= so only the OBSIDIAN_DIST_TOKEN header remains.

* fix(ci): isolate git context for the obsidian mirror push + diagnostics

Push kept sending a duplicate Authorization from a config scope local --unset
didn't reach. Split locally and push from an isolated context (global/system
config nulled, helper disabled, local extraheader unset) with the token in the
push URL → a single Authorization. Also dump auth-config origins for diagnosis.

* fix(ci): reset the inherited extraheader to push as OBSIDIAN_DIST_TOKEN

Diagnostic showed the runner injects the bot token as an http.extraheader via
an *included* config file (no credential helper), which --unset-all can't touch.
Reset the extraheader list with an empty -c value (read last → clears it at
request time) and auth via the push URL → a single dist-token Authorization.
2026-06-09 15:46:49 -04:00
Ben 3f025c1fc3 release(cursor): v0.2.0 2026-06-09 15:21:22 -04:00
Ben 37a20ec524 fix(ci): use OBSIDIAN_DIST_TOKEN for the obsidian mirror push (#2091)
actions/checkout sets an http extraheader for the default GITHUB_TOKEN that
overrode the token embedded in the subtree-push URL, so the push ran as
github-actions[bot] (no access to the dedicated repo → 403). Unset that header
before the push so OBSIDIAN_DIST_TOKEN is used.
2026-06-09 14:26:57 -04:00
Ben a67a8f774d fix(obsidian): mirror plugin to a dedicated repo instead of releasing in the monorepo (#2078)
* fix(obsidian): stop creating a GitHub Release per plugin version

Per-integration GitHub Releases pollute the repo's release list (meant for
the core Hindsight product) and steal the 'Latest' badge — the obsidian
v0.1.0 release displaced v0.8.0. BRAT / the community store also can't target
a tag inside a multi-release monorepo (they read a repo's *latest* release),
so the step never gave working BRAT distribution anyway.

- Remove the 'Attach Obsidian release assets' step from release-integration.yml
  (replaced with a comment explaining why; npm publish is unchanged).
- Point BRAT install instructions at the dedicated repo
  vectorize-io/hindsight-obsidian in the integration README and docs page.
- Add a 'Distribution & maintainers' section documenting the two-repo setup so
  plugin updates are released to both (monorepo = source of truth + npm,
  dedicated repo = BRAT / community-store releases).

* ci(obsidian): mirror plugin to dedicated repo via subtree on release

Instead of manually maintaining two repos, the release workflow now mirrors
hindsight-integrations/obsidian/ to the root of vectorize-io/hindsight-obsidian
(git subtree push --prefix) and cuts the BRAT / community-store GitHub Release
there — the monorepo stays the single source of truth.

- Add the 'Mirror Obsidian plugin to its dedicated repo' step to
  release-integration.yml (unshallow → subtree push → idempotent release).
  Needs secret OBSIDIAN_DIST_TOKEN (contents:write on the dedicated repo).
- Drop the now-unused 'contents: write' permission (no releases are created in
  this repo anymore).
- Rewrite the README 'Distribution & maintainers' section: the mirror is
  automatic, the dedicated repo is generated, don't edit it directly.
2026-06-09 13:47:50 -04:00
91d767cdcb feat(cursor): add Hindsight memory plugin for Cursor (#866)
* feat(cursor): add Hindsight memory plugin for Cursor

Adds a complete Cursor integration using the plugin architecture
(hooks, skills, rules). Automatically recalls relevant memories
before each prompt and retains conversation transcripts on task
completion. Modeled after the claude-code integration with
Cursor-specific adaptations.

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

* docs(cursor): add integration docs, blog post, and sidebar entry

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

* docs(cursor): clarify plugin vs MCP modes, add hook diagnostics

- Add plugin-vs-MCP comparison table near top of integration doc
- Add "Verifying Plugin Hooks" section with state file commands
- Add troubleshooting note: visible tool calls = MCP, not plugin
- Write last_retain.json state file in retain.py for diagnostics
- Add mode: plugin and query_length to recall state file
- Fix test_settings_file_loaded to isolate from user config

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

* fix(cursor): install path, always-write diagnostics, Cloud snippets

- Add mkdir -p before cp -r in all install examples (first-run fix)
- Add "fully quit and reopen Cursor" note to all setup flows
- Recall/retain hooks now write status on every invocation
  (success, empty, skipped, error) not just on success
- Fix docs to show ~/.hindsight/cursor-state/ default path
- Add concrete Hindsight Cloud config snippet to Quick Start
- Add Cloud option to blog post setup section

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

* fix(cursor): add session field to dynamic bank IDs, add changelog

- Support "session" in dynamicBankGranularity for per-conversation banks
- Add changelog page for cursor integration
- Add test for session-based dynamic bank ID

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

* fix(cursor): sync integration README with cookbook/blog setup guidance

- Add mkdir -p for plugin install path
- Add "fully quit and reopen Cursor" instruction
- Show Cloud as Option A, local as Option B, daemon as Option C
- Match the setup flow documented in the cookbook and blog

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

* feat(cursor): add pip/uvx installer, fix review findings

- Add hindsight_cursor package with CLI `init` and `uninstall` commands
- Add pyproject.toml for PyPI publishing via existing release pipeline
- Update README install path: `pip install hindsight-cursor && hindsight-cursor init`
- Fix rule/skill files to describe plugin behavior instead of MCP tools
- Add diagnostics on get_api_url failure paths in both hooks
- Remove missing assets/avatar.png reference from plugin manifest
- Add Cloud token retrieval guidance (Settings > API Keys)
- Add test_cli.py with 8 tests for init/uninstall commands

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

* fix(cursor): daemon timeout, config defaults, full config docs

- Set daemonIdleTimeout default to 300s (was 0/infinite with no cleanup hook)
- Fix retainEveryNTurns fallback from 1 to 10 in retain.py
- Fix DEFAULTS: hindsightApiUrl="" and bankId="cursor" to match settings.json
- Document all config settings in README (was missing ~15 entries)
- Fix pytest version discrepancy in pyproject.toml
- Fix plugin.json author to "Vectorize" for consistency

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

* docs(cursor): streamline setup with init flags, add Docker instructions

- Restructure Quick Start around Cloud vs Local as two clear paths
- Use hindsight-cursor init --api-url/--api-token for one-command setup
- Add Docker run command for users without a local Hindsight server
- Remove separate "configure" step that contradicted init behavior

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

* refactor(cursor): replace beforeSubmitPrompt with sessionStart + MCP

beforeSubmitPrompt does not support additionalContext in Cursor's hook
system — the old recall.py was silently ignored. This rewrites the
architecture to use Cursor's native mechanisms:

- sessionStart hook for ambient project-level recall (supports additionalContext)
- MCP integration for on-demand recall/retain/reflect tools mid-session
- stop hook for auto-retain (unchanged, works correctly)

Also fixes Python floor (3.9 -> 3.10, pytest 9 requires it) and
updates docs/blog to match the new architecture.

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

* fix(cursor): workaround broken sessionStart additionalContext

Cursor's sessionStart hook accepts additionalContext output but silently
drops it before the agent's composer handle is ready — a race condition
acknowledged by Cursor staff in 2026-04, still present in 3.6.31
(verified 2026-06-02 with a marker-emitting test hook). Without a
workaround the plugin's "auto-recall memories at session start" feature
silently does nothing in every install.

Per Cursor staff guidance (Dean Rie, thread 158452), the documented
escape hatch is to write a workspace .cursor/rules/<file>.mdc with
alwaysApply: true — the rules engine injects those reliably. Plugin-
local rules dirs (~/.cursor/plugins/local/...) are NOT reliable per
thread 159101.

Implementation:

- scripts/lib/rules_file.py (new): owns the workaround. Three helpers:
    * rotate_session_rules() — deletes any prior rules file at the top
      of each sessionStart so an empty recall doesn't leave stale
      memories from a previous session.
    * write_session_rules() — writes the .mdc with alwaysApply: true,
      an HTML comment that explains what the file is and links to the
      Cursor bug, and the recalled memories inside a
      <hindsight_memories> block (same wrapper the broken native path
      used, so the static rules guidance is unchanged).
    * ensure_gitignored() — idempotently appends the file path to
      <workspace>/.gitignore when the workspace is a git repo. No-ops
      otherwise. Matches both /-anchored and bare relative forms so we
      don't double-add against an existing entry.

- scripts/session_start.py: rotates at the top, writes the fallback
  file after recall succeeds, gates both behind config flags
  (useRulesFileFallback, appendToGitignore, both default True). Still
  emits additionalContext to stdout below — when Cursor fixes the
  upstream bug, dropping the workspace write is the only code change
  needed; the same plugin works on the native path with no protocol
  rev.

- scripts/lib/config.py: two new config keys + HINDSIGHT_USE_RULES_
  FILE_FALLBACK / HINDSIGHT_APPEND_TO_GITIGNORE env overrides.

- rules/hindsight-memory.mdc: tells the agent where recalled memories
  now appear (the new .cursor/rules/hindsight-session.mdc file) and
  notes that the file is plugin-generated and safe to delete.

- tests/test_rules_file.py: 18 tests pinning the on-disk shape:
  frontmatter, alwaysApply, bug link, rotation, idempotent gitignore
  with both anchor forms, falsy workspace handling, write-error
  degradation.

Why this design (vs. alternatives):

- Just shipping MCP-only and documenting the limitation would repeat
  the OpenAI Agents notebook-10 Pattern-1 failure mode: the agent has
  to choose to call recall, and small models reliably skip it. Auto-
  inject doesn't depend on tool-call choice.
- Reverting to beforeSubmitPrompt would mean a recall per turn instead
  of per session, and Cursor staff have signalled additional_context
  on that hook is unimplemented (forum 150707).
- The workspace file is the price of Cursor's bug being open with no
  ETA. Mitigations: auto-rotate, auto-gitignore, in-file explanatory
  comment, config opt-outs.

Verification:

- Full suite: 74 passed (56 prior + 18 new).
- Smoke end-to-end against a fresh git repo: rules file written with
  correct frontmatter, .gitignore appended cleanly with both an
  explanatory comment and the path entry, no duplicate-add on re-run.

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

* test(cursor): adopt requires_real_llm bucketing + live E2E + lockfile + docs

Aligns cursor with the standing test-bucketing convention from PR #1469
("Split test suite into deterministic mock and real LLM buckets") that the
other eight Python integrations already follow.

Changes:

- pyproject.toml: register the `requires_real_llm` marker so the live
  E2E suite is selectable as a discrete bucket (and excluded from the
  deterministic CI path via `pytest -m "not requires_real_llm"`). Add
  hindsight-client as a dev dep — the E2E driver needs it to seed and
  verify banks; the runtime plugin scripts still use stdlib only.

- tests/test_e2e.py (new): four-test gated suite that drives the actual
  hook scripts the way Cursor does — JSON on stdin, env vars for config
  — against a live Hindsight server. Covers:
    1. session_start writes the rules-file workaround with recalled
       content, appends `.gitignore`, and emits the forward-compat
       `additionalContext` to stdout.
    2. empty-bank case: hook succeeds without writing a rules file.
    3. opt-out: `useRulesFileFallback=false` produces no `.cursor/` or
       `.gitignore` mutations even when recall surfaces content.
    4. retain end-to-end: drives `retain.py` with a JSONL transcript
       (the on-disk shape Cursor actually emits, not an inline messages
       array), then verifies the bank holds the fact via direct recall.

  Two non-obvious fixtures the suite needs:
  - `HOME` / `CURSOR_PLUGIN_DATA` redirected to tmp so the test doesn't
    touch the developer's real `~/.hindsight/cursor.json` or state.
  - `HINDSIGHT_BANK_MISSION` overridden to a focused mission that aligns
    with the seeded fixtures — the production default mission is broad
    boilerplate, fine for real users but too diffuse to reliably
    surface targeted test content within a deadline.
  - `HINDSIGHT_RETAIN_EVERY_N_TURNS=1` because retain.py batches every
    N turns (10 by default) and a single-shot test only has one turn.

- uv.lock: committing per the convention every other Python
  integration follows. 258 KB, 29 packages resolved, `uv lock --check`
  clean.

- README.md: new "How session memory reaches the agent" section
  documenting why the plugin writes `<workspace>/.cursor/rules/
  hindsight-session.mdc` (Cursor's native `additionalContext` channel
  is broken, forum thread 158452, still open in 3.6.31). Captures the
  empirically-verified behaviour: Cursor blocks prompt submission
  until sessionStart returns, so every new agent's first prompt has
  memories, the rules file is regenerated each session, and the file
  is auto-gitignored. Two new config knobs (`useRulesFileFallback`,
  `appendToGitignore`) added to the Session Recall table.

Verification:
- Deterministic bucket: 74 pass / 4 deselected (the new gated E2E).
- Live bucket (HINDSIGHT_API_URL=http://127.0.0.1:8888): 4 pass.

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

* fix(cursor): default to hosted backend + give each retain a distinct document_id

V2 audit (2026-06-02) caught two real bugs in cursor that were missed by
the V1 pass:

1) Goal-5 (Default to Cloud) FAIL — settings.json shipped
   hindsightApiUrl='' and the daemon path treated empty as "fall back to
   local daemon at 127.0.0.1:9077". Users following the docs ("just enable
   the plugin") never reached the hosted backend without explicitly
   passing --api-url. Every other integration's empty-config path lands on
   https://api.hindsight.vectorize.io.

2) The retain path used document_id=session_id in full-session mode,
   which silently upserts the same Hindsight document on every retain.
   The audit's 5-turn distinct-fact driver exposed this as "5-turn cloud
   → 1 topic surfaced" — earlier turns got overwritten because each
   retain rewrote the single per-session document with whatever
   transcript snapshot was current.

Both are addressed below; the live test suite still passes against the
local server and the new deterministic tests pin the cloud-default
resolution + the unique-document-id derivation.

Changes:

- scripts/lib/config.py — add ``DEFAULT_HINDSIGHT_API_URL`` constant
  (``https://api.hindsight.vectorize.io``). Add ``useLocalDaemon`` flag
  (default ``False``) so self-hosters can opt back into the auto-managed
  daemon path. New env override ``HINDSIGHT_USE_LOCAL_DAEMON``.

- scripts/lib/daemon.py — rewrite ``get_api_url`` resolution:
    1. Explicit ``hindsightApiUrl`` wins.
    2. A locally-running server on the configured port is used (preserves
       the "developer already started a daemon" path).
    3. ``useLocalDaemon=True`` AND ``allow_daemon_start=True`` (retain
       path) triggers the auto-managed daemon. Recall path never starts a
       daemon on its own.
    4. Otherwise → ``DEFAULT_HINDSIGHT_API_URL``. A failed daemon-start
       under (3) also falls back here rather than hard-erroring, so the
       plugin keeps working when ``hindsight-embed`` isn't on PATH.

- scripts/retain.py — every retain now derives
  ``document_id = f"{session_id}-{int(time.time() * 1000)}"`` regardless
  of retainMode. The chunked-vs-full-session distinction at the doc-id
  layer was always a misfeature; full-session mode now means "the
  transcript ingested per retain may span the whole session", not "every
  retain writes the same document".

- tests/test_daemon.py (new) — pin the four-tier resolution + env
  override + the source-shape of retain.py's document_id derivation.

Verification:
- Deterministic bucket: 81 pass / 4 deselected (74 prior + 7 new).
- Live bucket: 4 pass / 0 fail against 127.0.0.1:8888.
- Manual smoke for empty-config → returns ``DEFAULT_HINDSIGHT_API_URL``.
- Live server still resolves to ``http://127.0.0.1:8888`` when healthy.

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

* fix(cursor): parse Cursor 3.x role-nested transcript format

retain.py's read_transcript only recognized two transcript shapes:
- Flat:        {role, content}
- Type-nested: {type: "user"|"assistant", message: {role, content}}

Cursor 3.6.31 writes a third shape to its stop-hook transcript:

  {"role":"user","message":{"content":[
    {"type":"text","text":"..."},
    {"type":"tool_use","name":"...","input":{...}}
  ]}}

Top-level has `role` (not `type`), and `content` lives under `message`
as a list of typed blocks (not at the top level as a string). The old
parser's two branches both missed every line: `entry.get("type")` was
None and `"content" in entry` was False. read_transcript silently
returned [] for every Cursor 3 transcript, and retain.py bailed with
status=skipped reason=empty_transcript on every stop hook.

Visible symptom: auto-retain silently stops working under Cursor 3
even though the stop hook fires correctly and transcript_path points
at a real, populated file (verified by reading
~/Library/Application Support/Cursor/logs/.../cursor.hooks.*.log —
the input JSON includes a valid transcript_path that the parser then
ignores). End users see recall continue to work (sessionStart writes
the rules-file workaround) but new turns never get retained.

Fix:
- Add _normalize_blocks_to_text to flatten typed-block lists to a
  single string, inlining a compact [tool_use:<name>] marker so
  downstream Answer:/Thought: handling still sees coherent structure.
- Recognize the role-nested Cursor 3 shape explicitly.
- Keep flat and type-nested handling intact.

Verified end-to-end against a real Cursor 3.6.31 transcript captured
from ~/.cursor/projects/.../agent-transcripts/<conv>/<conv>.jsonl:
read_transcript now returns the 15 messages it should (1 user + 14
assistant turns) instead of 0.

Regression tests (3 added):
- test_read_transcript_parses_flat_format pins the flat shape.
- test_read_transcript_parses_type_nested_format pins the type-nested
  shape.
- test_read_transcript_parses_cursor3_role_nested_with_block_content
  is the regression: fails on the pre-fix parser (returns []), passes
  now. Also asserts the [tool_use:Shell] marker survives.

14/14 tests in test_hooks.py pass.

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

* chore(docs): drop missing image refs in cursor blog post

The 2026-04-03 cursor-persistent-memory blog references
/img/blog/cursor-persistent-memory.png in both frontmatter and
inline markdown, but the image was never added to the repo. build-docs
fails MDX compilation with "Markdown image with URL
/img/blog/cursor-persistent-memory.png couldn't be resolved to an
existing local image file".

Strip the two references so the post renders. The prose stands on its
own without an illustration; an image can be added in a follow-up PR
if/when one is produced.

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

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

Branch carried an older snapshot of hindsight-docs/static/openapi.json
that pre-dated main's addition of the OperationProgress schema.
check-openapi-compatibility flagged the missing 'progress' field on
GET /v1/default/banks/{bank_id}/operations/{operation_id} as a
backwards-incompatible removal.

Re-checkout main's openapi.json onto the branch.

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

* chore(cursor): drop cursor-persistent-memory blog post

The blog post was added as marketing for the Cursor integration but
the accompanying illustration was never produced. Earlier commit
0e4b2568 stripped the missing image references so build-docs would
pass; user prefers the blog post itself be dropped from the integration
PR and authored separately.

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

* chore(cursor): ruff format scripts + generate docs-skill changelog

verify-generated-files CI flagged drift in three cursor scripts
(scripts/lib/daemon.py, scripts/retain.py, scripts/session_start.py)
and a missing skills/hindsight-docs/.../integrations/cursor.md.

- scripts: applied ruff format/check (3 files reformatted, all checks
  pass).
- generate-docs-skill.sh produced the integrations/cursor.md changelog
  mirror.

Format-only + a generated file regeneration; no behaviour changes.
All cursor tests pass.

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(cursor): address review — drop dead code, register changelog + gallery

- Remove compose_recall_query / truncate_recall_query from scripts/lib/content.py
  (ported from openclaw but unused — cursor only recalls at sessionStart) and
  their test; slice_last_turns_by_user_boundary stays (used by retain.py).
- Add cursor to the INTEGRATIONS map in generate_changelog.py so the release
  changelog step resolves the slug.
- Add the integrations.json gallery entry + icon and rely on the existing
  docs-integrations/cursor.md so check-integrations.mjs passes.

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Ben <[email protected]>
Co-authored-by: DK09876 <[email protected]>
2026-06-09 09:50:22 -07:00
Nicolò Boschi 22ae72a907 feat(api): support gemini-embedding-2 family (per-input embedding) (#2087)
Closes #1139. The gemini-embedding-2 multimodal models aggregate a multi-input request into one embedding, breaking 1:1 input->vector alignment. Force batch size 1 (one input per call) for that family, keep batching for gemini-embedding-001, and raise a clear error on misaligned counts. Adds unit tests + a real Vertex integration test that skips when the preview model isn't enabled.
2026-06-09 18:08:14 +02:00
Ben 4cb78173ba release(cline): v0.2.0 2026-06-09 11:55:01 -04:00
Ben 36e31c675c feat(cline): ship as pip-installable hindsight-cline package (#2088)
The docs told users to run `python /path/to/.../install.py` with no way to
obtain that file (no pip package, no clone step) — effectively unusable. Bring
cline in line with roo-code and cursor-cli by shipping it as a pip package.

    pip install hindsight-cline
    hindsight-cline install --api-url ... --api-token ...
    hindsight-cline uninstall

- Move the install logic into a hindsight_cline package with an argparse CLI
  (install/uninstall subcommands) exposed via a console_scripts entry point.
- Bundle the hook payload (4 hook scripts + lib/ + settings.json) as package
  data under hindsight_cline/hooks/, read via importlib.resources.
- Add pyproject.toml (hatchling), LICENSE, py.typed, uv.lock.
- Switch the CI job to uv build + uv sync --frozen + uv run pytest.
- Update README, docs page, and the launch blog post to the pip flow.

The changelog generator already maps cline -> hindsight-cline. Detecting a
pyproject.toml, the release workflow now publishes hindsight-cline to PyPI
(first release needs a PyPI pending publisher).
2026-06-09 11:53:48 -04:00
Ben 1c133dbd6c release(cursor-cli): v0.2.0 2026-06-09 11:18:22 -04:00
Ben c6dd089445 feat(cursor-cli): ship as pip-installable hindsight-cursor-cli package (#2083)
Convert the Cursor CLI integration from a git-clone + ./scripts/install.sh
flow to a pip-installable package with a `hindsight-cursor-cli install` CLI,
matching roo-code and the other Python integrations.

- Add pyproject.toml (name: hindsight-cursor-cli) and console script
- Add hindsight_cursor_cli/ package: cli.py + install.py, a Python port of
  the old install.sh/uninstall.sh
- Bundle the hook payload (scripts/, settings.json, hooks.json) as package
  data under hindsight_cursor_cli/hooks/; the installer deploys it to
  ~/.cursor/hooks/cursor-cli/ and merges the hook registry
- pyproject is the single source of truth for the version; the installer
  stamps it into the deployed settings.json (used by client.py's User-Agent)
- Remove scripts/install.sh and scripts/uninstall.sh
- Add test_install.py + test_cli.py; retarget existing hook tests
- Switch the CI job to the uv build/sync/test flow
- Update README + docs to `pip install hindsight-cursor-cli`
2026-06-09 11:16:44 -04:00
Ben ba44e0205d docs(icons): use real Cline brand mark in place of placeholder (#2086)
The existing cline.svg was a hand-drawn placeholder — dark rounded
box with two blue eyes and an antenna — that doesn't match Cline's
actual logo. Swap it for the official Cline AI mark (black squircle
with two pill cutouts and a small knob on top).

Source: uxwing.com/cline-ai-icon — licensed for commercial use
without attribution.
2026-06-09 11:14:02 -04:00
Ben d5df9ad083 blog: Cline persistent memory (lifecycle hooks, no MCP) (#2085)
* blog: add Cline persistent memory integration post

Walkthrough of the new Hindsight + Cline integration that wires up
persistent memory via Cline's lifecycle hooks (no MCP). Covers the
four hooks, install + config, per-project and team memory patterns,
and tradeoffs.

Cover is a placeholder (Codex art) for now — swap before merging.


* blog(cline): replace em-dashes with contextual punctuation

Targeted sweep replacing 21 em-dashes with the appropriate punctuation
(commas / semicolons / periods / colons) given each surrounding clause.
The table-cell placeholder on the "Model tool-calling needed" row
becomes "n/a" so the column still reads as "not applicable for the
default."

Code blocks, URLs, file paths, and the ASCII flow diagram (which uses
U+2500 box-drawing characters, not em-dashes) are untouched.

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

* blog(cline): explain why Cloud matters for the Cline workflow

Expand the Cloud-section intro to cover the Cline-specific wins:
multi-machine VS Code sync, no LLM key in the hook environment, and
no local hindsight-api to keep running while doing dev work.


* blog(cline): swap placeholder cover for Hindsight x Cline card
2026-06-09 10:56:16 -04:00
Nicolò Boschi d7ff44b984 chore(integrations): apply CI ruff formatting to haystack + roo-code tests (#2082)
Clears the verify-generated-files drift: CI lints all integrations
(LINT_ALL_INTEGRATIONS) and reformats these recently-added test files,
which were committed without CI-mode formatting and so showed as drift
on every PR.
2026-06-09 16:19:12 +02:00
Nicolò Boschi 3069bb41af docs: changelog and blog post for v0.8.1 (#2080)
* docs: changelog and blog post for v0.8.1

* docs(blog): drop integrations section; add hs-release skill

* docs(hs-release): make changelog worktree a fallback, not a required step

* docs(blog): fix broken 0.8.0 cross-link (date-based blog URL)
2026-06-09 15:33:16 +02:00
Nicolò Boschi 4dc149a1ac Release v0.8.1
- Update version to 0.8.1 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.8
2026-06-09 14:55:08 +02:00
Nicolò Boschi 1296e9fc12 feat(api): config flag to skip storing raw document text (#2061) (#2062)
* feat(api): add HINDSIGHT_API_STORE_DOCUMENT_TEXT flag to skip raw text storage

When set to false, the retain pipeline runs unchanged (chunking, fact
extraction, embedding, entity linking) but drops the raw source text:
documents.original_text is stored as NULL and chunks.chunk_text as empty.
content_hash is still computed from the real text so delta-retain dedup is
unaffected, and recall is unaffected because it reads from memory_units.

Closes #2061

* feat(api): reject append + drop source-text reads in privacy mode

Follow-up to the HINDSIGHT_API_STORE_DOCUMENT_TEXT flag, covering the
features that read raw document/chunk text back:

- retain update_mode='append' is now rejected when text storage is
  disabled (it rebuilds the document from the stored original_text, which
  is NULL, and would silently drop prior content).
- reflect no longer offers the 'expand' tool (get chunk/document source),
  gated via get_reflect_tools(include_expand=...); the hallucination guards
  no longer hardcode 'expand' as always-allowed.
- reflect's recall step no longer attaches empty source chunks.

Other read sites (get-document/list-chunks/get-chunk endpoints + MCP,
export/import, public recall include_chunks) already degrade gracefully to
empty/None and are documented.

* fix(api): get-document 200 with null text + 400 on append in privacy mode

Caught while testing the flag live against a running server:

- DocumentResponse.original_text was a non-optional str, so the GET
  document endpoint raised ResponseValidationError -> HTTP 500 when the
  text is NULL. Made it str | None.
- The retain handler mapped all exceptions (including the append-rejection
  and duplicate-document_id ValueErrors) to HTTP 500. Map ValueError to 400,
  matching the convention used by the other endpoints.

Adds an HTTP-level regression test (the engine-level test passed because
get_document returns a dict, bypassing response-model validation).

* feat(ui): warn when document text storage is disabled

Surface the store_document_text flag so the control plane can warn users
that raw source text isn't persisted:

- /version feature flags now include store_document_text (regenerated
  OpenAPI spec + SDK clients; also picks up the earlier DocumentResponse
  original_text optional change).
- features-context exposes it (defaults true, so the warning only shows
  when the server explicitly reports privacy mode).
- Document detail dialog: the Content tab shows a small notice instead of
  an empty body when text isn't stored.
- Add Document dialog: a small notice that raw text won't be kept.
- i18n strings added across all locales.

Adds an API test asserting /version reports the flag.

* refactor: drop "privacy mode" wording; reposition document-text warnings

- Remove the "privacy mode" phrasing I had introduced from comments,
  docstrings, test names, docs, and UI labels. The flag is described by
  what it does (skip storing raw document text) instead.
- Add Document dialog: move the warning to just above the action buttons.
- Document dialog: also show the warning on the Chunks tab.

* fix(cli): handle optional document original_text

original_text is now Option<String> in the generated client (it can be
null when document text storage is disabled), so the CLI can't print it
with {} directly. Show "(not stored)" when absent.
2026-06-09 14:46:09 +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
1766 changed files with 172127 additions and 30801 deletions
+1
View File
@@ -1,6 +1,7 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "hindsight",
"version": "0.7.2",
"description": "Official Hindsight integrations for Claude Code",
"owner": {
"name": "vectorize-io"
+15 -1
View File
@@ -172,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
@@ -191,6 +192,18 @@ If a migration adds a new PostgreSQL table (look for `CREATE TABLE` / `op.create
- The guard test `test_backup_tables_covers_entire_schema` in `tests/test_admin_backup_restore.py` enforces this — flag it as a **must fix** if a new table is absent from `BACKUP_TABLES`.
- Oracle-only tables (e.g. `observation_sources`) are intentionally excluded — admin backup/restore is PostgreSQL-only.
### 11b. Check new config flags update the env template
If the diff adds a new configuration field (a new `ENV_*` / `HINDSIGHT_*` env var
in `hindsight-api-slim/hindsight_api/config.py`):
- **`.env.example`** (repo root) — must add the variable (commented if optional)
alongside the docs entry in `hindsight-docs/docs/developer/configuration.md`.
A flag added to `config.py` but absent from `.env.example` is a **should fix**.
- **`hindsight-embed/hindsight_embed/env.example`** — the bundled copy must stay
byte-identical to the repo-root `.env.example` (it seeds embed/profile configs).
The `test_bundled_template_matches_repo_root` sync test fails on drift; if the
root file changed without re-copying, flag it as a **must fix**.
### 12. Review against other coding standards
Check the diff for violations of the standards listed above:
@@ -217,6 +230,7 @@ Present a clear summary organized by severity:
- 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:
+116
View File
@@ -0,0 +1,116 @@
---
name: hs-release
description: Cut a core Hindsight release (vX.Y.Z) and open the changelog + blog PR. Use when asked to cut/start a release, bump the version, or publish a new Hindsight version.
user_invocable: true
---
# Hindsight Release
Cut a **core** Hindsight release and open the accompanying changelog/blog PR. This is for the core
product version (API, clients, CLI, control plane, Helm). **Integrations are versioned
independently** — use `scripts/release-integration.sh` for those, not this skill.
The release is **irreversible and outward-facing**: it tags a version and pushes it straight to
`main`, which triggers CI that publishes packages to PyPI / npm / Helm. Confirm the version number
and that the intended fixes are already merged to `main` before you start.
## Step 0 — Pre-flight
1. **Decide the base.** A release is cut from the latest `origin/main`, never from a feature
branch. `git fetch origin --tags` first. Confirm the "couple of fixes" the user means are
actually merged to `main` (`git log v<prev>..origin/main --oneline`).
2. **Find where `main` is checked out.** `main` is often already checked out in a sibling worktree
(`git worktree list`). You **cannot** check out `main` in a second worktree — run the release in
the worktree that already holds it. If that worktree is dirty with throwaway cruft
(`.next-*` tsconfig paths, screenshots), `git stash push -u`, fast-forward to `origin/main`,
run the release, then `git stash pop`.
3. **Pitfall:** never pipe the checkout in an `&&` chain like
`git checkout main 2>&1 | tail && git reset --hard ...` — the pipe's exit status is `tail`'s
(always 0), so a failed checkout won't stop the chain and the `reset` fires on the **wrong
branch**. Check out as its own command and verify `git branch --show-current` before resetting.
## Step 1 — Cut the release
Run from the worktree on a clean `main`:
```bash
./scripts/release.sh <version> # e.g. 0.8.1 (no leading v)
```
`release.sh` bumps the version in every component, regenerates the OpenAPI spec + all client SDKs,
updates docs versioning, commits `Release v<version>`, tags `v<version>`, and **pushes the commit
and tag directly to `main`**. The push triggers the `Release` GitHub Actions workflow that builds
and publishes the packages. It is **not** a PR.
Verify after: `gh run list --limit 5` should show the `Release v<version>` workflow running, and
`git ls-remote --tags origin v<version>` should return the tag.
## Step 2 — Changelog + blog PR (separate)
Done **after** the tag exists, as its own PR (precedent: v0.8.0 = #2053, v0.8.1 = #2080). Work on a
branch off the new `main`:
```bash
git checkout -b docs-changelog-<version> origin/main
```
Only spin up a separate worktree (`git worktree add ../hindsight-changelog-<version> -b
docs-changelog-<version> origin/main`) if you can't get a clean checkout otherwise — e.g. `main` is
held in another worktree and the current one has work you don't want to disturb.
**Branch naming:** use the `docs-` (hyphen) convention, e.g. `docs-changelog-0.8.1`. A remote
branch literally named `docs` exists, so any `docs/...` branch is rejected on push with
`directory file conflict`.
### Changelog
```bash
uv run --directory hindsight-dev generate-changelog <version>
```
LLM-summarizes the commits between the previous tag and `v<version>` and prepends an entry to
`hindsight-docs/src/pages/changelog/index.md`. Requires `OPENAI_API_KEY` (already in the repo
`.env`). It excludes `hindsight-integrations/` source, but new integrations whose commits also
touched docs will still appear — that matches precedent, leave them in the **changelog**.
### Blog post
Hand-write `hindsight-docs/blog/YYYY-MM-DD-version-X-Y-Z.md` (mirror an existing one; patch
releases are short — see `2026-06-02-version-0-7-2.md`). Guidance:
- **Explain user impact, not internals/mechanism.** Lead with what the user can now do and what to
set. Config/env-var names are fine (developer-facing), code symbols and internals are not.
- **Do not list integrations in the release blog.** The core blog covers core engine / API /
ops changes; each integration ships its own changelog. (Integrations may still appear in the
generated `changelog/index.md` — that's fine; just keep them out of the blog.)
- Call out an upgrade recommendation when there are operational/data-integrity fixes.
- Validate formatting: `npx prettier --check <blog file>`.
### Sync the docs skill
```bash
./scripts/generate-docs-skill.sh
```
Refreshes `skills/hindsight-docs/references/changelog/index.md`. It will also bump
`skills/hindsight-docs/references/openapi.json` by one version — `release.sh` regenerates the skill
*before* bumping OpenAPI, so the skill copy lags a version in the release commit; this step syncs
it. Expect a one-line `version` diff there; keep it.
### Commit, push, PR
```bash
git add -A
git commit --no-verify -m "docs: changelog and blog post for v<version>"
git push -u origin docs-changelog-<version>
gh pr create --base main --title "docs: changelog and blog post for v<version>" --body "..."
```
Expected files in the PR: the changelog entry, the new blog post, the regenerated skill changelog
mirror, and the skill `openapi.json` version sync.
## Cleanup
If you created a temporary worktree, remove it once the PR is up
(`git worktree remove ../hindsight-changelog-<version>`; the branch stays on origin). Restore any
stash you popped in Step 0.
+68 -2
View File
@@ -2,7 +2,7 @@
# Copy this file to .env and fill in your values
# LLM Configuration (Required)
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai, minimax, deepseek, zai, volcano
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai, minimax, deepseek, zai, atlas, volcano
HINDSIGHT_API_LLM_PROVIDER=openai
HINDSIGHT_API_LLM_API_KEY=your-api-key-here
HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
@@ -10,6 +10,17 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# Reasoning effort for providers/models that support it. Examples: low, medium, high, xhigh.
# HINDSIGHT_API_LLM_REASONING_EFFORT=low
# Sampling temperature for internal LLM calls. Set a number in [0.0, 2.0], or `none`
# to omit the temperature parameter entirely (required for models that reject explicit
# temperatures, e.g. Azure gpt-5.5). The global override below applies to every operation;
# per-operation overrides (defaults: verification=0.0, retain=0.1, reflect=0.9,
# consolidation=0.0) take precedence.
# HINDSIGHT_API_LLM_TEMPERATURE=none
# HINDSIGHT_API_LLM_TEMPERATURE_VERIFICATION=0.0
# HINDSIGHT_API_LLM_TEMPERATURE_RETAIN=0.1
# HINDSIGHT_API_LLM_TEMPERATURE_REFLECT=0.9
# HINDSIGHT_API_LLM_TEMPERATURE_CONSOLIDATION=0.0
# Example: Anthropic Claude configuration
# HINDSIGHT_API_LLM_PROVIDER=anthropic
# HINDSIGHT_API_LLM_API_KEY=your-anthropic-api-key
@@ -37,16 +48,41 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# HINDSIGHT_API_LLM_API_KEY=your-zai-api-key
# HINDSIGHT_API_LLM_MODEL=glm-4.5-flash # or glm-4.5-air for the paid tier
# Example: Atlas Cloud configuration (OpenAI-compatible, https://www.atlascloud.ai)
# HINDSIGHT_API_LLM_PROVIDER=atlas
# HINDSIGHT_API_LLM_API_KEY=your-atlascloud-api-key
# HINDSIGHT_API_LLM_MODEL=deepseek-ai/deepseek-v4-pro # reasoning model; also Qwen / GLM / Kimi / MiniMax, etc.
# Example: LM Studio local configuration (Qwen 2.5 32B recommended)
# HINDSIGHT_API_LLM_PROVIDER=lmstudio
# HINDSIGHT_API_LLM_API_KEY=lmstudio
# HINDSIGHT_API_LLM_BASE_URL=http://localhost:1234/v1
# HINDSIGHT_API_LLM_MODEL=qwen2.5-32b-instruct
# Multi-LLM strategies: configure extra LLMs by index alongside the primary above,
# then pick a routing strategy. Unset = single primary LLM (default). Members are
# numbered from 1; indices must be contiguous. Each operation can override with a
# RETAIN_/REFLECT_/CONSOLIDATION_ prefix (e.g. HINDSIGHT_API_RETAIN_LLM_1_PROVIDER).
# HINDSIGHT_API_LLM_1_PROVIDER=groq
# HINDSIGHT_API_LLM_1_API_KEY=your-groq-api-key
# HINDSIGHT_API_LLM_1_MODEL=openai/gpt-oss-120b
# HINDSIGHT_API_LLM_2_PROVIDER=anthropic
# HINDSIGHT_API_LLM_2_API_KEY=your-anthropic-api-key
# Strategy JSON: {"mode": "failover"} or {"mode": "round-robin"}.
# Round-robin accepts optional positive-int "weights" (one per member, primary first).
# HINDSIGHT_API_LLM_STRATEGY={"mode": "failover"}
# API Configuration (Optional)
HINDSIGHT_API_HOST=0.0.0.0
HINDSIGHT_API_PORT=8888
HINDSIGHT_API_LOG_LEVEL=info
# Optional retain chunking override for structured logs/transcripts.
# Unset uses HINDSIGHT_API_RETAIN_CHUNK_SIZE as the structured-chunk limit.
# HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE=
# Dry-run extraction preview endpoint (POST /memories/dry-run-extract). Enabled by default; it makes
# a real LLM call but stores nothing. Set to false to remove the endpoint (returns 404).
# HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=true
# Base Path / Reverse Proxy Support (Optional)
# Set these when deploying behind a reverse proxy with path-based routing
@@ -59,6 +95,7 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_READ_DATABASE_URL= # Optional read-replica URL. When set, recall queries (semantic, BM25, graph, temporal) flow through a separate pool against this URL, offloading the primary. Typically points to a read-only endpoint (CNPG's <cluster>-ro service or Aurora reader endpoint).
# HINDSIGHT_API_MIGRATION_DATABASE_URL= # Direct PostgreSQL URL for migrations (bypasses PgBouncer). Falls back to DATABASE_URL.
# HINDSIGHT_API_DATABASE_SCHEMA=public # PostgreSQL schema name (default: public)
# HINDSIGHT_API_MIGRATION_CONCURRENCY=1 # Tenant schemas to migrate concurrently (PG only, each in its own process; per-schema work stays sequential). Each worker has ~1-2s startup cost + uses ~3 DB connections, so it only pays off with many schemas (tens+) or slow migrations; keep concurrency*3 <= spare max_connections. Default: 1 (sequential).
# Vector Extension (Optional - uses pgvector by default)
# Options: "pgvector" (default), "vchord", "pgvectorscale" (DiskANN)
@@ -79,11 +116,36 @@ HINDSIGHT_API_LOG_LEVEL=info
# korean_lindera/lindera(korean), ngram(min,max), edge_ngram(min,max)
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER=
# File Parser (Optional - uses markitdown by default)
# HINDSIGHT_API_FILE_PARSER=markitdown
# Enable image OCR for MarkItDown using an OpenAI-compatible OCR/vision endpoint.
# These OCR settings are independent from HINDSIGHT_API_LLM_* because MarkItDown
# uses the OpenAI SDK directly and requires Chat Completions image input support.
# When OCR is enabled, API_KEY, BASE_URL, and MODEL are required.
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED=false
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY=
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL=
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL=
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_PROMPT=
# 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:
@@ -129,6 +191,10 @@ HINDSIGHT_API_LOG_LEVEL=info
# Custom service name and environment (optional, defaults: hindsight-api, development)
# HINDSIGHT_API_OTEL_SERVICE_NAME=hindsight-production
# HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=production
#
# Expose async-operation queue + consolidation-backlog gauges on /metrics.
# Runs periodic per-schema COUNT queries on a background task (disabled by default).
# HINDSIGHT_API_METRICS_BACKLOG_ENABLED=true
# -----------------------------------------------------------------------------
# Control Plane (Optional)
-6
View File
@@ -1,6 +0,0 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+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
+1
View File
@@ -23,6 +23,7 @@ on:
- retain
- recall
- recall-with-observations
- recall-temporal
- consolidation
- graph-maintenance
default: ""
+77 -3
View File
@@ -9,7 +9,11 @@ jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write # for PyPI trusted publishing
id-token: write # for PyPI trusted publishing + build-provenance attestations
attestations: write # for actions/attest-build-provenance (Obsidian assets)
# No `contents: write`: we never create releases in this repo. The Obsidian
# plugin's distribution release is pushed to its dedicated repo using
# OBSIDIAN_DIST_TOKEN (see the "Mirror Obsidian plugin" step below).
steps:
- uses: actions/checkout@v6
@@ -71,7 +75,7 @@ jobs:
if: steps.type.outputs.type == 'plugin'
run: |
echo "Plugin integration ${{ steps.info.outputs.integration }} v${{ steps.info.outputs.version }} — no package to publish."
echo "Users install via: claude plugin marketplace add vectorize-io/hindsight --sparse hindsight-integrations"
echo "Users install via: claude plugin marketplace add vectorize-io/hindsight"
# ── TypeScript integrations (ai-sdk, chat, openclaw) ────────────────────
@@ -112,6 +116,71 @@ jobs:
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: npm run build
# Build-provenance attestations for the Obsidian release assets (community-store
# recommendation). Runs after the build so main.js exists. The assets are
# released in the dedicated repo while the build runs here, so users verify at
# owner scope: `gh attestation verify main.js --owner vectorize-io`.
- name: Attest Obsidian plugin build provenance
if: steps.type.outputs.type == 'typescript' && steps.info.outputs.integration == 'obsidian'
uses: actions/attest-build-provenance@v2
with:
subject-path: |
hindsight-integrations/obsidian/main.js
hindsight-integrations/obsidian/styles.css
# ── Obsidian plugin — mirror to its dedicated repo + cut the BRAT release ──
# We do NOT create a GitHub Release in this monorepo: per-integration
# releases pollute the repo's release list (it's for the core product) and
# steal the "Latest" badge, and BRAT / the community store read a repo's
# *latest* release — not a tag — so they can't target a tag in a monorepo.
#
# Instead this monorepo stays the source of truth, and on each obsidian
# release we mirror hindsight-integrations/obsidian/ → the *root* of
# github.com/vectorize-io/hindsight-obsidian (git subtree, history
# preserved) and cut the BRAT / community-store release *there*.
#
# Requires secret OBSIDIAN_DIST_TOKEN — a token with `contents: write` on
# vectorize-io/hindsight-obsidian (fine-grained PAT or app installation
# token). The dedicated repo is generated; never edit it directly.
- name: Mirror Obsidian plugin to its dedicated repo
if: steps.type.outputs.type == 'typescript' && steps.info.outputs.integration == 'obsidian'
env:
DIST_TOKEN: ${{ secrets.OBSIDIAN_DIST_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ steps.info.outputs.version }}"
DIST_REPO="vectorize-io/hindsight-obsidian"
OBS_DIR="hindsight-integrations/obsidian"
# `git subtree split` needs full history; the default checkout is shallow.
git fetch --unshallow 2>/dev/null || true
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# The runner injects the default GITHUB_TOKEN as an http.extraheader via
# an *included* config file (/home/runner/work/_temp/git-credentials-*.config),
# so `git config --local --unset-all` can't remove it and it authenticates
# the push as github-actions[bot] (no access to the dedicated repo → 403).
# The documented way to drop an inherited extraheader is to RESET the list
# with an empty value: since command-line `-c` is read last, the empty
# value clears the accumulated headers (including the included one) at
# request-build time. The dist token then comes from the push URL → a
# single Authorization header.
git subtree split --prefix="$OBS_DIR" -b _obs_dist
git -c "http.https://github.com/.extraheader=" \
push "https://x-access-token:${DIST_TOKEN}@github.com/${DIST_REPO}.git" _obs_dist:main
# Cut the BRAT / community-store release. Bare version tag (e.g. 0.1.0)
# to match manifest.json — idempotent so re-runs just refresh the assets.
export GH_TOKEN="$DIST_TOKEN"
ASSETS="$OBS_DIR/main.js $OBS_DIR/manifest.json $OBS_DIR/styles.css"
NOTES="Hindsight for Obsidian v${VERSION}. Install via BRAT (add ${DIST_REPO}) or copy main.js/manifest.json/styles.css into <vault>/.obsidian/plugins/hindsight/."
if gh release view "$VERSION" --repo "$DIST_REPO" >/dev/null 2>&1; then
gh release upload "$VERSION" $ASSETS --repo "$DIST_REPO" --clobber
else
gh release create "$VERSION" $ASSETS --repo "$DIST_REPO" --title "$VERSION" --notes "$NOTES"
fi
- name: Publish TypeScript package to npm
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
@@ -121,7 +190,12 @@ jobs:
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
# Treat "already published" as success so re-pointed-tag re-runs stay green.
# "cannot publish over" = the version exists. TLOG_CREATE_ENTRY_ERROR / 409
# "equivalent entry already exists in the transparency log" = the identical
# --provenance artifact was already logged on a prior run (Sigstore tlog is
# idempotent); the package is published, so this is benign.
if echo "$OUTPUT" | grep -qE "cannot publish over|TLOG_CREATE_ENTRY_ERROR|already exists in the transparency log"; then
echo "Package version already published, skipping..."
exit 0
fi
+2 -2
View File
@@ -266,7 +266,7 @@ jobs:
strategy:
matrix:
include:
- os: ubuntu-latest
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
artifact_name: hindsight
asset_name: hindsight-linux-amd64
@@ -278,7 +278,7 @@ jobs:
target: aarch64-apple-darwin
artifact_name: hindsight
asset_name: hindsight-darwin-arm64
- os: ubuntu-24.04-arm
- os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
artifact_name: hindsight
asset_name: hindsight-linux-arm64
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -6,6 +6,7 @@ dist/
wheels/
*.egg-info
.mcp.json
.playwright-mcp/
.osgrep
# Virtual environments
.venv
@@ -15,6 +16,8 @@ node_modules/
# Environment variables and local config
.env
.env.bak*
.env.*.bak
docker-compose.yml
docker-compose.override.yml
@@ -59,4 +62,5 @@ hindsight-integrations/_drafts/
# Changelog is now tracked in hindsight-docs/src/pages/changelog.md
# CHANGELOG.md
blog-post*
blog-post*
.worktrees/
+30 -5
View File
@@ -216,6 +216,18 @@ migration file dispatches through `run_for_dialect`, which calls either
./scripts/hooks/lint.sh
```
Dead-code detection runs in CI (the `check-unused-code` job) at two levels:
- **Blocking:** unused imports (ruff `F401`) and variables (`F841`) — `lint.sh` auto-removes
them and `verify-generated-files` fails on any leftover diff; and **knip** for orphaned
control-plane files / unused (or unlisted) `package.json` dependencies.
- **Advisory:** whole unused Python functions (vulture) and unused control-plane *exports*
(the shadcn/ui surface is kept on purpose) — surfaced, not gated.
Run both locally with:
```bash
./scripts/hooks/check-unused.sh
```
**After completing any implementation work, run `/code-review`** to verify your changes against project standards (missing tests, dead code, type safety, etc.). Fix any "must fix" issues before considering the task done.
**MANDATORY: Run `/code-review` before pushing code or creating a pull request.** Do not push or create a PR until all "must fix" issues are resolved.
@@ -315,7 +327,10 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
```
2. **main.py** (`hindsight-api-slim/hindsight_api/main.py`):
- Add field to the manual `HindsightConfig()` constructor call (search for "CLI override")
- No change is needed for ordinary environment-backed config fields. The CLI starts from `_get_raw_config()`,
so new `HindsightConfig` fields are carried through automatically.
- If the new field should be overridable by a CLI flag, add the argparse option in `_parse_cli_args()` and include
that field in the `dataclasses.replace(config, ...)` call near the "CLI override" comment.
3. **Use hierarchical config in MemoryEngine**:
```python
@@ -335,6 +350,16 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
- Add to appropriate section table with Variable, Description, Default
- Mark if it's hierarchical (can be overridden per-bank)
6. **Env template** (`.env.example`):
- Add the variable to the appropriate section, commented if optional, with a
short inline comment describing it (mirror the documentation entry).
- This file is the single source of truth for the env template:
`scripts/dev/setup.sh` copies it to `.env`, and `hindsight-embed` ships a
bundled copy (`hindsight-embed/hindsight_embed/env.example`) that seeds
embed/profile configs. After editing `.env.example`, re-copy it to the
embed package (`cp .env.example hindsight-embed/hindsight_embed/env.example`)
or the `test_bundled_template_matches_repo_root` sync test will fail.
#### Hierarchical vs Static Guidelines
**Hierarchical** (per-bank overridable):
@@ -351,7 +376,7 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
```bash
cp .env.example .env
# Edit .env with LLM API key
# Edit .env with the LLM provider/model and credentials for your setup
# Python deps
uv sync --directory hindsight-api-slim/
@@ -360,10 +385,10 @@ uv sync --directory hindsight-api-slim/
npm install
```
Required env vars:
Common LLM settings:
- `HINDSIGHT_API_LLM_PROVIDER`: openai, anthropic, gemini, groq, minimax, ollama, lmstudio
- `HINDSIGHT_API_LLM_API_KEY`: Your API key
- `HINDSIGHT_API_LLM_MODEL`: Model name (e.g., gpt-4o-mini, claude-sonnet-4-20250514)
- `HINDSIGHT_API_LLM_API_KEY`: API key for providers that require one
- `HINDSIGHT_API_LLM_MODEL`: Model name (defaults are provider-specific)
Optional (uses local models by default):
- `HINDSIGHT_API_EMBEDDINGS_PROVIDER`: local (default) or tei
+19 -5
View File
@@ -7,7 +7,6 @@
[![CI](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml/badge.svg)](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml)
[![Slack Community](https://img.shields.io/badge/Slack-Join%20Community-4A154B?logo=slack)](https://join.slack.com/t/hindsight-space/shared_invite/zt-3nhbm4w29-LeSJ5Ixi6j8PdiYOCPlOgg)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![gitcgr](https://gitcgr.com/badge/vectorize-io/hindsight.svg)](https://gitcgr.com/vectorize-io/hindsight)
![PyPI - Downloads](https://img.shields.io/pypi/dm/hindsight-api?label=PyPI)
![NPM Downloads](https://img.shields.io/npm/dm/%40vectorize-io%2Fhindsight-client?logoColor=orange&label=NPM&color=blue&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40vectorize-io%2Fhindsight-client)
<br/>
@@ -64,14 +63,14 @@ export OPENAI_API_KEY=sk-xxx
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
```
>API: http://localhost:8888
>UI: http://localhost:9999
You can modify the LLM provider by setting `HINDSIGHT_API_LLM_PROVIDER`. Valid options are `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `lmstudio`, and `minimax`. The documentation provides more details on [supported models](https://hindsight.vectorize.io/developer/models).
You can modify the LLM provider by setting `HINDSIGHT_API_LLM_PROVIDER`. Valid options are `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `lmstudio`, `minimax`, and `atlas` ([Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=hindsight)). The documentation provides more details on [supported models](https://hindsight.vectorize.io/developer/models).
@@ -143,6 +142,8 @@ main();
pip install hindsight-all -U
```
On Intel (x86_64) Macs, install `hindsight-all-slim` instead — see [Supported Platforms](#supported-platforms).
```python
import os
from hindsight import HindsightServer, HindsightClient
@@ -249,7 +250,7 @@ Recall performs 4 retrieval strategies in parallel:
- Graph: Entity/temporal/causal links
- Temporal: Time range filtering
![Retain Operation](hindsight-docs/static/img/recall-operation.webp)
![Recall Operation](hindsight-docs/static/img/recall-operation.webp)
The individual results from the retrievals are merged, then ordered by relevance using reciprocal rank fusion and a cross-encoder reranking model.
@@ -275,7 +276,7 @@ client = Hindsight(base_url="http://localhost:8888")
client.reflect(bank_id="my-bank", query="What should I know about Alice?")
```
![Retain Operation](hindsight-docs/static/img/reflect-operation.webp)
![Reflect Operation](hindsight-docs/static/img/reflect-operation.webp)
---
@@ -300,6 +301,19 @@ client.reflect(bank_id="my-bank", query="What should I know about Alice?")
[![Star History Chart](https://api.star-history.com/svg?repos=vectorize-io/hindsight&type=date&legend=top-left)](https://www.star-history.com/#vectorize-io/hindsight&type=date&legend=top-left)
---
## Supported Platforms
| Platform | Docker | Bare Metal (pip) | Embedded DB (pg0) |
|----------|--------|------------------|--------------------|
| **Linux** (x86_64, ARM64) | ✅ | ✅ | ✅ |
| **macOS** (Apple Silicon / arm64) | ✅ | ✅ | ✅ |
| **macOS** (Intel / x86_64) | ✅ | ⚠️ | ✅ |
| **Windows** (x86_64) | ✅ | ✅ | ✅ |
⚠️ Intel Macs: use `hindsight-all-slim` — see the [installation guide](https://hindsight.vectorize.io/developer/installation#supported-platforms) for details.
---
## Contributing
See [CONTRIBUTING.md](./CONTRIBUTING.md).
Generated
-1
View File
@@ -77,7 +77,6 @@
"npm:@radix-ui/react-dropdown-menu@^2.1.16",
"npm:@radix-ui/react-label@^2.1.8",
"npm:@radix-ui/react-popover@^1.1.15",
"npm:@radix-ui/react-radio-group@^1.3.8",
"npm:@radix-ui/react-select@^2.2.6",
"npm:@radix-ui/react-slider@^1.3.6",
"npm:@radix-ui/react-slot@^1.2.4",
@@ -1,6 +1,6 @@
name: hindsight
# Docker Compose file for Hindsight with PostgreSQL and vectorchord
# docker compose -f docker/docker-compose/docker-compose.yaml down && sleep 2 && docker compose -f docker/docker-compose/docker-compose.yaml up -d
# docker compose -f docker/docker-compose/vchord/docker-compose.yaml down && sleep 2 && docker compose -f docker/docker-compose/vchord/docker-compose.yaml up -d
# Make sure to set the required environment variables before running:
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
# - Configure LLM provider variables as needed (see below in the hindsight service)
+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.2
appVersion: "0.7.2"
version: 0.8.4
appVersion: "0.8.4"
keywords:
- ai
- memory
+3 -3
View File
@@ -66,13 +66,13 @@ helm install hindsight ./helm/hindsight -n hindsight --create-namespace -f value
| Parameter | Description | Default |
|-----------|-------------|---------|
| `version` | Default image tag for all components | `0.1.0` |
| `version` | Default image tag for all components | Chart `appVersion` |
| `api.enabled` | Enable the API component | `true` |
| `api.image.repository` | API image repository | `hindsight/api` |
| `api.image.repository` | API image repository | `ghcr.io/vectorize-io/hindsight-api` |
| `api.image.tag` | API image tag (defaults to `version`) | - |
| `api.service.port` | API service port | `8888` |
| `controlPlane.enabled` | Enable the control plane | `true` |
| `controlPlane.image.repository` | Control plane image repository | `hindsight/control-plane` |
| `controlPlane.image.repository` | Control plane image repository | `ghcr.io/vectorize-io/hindsight-control-plane` |
| `controlPlane.image.tag` | Control plane image tag (defaults to `version`) | - |
| `controlPlane.service.port` | Control plane service port | `3000` |
| `postgresql.enabled` | Deploy PostgreSQL as subchart | `true` |
-3
View File
@@ -13,9 +13,6 @@
# - Any other env vars you want to inject
# existingSecret: "my-hindsight-secret"
# Global settings
replicaCount: 1
# Image settings for api
api:
enabled: true
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.7.2",
"version": "0.8.4",
"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.2"
version = "0.8.4"
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.2",
"hindsight-api-slim==0.8.4",
"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.2"
version = "0.8.4"
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.2",
"hindsight-api-slim[all]==0.8.4",
"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.2",
"hindsight-api-slim[local-llm]==0.8.4",
]
test = [
"pytest>=7.0.0",
+1 -1
View File
@@ -121,7 +121,7 @@ This runs a stdio-based MCP server that can be used directly with MCP-compatible
- **Entity Graph** — Automatic entity extraction and relationship tracking
- **Temporal Reasoning** — Native support for time-based queries
- **Disposition Traits** — Configurable skepticism, literalism, and empathy influence opinion formation
- **Three Memory Types** — World facts, bank actions, and formed opinions with confidence scores
- **Three Memory Types** — World facts, experience facts (the bank's own actions), and observations
## Documentation
+1 -1
View File
@@ -53,4 +53,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.7.2"
__version__ = "0.8.4"
@@ -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 = {
+38 -33
View File
@@ -49,10 +49,14 @@ BACKUP_TABLES = [
"entities",
"chunks",
"memory_units",
"invalidated_memory_units",
"unit_entities",
"entity_cooccurrences",
"memory_links",
"observation_history",
"mental_models",
"mental_model_history",
"knowledge_pages",
"directives",
"async_operations",
"webhooks",
@@ -253,14 +257,10 @@ async def _run_migration(
schema: str | None = None,
base_schema: str = DEFAULT_DATABASE_SCHEMA,
embedding_dimension: int | None = None,
ensure_extensions: bool = True,
) -> list[str]:
"""Resolve database URL and run migrations for one schema or all discovered schemas."""
from ..migrations import (
ensure_embedding_dimension,
ensure_text_search_extension,
ensure_vector_extension,
run_migrations,
)
from ..migrations import run_migrations_for_schemas
is_pg0, instance_name, _ = parse_pg0_url(db_url)
if is_pg0:
@@ -281,32 +281,21 @@ async def _run_migration(
# Preserve order while removing duplicates.
schemas = list(dict.fromkeys(schemas))
for schema in schemas:
run_migrations(resolved_url, schema=schema, migration_database_url=config.migration_database_url)
if embedding_dimension is not None:
for schema in schemas:
ensure_embedding_dimension(
resolved_url,
embedding_dimension,
schema=schema,
vector_extension=config.vector_extension,
)
for schema in schemas:
ensure_vector_extension(
resolved_url,
vector_extension=config.vector_extension,
schema=schema,
)
for schema in schemas:
ensure_text_search_extension(
resolved_url,
text_search_extension=config.text_search_extension,
pg_search_tokenizer=config.text_search_extension_pg_search_tokenizer,
schema=schema,
)
# Migrate up to `migration_concurrency` schemas at once (each in its own
# process); within a schema the work stays sequential. Run off the event
# loop so the process pool's blocking joins don't stall it.
await asyncio.to_thread(
run_migrations_for_schemas,
resolved_url,
schemas,
concurrency=config.migration_concurrency,
migration_database_url=config.migration_database_url,
embedding_dimension=embedding_dimension,
vector_extension=config.vector_extension,
text_search_extension=config.text_search_extension,
pg_search_tokenizer=config.text_search_extension_pg_search_tokenizer,
ensure_extensions=ensure_extensions,
)
return schemas
@@ -324,6 +313,18 @@ def run_db_migration(
"--embedding-dimension",
help="Expected embedding dimension to enforce after migrations. Omit to skip dimension sync.",
),
skip_extension_reconcile: bool = typer.Option(
False,
"--skip-extension-reconcile",
help=(
"Skip the post-migration vector / text-search index reconcile. This step only does "
"work when the configured backend (HINDSIGHT_API_VECTOR_EXTENSION / "
"HINDSIGHT_API_TEXT_SEARCH_EXTENSION) differs from a schema's existing indexes — a "
"rare, operator-driven change. Skipping it makes a no-change re-migration over many "
"tenant schemas much faster. Only use when you have NOT changed the backend; a "
"backend change still needs a normal run to reshape the indexes."
),
),
):
"""Run database migrations to the latest version."""
config = HindsightConfig.from_env()
@@ -337,6 +338,8 @@ def run_db_migration(
typer.echo(f"Running database migrations for schema: {schema}...")
else:
typer.echo("Running database migrations for base schema and all discovered tenant schemas...")
if skip_extension_reconcile:
typer.echo("Skipping post-migration extension reconcile (--skip-extension-reconcile).")
schemas = asyncio.run(
_run_migration(
@@ -344,6 +347,7 @@ def run_db_migration(
schema=schema,
base_schema=config.database_schema,
embedding_dimension=embedding_dimension,
ensure_extensions=not skip_extension_reconcile,
)
)
@@ -461,7 +465,8 @@ def import_bank_command(
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), {result.directives_imported} directive(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)"
)
@@ -0,0 +1,105 @@
"""Add a composite index on memory_links(bank_id, link_type) (PostgreSQL).
``bank_id`` was added to ``memory_links`` in ``c5d6e7f8a9b0`` precisely so that
bank-scoped reads (e.g. the stats endpoint) could filter on the link table
directly instead of joining ``memory_units`` — that JOIN took 18+ seconds on
banks with millions of links. The column landed without an index, so every
``bank_id = $1`` predicate still falls back to a sequential scan over the whole
table.
This adds the missing btree. It is composite on ``(bank_id, link_type)`` rather
than ``bank_id`` alone because the hot query is the stats endpoint's
``SELECT link_type, COUNT(*) ... WHERE bank_id = $1 GROUP BY link_type``: a
``(bank_id, link_type)`` index serves that filter, grouping and count as an
index-only scan, never touching the heap, whereas a ``bank_id``-only index would
still have to read every matching row to recover ``link_type``. ``link_type`` is
low-cardinality (only ``temporal``/``semantic``/``caused_by`` are written —
entity edges were dropped in ``e9b2c7d1f3a4``), so the trailing column adds
little to the index size while removing the heap fetch.
The Oracle baseline (``o1a2b3c4d5e6``) already creates ``idx_ml_bank_id`` on
``memory_links(bank_id)``; that single-column index already covers Oracle's
bank-scoped filter, so the Oracle slot here is intentionally absent and only the
PostgreSQL dialect gets the composite index.
``memory_links`` can hold tens of millions of rows, so the index is built
CONCURRENTLY to avoid taking a write lock on the table. CONCURRENTLY cannot run
inside a transaction block, so the statement runs in an ``autocommit_block()``;
``IF NOT EXISTS`` keeps it idempotent across retries and re-migrated tenant
schemas. A CONCURRENTLY build interrupted partway (lock conflict, disk
pressure, signal) leaves the index behind as *invalid*; ``IF NOT EXISTS`` would
then skip over it forever, so the upgrade first drops any invalid leftover of
this name before (re)creating it.
Revision ID: 2071c7518f88
Revises: a1d3f5b7c9e2
Create Date: 2026-06-16
"""
from collections.abc import Sequence
from alembic import context, op
from sqlalchemy import text
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "2071c7518f88"
down_revision: str | Sequence[str] | None = "a1d3f5b7c9e2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_INDEX_NAME = "idx_memory_links_bank_id_link_type"
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 ""
def _pg_upgrade() -> None:
bind = op.get_bind()
# `or None` collapses an unset option and an explicit empty string into NULL
# so the COALESCE below falls back to current_schema() in both cases.
target_schema = context.config.get_main_option("target_schema") or None
schema = _get_schema_prefix()
# CREATE INDEX CONCURRENTLY cannot run inside a transaction block; the
# autocommit_block runs each statement outside Alembic's migration
# transaction.
with op.get_context().autocommit_block():
# A CONCURRENTLY build that errored on a previous run leaves an INVALID
# index of this name behind. `CREATE INDEX ... IF NOT EXISTS` would see
# that relation and skip, so bank_id queries would keep seq-scanning.
# Drop only the invalid leftover — never a healthy index — so the retry
# actually rebuilds a usable one.
leftover_invalid = bind.execute(
text(
"SELECT NOT i.indisvalid "
"FROM pg_class c "
"JOIN pg_index i ON c.oid = i.indexrelid "
"JOIN pg_namespace n ON c.relnamespace = n.oid "
"WHERE c.relname = :index_name "
" AND n.nspname = COALESCE(:target_schema, current_schema())"
),
{"index_name": _INDEX_NAME, "target_schema": target_schema},
).scalar()
if leftover_invalid:
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}{_INDEX_NAME}")
# IF NOT EXISTS keeps the create idempotent across retries and schemas.
op.execute(f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {_INDEX_NAME} ON {schema}memory_links(bank_id, link_type)")
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
with op.get_context().autocommit_block():
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}{_INDEX_NAME}")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,85 @@
"""Repair: widen the remaining live ``bank_id`` columns from VARCHAR(64) to TEXT on PostgreSQL.
Follow-up to ``c3e5a7b9d1f4`` (issue #2106), which widened the two *history*
tables (``observation_history``, ``mental_model_history``) to ``TEXT`` after the
narrow ``VARCHAR(64)`` declaration bricked startup. The same VARCHAR(64) / TEXT
inconsistency still affects the live tables that store a user-supplied
``bank_id``:
* ``directives`` -- created VARCHAR(64) in ``p1k2l3m4n5o6``
* ``mental_models`` -- VARCHAR(64) (origin ``pinned_reflections`` in
``n9i0j1k2l3m4``; recreated in ``h3c4d5e6f7g8``)
``mental_model_versions`` is intentionally *not* widened here: it is created in
``j5e6f7g8h9i0`` but dropped (``DROP TABLE ... CASCADE``) in ``o0j1k2l3m4n5`` and
never recreated on the upgrade path, so it does not exist at head. Issuing
``ALTER TABLE mental_model_versions ...`` would raise ``UndefinedTable`` and --
because migrations run inside the lifespan-startup transaction -- roll the whole
migration back, bricking the API. (It is unrelated to the live
``mental_model_history`` table widened by ``c3e5a7b9d1f4``.)
``banks.bank_id`` is ``TEXT`` (unbounded), so a deployment can create a bank
whose id exceeds 64 chars -- the 78-char hierarchical org-unit shape reported in
issue #2106 -- and the bank insert succeeds. The next write that propagates that
id (``create_directive``, ``create_mental_model`` / consolidation, or
mental-model versioning) then aborts with::
psycopg2.errors.StringDataRightTruncation: value too long for type
character varying(64)
i.e. a 500 on core write endpoints, instead of the startup brick that
``c3e5a7b9d1f4`` already repaired.
``ALTER COLUMN ... TYPE TEXT`` is a no-op on a column that is already ``TEXT``,
so every upgrade path converges on ``TEXT``. These tables are per-tenant (they
live in each tenant schema, not ``public``), so this runs for every migrated
schema via the search-path-aware prefix -- the same mechanism as
``c3e5a7b9d1f4``.
PostgreSQL only: these tables are created by PostgreSQL-only migrations
(``run_for_dialect(pg=...)``); on Oracle they are absent or already
``VARCHAR2(256)`` (consistent, never truncates), so the Oracle slot is
intentionally absent -- mirroring ``c3e5a7b9d1f4``.
Revision ID: a1d3f5b7c9e2
Revises: c3e5a7b9d1f4
Create Date: 2026-06-13
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a1d3f5b7c9e2"
down_revision: str | Sequence[str] | None = "c3e5a7b9d1f4"
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 ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}directives ALTER COLUMN bank_id TYPE TEXT")
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN bank_id TYPE TEXT")
def _pg_downgrade() -> None:
# No-op: narrowing back to VARCHAR(64) could truncate real data and would
# re-introduce the bug this migration repairs. The column types are owned by
# the migrations that created the tables.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,52 @@
"""Add managed flag to knowledge_pages.
The knowledge base is managed by clients (CRUD over folders/pages). ``managed``
lets a client tag a node as system-owned vs. hand-authored; it carries no
server-side behaviour.
Revision ID: a5b6c7d8e9f0
Revises: a9b8c7d6e5f4
Create Date: 2026-06-26
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a5b6c7d8e9f0"
down_revision: str | Sequence[str] | None = "a9b8c7d6e5f4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}knowledge_pages ADD COLUMN IF NOT EXISTS managed BOOLEAN NOT NULL DEFAULT false")
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}knowledge_pages DROP COLUMN IF EXISTS managed")
def _oracle_upgrade() -> None:
op.execute("ALTER TABLE knowledge_pages ADD (managed NUMBER(1) DEFAULT 0 NOT NULL)")
def _oracle_downgrade() -> None:
op.execute("ALTER TABLE knowledge_pages DROP COLUMN managed")
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,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 TEXT 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 TEXT 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,110 @@
"""Add knowledge_pages table (knowledge-base hierarchy).
The knowledge base organizes synthesized mental models into a navigable tree of
**folders** and **pages**. A page references the mental model that holds its
content (``mental_model_id``); a folder is a pure container (``mental_model_id``
NULL). Hierarchy is a single self-referential ``parent_id`` so folders can nest
arbitrarily. Content stays in ``mental_models`` — this table is metadata + tree
structure only.
Revision ID: a9b8c7d6e5f4
Revises: b57a7c9e0d13
Create Date: 2026-06-25
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a9b8c7d6e5f4"
down_revision: str | Sequence[str] | None = "b57a7c9e0d13"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# parent_id self-FK cascades so deleting a folder row removes its whole
# subtree of rows in one shot. The mental_model FK is composite (matches the
# mental_models (id, bank_id) PK) and cascades too, so deleting a page's
# mental model removes the page row — folders skip the FK because a NULL
# column in a composite FK is not enforced (MATCH SIMPLE).
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}knowledge_pages (
id VARCHAR(64) NOT NULL,
bank_id TEXT NOT NULL,
parent_id VARCHAR(64),
kind VARCHAR(16) NOT NULL,
name TEXT NOT NULL,
mental_model_id VARCHAR(64),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT pk_knowledge_pages PRIMARY KEY (id),
CONSTRAINT ck_knowledge_pages_kind CHECK (kind IN ('folder', 'page')),
CONSTRAINT fk_kp_bank FOREIGN KEY (bank_id)
REFERENCES {schema}banks(bank_id) ON DELETE CASCADE,
CONSTRAINT fk_kp_parent FOREIGN KEY (parent_id)
REFERENCES {schema}knowledge_pages(id) ON DELETE CASCADE,
CONSTRAINT fk_kp_mm FOREIGN KEY (mental_model_id, bank_id)
REFERENCES {schema}mental_models(id, bank_id) ON DELETE CASCADE
)
"""
)
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_kp_bank_parent ON {schema}knowledge_pages (bank_id, parent_id, sort_order)"
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_kp_bank_parent")
op.execute(f"DROP TABLE IF EXISTS {schema}knowledge_pages")
def _oracle_upgrade() -> None:
op.execute(
"""
CREATE TABLE IF NOT EXISTS knowledge_pages (
id VARCHAR2(64) NOT NULL,
bank_id VARCHAR2(256) NOT NULL,
parent_id VARCHAR2(64),
kind VARCHAR2(16) NOT NULL,
name CLOB NOT NULL,
mental_model_id VARCHAR2(64),
sort_order NUMBER DEFAULT 0 NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_knowledge_pages PRIMARY KEY (id),
CONSTRAINT ck_knowledge_pages_kind CHECK (kind IN ('folder', 'page')),
CONSTRAINT fk_kp_bank FOREIGN KEY (bank_id)
REFERENCES banks(bank_id) ON DELETE CASCADE,
CONSTRAINT fk_kp_parent FOREIGN KEY (parent_id)
REFERENCES knowledge_pages(id) ON DELETE CASCADE,
CONSTRAINT fk_kp_mm FOREIGN KEY (mental_model_id, bank_id)
REFERENCES mental_models(id, bank_id) ON DELETE CASCADE
)
"""
)
op.execute("CREATE INDEX idx_kp_bank_parent ON knowledge_pages (bank_id, parent_id, sort_order)")
def _oracle_downgrade() -> None:
op.execute("DROP TABLE knowledge_pages 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,61 @@
"""Add bank_stats_cache table for distributed get_bank_stats caching
Revision ID: b57a7c9e0d13
Revises: c3f7a1b9d2e4
Create Date: 2026-07-01
get_bank_stats aggregates over memory_links / unit_entities — a multi-second scan
on banks with millions of rows. The result was cached per-process (in-memory), so
every API worker recomputed it once per TTL and the first caller after expiry
stalled. This table backs a shared, cross-process TTL cache: one worker's compute
is written here and served to all the others.
PostgreSQL only. Oracle keeps the in-process cache (the runtime picks the backing
store by dialect), so the Oracle upgrade slot is intentionally absent.
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "b57a7c9e0d13"
down_revision: str | Sequence[str] | None = "c3f7a1b9d2e4"
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 ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
# One row per bank: payload is the full get_bank_stats result, computed_at
# drives logical TTL expiry. Rows are overwritten in place (ON CONFLICT), so
# the table never grows beyond the number of banks and needs no purge job.
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}bank_stats_cache (
bank_id TEXT PRIMARY KEY,
payload JSONB NOT NULL,
computed_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP TABLE IF EXISTS {schema}bank_stats_cache")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade) # oracle slot intentionally absent → no-op
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,71 @@
"""Unique page name per folder in knowledge_pages.
The folder curator can fire concurrently (folder-create trigger + the
post-consolidation sweep), and an in-process lock can't serialize runs that
execute in different threads/loops. A partial unique index on
(bank_id, parent, lower(name)) for pages makes duplicate-named pages in the same
folder impossible at the DB level — the second concurrent insert fails and the
curator treats it as "already exists".
PostgreSQL only: the Oracle ``name`` column is a CLOB and cannot back a
functional unique index; Oracle relies on the in-process serialization instead.
Revision ID: c3d4e5f6a7b8
Revises: a5b6c7d8e9f0
Create Date: 2026-06-26
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c3d4e5f6a7b8"
down_revision: str | Sequence[str] | None = "a5b6c7d8e9f0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# First drop any pre-existing duplicate pages (created by the racy curator
# before this guard existed), keeping the earliest row of each duplicate set,
# so the unique index can be built. Their backing mental models are left in
# place (harmless orphans).
op.execute(
f"""
DELETE FROM {schema}knowledge_pages a
USING {schema}knowledge_pages b
WHERE a.kind = 'page' AND b.kind = 'page'
AND a.bank_id = b.bank_id
AND COALESCE(a.parent_id, '') = COALESCE(b.parent_id, '')
AND lower(a.name) = lower(b.name)
AND a.ctid > b.ctid
"""
)
# COALESCE(parent_id, '') so root-level pages (NULL parent) are also unique by
# name — NULLs would otherwise compare distinct and allow duplicates.
op.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_kp_folder_pagename "
f"ON {schema}knowledge_pages (bank_id, COALESCE(parent_id, ''), lower(name)) "
"WHERE kind = 'page'"
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}uq_kp_folder_pagename")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade) # oracle slot intentionally absent (CLOB name)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,75 @@
"""Repair: widen ``*_history.bank_id`` from VARCHAR(64) to TEXT on PostgreSQL.
The original split-history migration (``a7b8c9d0e1f2``) declared
``observation_history.bank_id`` and ``mental_model_history.bank_id`` as
``VARCHAR(64)`` on PostgreSQL. But ``memory_units.bank_id`` — the backfill
source for observations — is ``TEXT`` (unbounded), as are ``banks``,
``documents`` and ``entities``. Any deployment whose ``bank_id`` exceeds 64
characters aborts the backfill ``INSERT`` with::
psycopg2.errors.StringDataRightTruncation: value too long for type
character varying(64)
Because the migration runs in ``lifespan`` startup inside a transaction, the
whole migration rolls back and the API never comes up — unrecoverable from the
running container. See https://github.com/vectorize-io/hindsight/issues/2106.
``a7b8c9d0e1f2`` itself has been corrected to create the column as ``TEXT``,
which unblocks deployments that *failed* (the migration rolled back, so it
re-runs the fixed DDL). This forward migration covers deployments that already
*succeeded* with the narrow ``VARCHAR(64)`` column — where editing
``a7b8c9d0e1f2`` has no effect because it will not re-run — by widening the
column in place. ``ALTER COLUMN ... TYPE TEXT`` is a no-op on a column that is
already ``TEXT`` (fresh installs and re-run failures), so every upgrade path
converges on ``TEXT``.
The history tables are per-tenant (they live in each tenant schema, not
``public``), so this runs for every migrated schema via the search-path-aware
prefix — unlike the shared-``public`` routines repaired in ``b2d4f6a8c1e3``.
PostgreSQL only. On Oracle both ``memory_units.bank_id`` and the history
``bank_id`` columns are already ``VARCHAR2(256)`` (consistent, never
truncates), so the Oracle slot is intentionally absent.
Revision ID: c3e5a7b9d1f4
Revises: c9a1b2d3e4f5
Create Date: 2026-06-10
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c3e5a7b9d1f4"
down_revision: str | Sequence[str] | None = "c9a1b2d3e4f5"
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 ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}observation_history ALTER COLUMN bank_id TYPE TEXT")
op.execute(f"ALTER TABLE {schema}mental_model_history ALTER COLUMN bank_id TYPE TEXT")
def _pg_downgrade() -> None:
# No-op: narrowing back to VARCHAR(64) could truncate real data and would
# re-introduce the bug this migration repairs. The column type is owned by
# ``a7b8c9d0e1f2``'s lifecycle.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,144 @@
"""Backfill search_vector for native-backend observations.
Observations created or updated by the consolidator landed with a NULL
``search_vector`` under the ``native`` text-search backend: the
single-row INSERT/UPDATE paths in ``consolidator.py`` never populated the
tsvector (only the batch raw-fact path in ``ops_postgresql.insert_facts_batch``
did). Those observations were therefore invisible to the BM25 retrieval arm
until they were re-written by a later consolidation pass. The writer is fixed
in the same change set (all four consolidator sites now call
``to_tsvector($lang, COALESCE(text, ''))``); this migration repairs the
historical residue so existing observations become BM25-searchable without a
re-ingest.
Scope mirrors the writer fix exactly:
* Only the ``native`` backend is touched. The gate is the column *type*:
under ``native`` ``search_vector`` is a regular (non-generated) tsvector
column; under ``vchord`` it is a ``bm25vector`` and under
``pg_textsearch`` / ``pgroonga`` / ``pg_search`` it is a dummy ``text``
column. ``_is_regular_tsvector`` is true only for ``native``, so every
other backend is a no-op.
* The tsvector is built from the observation's own ``text`` only — matching
the consolidator INSERT/UPDATE paths (entity / source / temporal signals
are intentionally excluded; the other retrieval arms cover those).
* Only ``fact_type = 'observation'`` rows with a NULL ``search_vector`` are
rewritten. Raw facts already carry a populated tsvector, and the
``IS NULL`` predicate makes the migration idempotent and re-runnable.
The configured ``HINDSIGHT_API_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE`` is used
so backfilled rows are lexically identical to newly-created observations. The
value is validated as a PG identifier (mirroring
``HindsightConfig.validate``) before being embedded as a SQL literal.
This is a single UPDATE per schema: it locks the targeted observation rows for
its duration. It is one-time and only touches unpopulated rows, so subsequent
online writes (which now carry the tsvector via the writer fix) are unaffected.
Oracle slot is intentionally absent: the consolidator INSERT/UPDATE paths that
this repairs are PostgreSQL-specific (``ops_postgresql``), and the native
tsvector ``search_vector`` column only exists on PostgreSQL. There is no Oracle
residue to repair.
Revision ID: c3f7a1b9d2e4
Revises: f4d1c2b3a5e6
Create Date: 2026-06-29
"""
import os
import re
from collections.abc import Sequence
from alembic import context, op
from sqlalchemy import Connection, text
from hindsight_api.alembic._dialect import run_for_dialect
from hindsight_api.config import (
DEFAULT_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE,
ENV_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE,
)
revision: str = "c3f7a1b9d2e4"
down_revision: str | Sequence[str] | None = "f4d1c2b3a5e6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# Matches HindsightConfig.validate(): a tsvector regconfig name embedded as a
# SQL literal must be a bare PG identifier.
_PG_IDENTIFIER = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*")
def _schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _schema_name() -> str:
return (context.config.get_main_option("target_schema") or "public").strip('"')
def _native_language() -> str:
"""Configured native tsvector language, validated as a PG identifier."""
lang = os.getenv(
ENV_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE,
DEFAULT_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE,
)
if not _PG_IDENTIFIER.fullmatch(lang):
return DEFAULT_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE
return lang
def _is_regular_tsvector(conn: Connection, schema: str, table: str) -> bool:
"""True iff ``schema.table.search_vector`` is a non-generated tsvector column.
This is the ``native`` backend signature. ``vchord`` (bm25vector) and
``pg_textsearch`` / ``pgroonga`` / ``pg_search`` (dummy text column) all
fail this check, so the backfill is a no-op for them.
"""
row = conn.execute(
text(
"""
SELECT is_generated, udt_name
FROM information_schema.columns
WHERE table_schema = :schema
AND table_name = :table
AND column_name = 'search_vector'
"""
),
{"schema": schema, "table": table},
).fetchone()
if not row:
return False
is_generated, udt_name = row[0], row[1]
return udt_name == "tsvector" and is_generated != "ALWAYS"
def _pg_upgrade() -> None:
conn = op.get_bind()
schema_name = _schema_name()
if not _is_regular_tsvector(conn, schema_name, "memory_units"):
# Non-native backend (or column absent) — nothing to backfill.
return
schema_prefix = _schema_prefix()
lang = _native_language()
op.execute(
f"""
UPDATE {schema_prefix}memory_units
SET search_vector = to_tsvector('{lang}'::regconfig, COALESCE(text, ''))
WHERE fact_type = 'observation' AND search_vector IS NULL
"""
)
def _pg_downgrade() -> None:
# No-op: backfilled rows are indistinguishable from observations that were
# populated by the post-fix writer, and reverting either to NULL would
# re-break BM25 retrieval. The column simply stays populated.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,158 @@
"""Make maintenance routines resilient to schemas that vanish mid-scan.
``public.banks_needing_consolidation()`` and
``public.schemas_with_expired_rows(...)`` snapshot the set of schemas owning a
target table from ``pg_class`` and then run a dynamic query against each schema
in turn. That is a time-of-check/time-of-use race: a schema (or its tables) can
be dropped — a tenant being deleted, or a tenant migration that recreates
tables — between the snapshot and the per-schema query, which then aborts the
whole routine with::
relation "<schema>.memory_units" does not exist
relation "<schema>.audit_log" does not exist
In the test suite this surfaces as cross-worker contamination: the multi-tenant
maintenance test creates and drops ~100 ``mt<hash>_NNN`` schemas while
``test_maintenance_routines`` (on another xdist worker, same DB) calls the
routines. In production the background maintenance loop hits the same race when
a tenant is removed or mid-migration.
Wrap each per-schema query in its own ``BEGIN ... EXCEPTION`` block so a schema
that disappears (``undefined_table`` / ``invalid_schema_name`` /
``undefined_column``) is skipped instead of aborting the scan. The routines stay
``CREATE OR REPLACE`` and PostgreSQL-only, and are (re)installed only on the run
that targets the shared ``public`` schema — same gating as the original
install (``e5f6a7b8c9d0``) and its repair (``b2d4f6a8c1e3``).
Revision ID: c7e9f1a3b5d2
Revises: e1f2a3b4c5d6
Create Date: 2026-06-19
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c7e9f1a3b5d2"
down_revision: str | Sequence[str] | None = "e1f2a3b4c5d6"
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``, so they are installed exactly
once — on the base run (no ``target_schema``) or the run that explicitly
targets ``public``. Mirrors ``b2d4f6a8c1e3``.
"""
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
# Same body as b2d4f6a8c1e3, but each per-schema query runs in its own
# subtransaction so a schema dropped mid-scan is skipped, not fatal.
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
BEGIN
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);
EXCEPTION
-- Schema or its tables vanished between the pg_class
-- snapshot and this query (tenant dropped or migrating).
WHEN undefined_table OR invalid_schema_name OR undefined_column THEN
CONTINUE;
END;
END LOOP;
END;
$fn$;
"""
)
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
BEGIN
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;
EXCEPTION
-- Schema or its table vanished mid-scan; skip it.
WHEN undefined_table OR invalid_schema_name OR undefined_column THEN
CONTINUE;
END;
IF has_expired THEN
RETURN NEXT sch;
END IF;
END LOOP;
END;
$fn$;
"""
)
def _pg_downgrade() -> None:
# No-op: e5f6a7b8c9d0 owns these functions' lifecycle and drops them on its
# own downgrade. This migration only re-installs them (the resilient body is
# a strict superset of the previous behaviour), 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,108 @@
"""Add invalidated_memory_units table for curation (edit/invalidate).
Curation keeps the recall hot-path (``memory_units``) clean by *moving*
invalidated facts into a sibling archive table rather than flagging them in
place. If a row is in ``memory_units`` it is live; if it is in
``invalidated_memory_units`` it has been retired. Recall/consolidation/graph
queries never need a state predicate — the rows simply aren't there.
The archive mirrors ``memory_units`` column-for-column — except ``embedding``,
which it never keeps: the archive is cold storage, never a recall surface, and
revert recomputes the embedding from the unit's text/dates/entities. Keeping no
archive vector also means a later embedding-model switch (which re-dimensions
``memory_units``) can't trip a dimension mismatch on the move (#2209). Plus:
- ``invalidation_reason`` optional free text recorded on invalidate
- ``invalidated_at`` when it was retired
- ``entity_ids`` snapshot of the unit's entity associations, so revert
can restore them (``unit_entities`` is cascade-deleted
when the live row is removed)
This migration also adds ``edited_at`` to ``memory_units``: set whenever a user
edits a memory's fields (text, context, dates, fact_type, entities) via curation.
NULL means never manually modified; a non-NULL value answers "has the user ever
changed this?" with the time of the last edit (distinct from ``updated_at``,
which background operations also bump). It is added to ``memory_units`` *before*
the archive is cloned below, so the archive inherits the column and the marker
travels with a fact when it is invalidated.
Revision ID: c9a1b2d3e4f5
Revises: b2d4f6a8c1e3
Create Date: 2026-06-03
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c9a1b2d3e4f5"
down_revision: str | Sequence[str] | None = "b2d4f6a8c1e3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# Add edited_at to the live table FIRST so the archive's LIKE clone below
# inherits it (keeps the two tables column-for-column identical for round-trip).
op.execute(f"ALTER TABLE {schema}memory_units ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ")
# LIKE ... INCLUDING DEFAULTS clones every memory_units column (incl.
# edited_at) so an invalidated row can move back verbatim. We deliberately
# omit indexes/constraints — the archive is cold storage, not a recall
# surface; only the lookups below need indexing.
op.execute(
f"CREATE TABLE IF NOT EXISTS {schema}invalidated_memory_units (LIKE {schema}memory_units INCLUDING DEFAULTS)"
)
# ...then drop the inherited embedding: the archive never stores one (revert
# recomputes it), so it isn't created here only to be dropped again later by
# d4f6a8c2e1b3. That migration still runs as a no-op (DROP ... IF EXISTS) on
# fresh DBs and does the real drop on DBs created before this column was removed.
op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS embedding")
op.execute(
f"ALTER TABLE {schema}invalidated_memory_units "
f"ADD COLUMN IF NOT EXISTS invalidation_reason TEXT, "
f"ADD COLUMN IF NOT EXISTS invalidated_at TIMESTAMPTZ DEFAULT now(), "
f"ADD COLUMN IF NOT EXISTS entity_ids UUID[]"
)
op.execute(f"CREATE UNIQUE INDEX IF NOT EXISTS idx_invalidated_mu_id ON {schema}invalidated_memory_units (id)")
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_invalidated_mu_bank "
f"ON {schema}invalidated_memory_units (bank_id, invalidated_at)"
)
# Deleting a document (or bank) should clear its archived facts too, mirroring
# the memory_units → documents cascade.
op.execute(
f"""
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'invalidated_mu_document_fkey') THEN
ALTER TABLE {schema}invalidated_memory_units
ADD CONSTRAINT invalidated_mu_document_fkey
FOREIGN KEY (document_id, bank_id)
REFERENCES {schema}documents(id, bank_id) ON DELETE CASCADE;
END IF; END $$;
"""
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
# Drops the archive (and its inherited edited_at) wholesale, then removes
# edited_at from the live table.
op.execute(f"DROP TABLE IF EXISTS {schema}invalidated_memory_units")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS edited_at")
def upgrade() -> None:
# PG-only: Oracle gets the table from the baseline snapshot, matching the
# convention used by sibling column/index migrations in this tree.
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,93 @@
"""Drop the embedding column from the curation archive (invalidated_memory_units).
The archive is cold storage, never a recall surface, so it has no business
keeping an embedding. Earlier curation code copied the live row's embedding into
``invalidated_memory_units`` on invalidate; the engine now leaves it out on
invalidate and recomputes it on revert, so the column is dead weight.
Dropping it makes "the archive holds no embedding" a schema-enforced invariant
rather than a convention the move queries have to honour, and removes a latent
failure mode (#2209): after an embedding-model switch the live tables are
re-dimensioned but the archive was not, so a stale old-dimension embedding in
the archive tripped a vector-dimension mismatch on the INSERT … SELECT
round-trip. With no column at all, there is nothing to mismatch.
The creation sites no longer add the column (the PG ``LIKE`` clone in
c9a1b2d3e4f5 drops it; the Oracle baseline omits it), so on a fresh database
this migration is a no-op (DROP ... IF EXISTS / Oracle ORA-00904 swallow). It
does the real work on databases created before the column was removed there.
DROP COLUMN is a metadata-only operation on both PostgreSQL and Oracle 23ai (no
table rewrite), so it is cheap even across many tenant schemas. The downgrade
re-adds an unconstrained vector column (any dimension) — empty, since the
embeddings are intentionally discarded.
Revision ID: d4f6a8c2e1b3
Revises: a1d3f5b7c9e2
Create Date: 2026-06-15
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "d4f6a8c2e1b3"
down_revision: str | Sequence[str] | None = "a1d3f5b7c9e2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS embedding")
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
# Unconstrained `vector` (no dimension) so the re-added column accepts any
# model's embeddings; it comes back empty regardless.
op.execute(f"ALTER TABLE {schema}invalidated_memory_units ADD COLUMN IF NOT EXISTS embedding vector")
def _oracle_upgrade() -> None:
# Oracle has no `DROP COLUMN IF EXISTS`; swallow ORA-00904 (column does not
# exist) so the migration is idempotent and safe on a fresh schema whose
# baseline already omits the column.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units DROP COLUMN embedding';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -904 THEN RAISE; END IF;
END;
"""
)
def _oracle_downgrade() -> None:
# Swallow ORA-01430 (column already exists) for idempotency.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units ADD (embedding VECTOR)';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -1430 THEN RAISE; END IF;
END;
"""
)
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -0,0 +1,39 @@
"""Merge two divergent migration heads.
``d4f6a8c2e1b3`` (drop the curation-archive embedding column) and
``2071c7518f88`` (add the memory_links(bank_id, link_type) index) were authored
in parallel off the same parent (``a1d3f5b7c9e2``) and merged independently,
leaving the DAG with two heads. This is a no-op merge that re-unifies them so
``alembic upgrade head`` is unambiguous again (enforced by
``tests/test_alembic_dag.py::test_single_head``).
Revision ID: e1f2a3b4c5d6
Revises: d4f6a8c2e1b3, 2071c7518f88
Create Date: 2026-06-16
"""
from collections.abc import Sequence
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "e1f2a3b4c5d6"
down_revision: str | Sequence[str] | None = ("d4f6a8c2e1b3", "2071c7518f88")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_upgrade() -> None:
# Pure DAG merge — both parents already applied their schema changes.
pass
def _pg_downgrade() -> None:
pass
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)
@@ -0,0 +1,110 @@
"""Add server-side routine for cron-scheduled mental model refresh.
Installs ``public.mental_models_with_cron()`` — a discovery routine that returns
every mental model carrying a non-empty ``trigger->>'refresh_cron'`` across all
tenant schemas in one round-trip (the same per-schema scan as the other
maintenance routines from ``e5f6a7b8c9d0``). The maintenance loop evaluates each
candidate's cron expression in Python (``croniter``) against ``last_refreshed_at``
to decide whether a scheduled refresh is due — cron arithmetic isn't expressible
in plain SQL — and only the cron *candidate set* is discovered here.
Models that already have a ``refresh_mental_model`` operation pending/processing
are excluded so a slow refresh isn't double-queued (mirrors the in-flight guard
in ``banks_needing_consolidation``). Each per-schema query runs in its own
``BEGIN ... EXCEPTION`` subtransaction so a schema dropped mid-scan (tenant
deletion / migration) is skipped, not fatal — same resilience as
``c7e9f1a3b5d2``.
Read-only (STABLE) discovery routine — the caller performs the refresh enqueue —
so installing it never mutates data. PostgreSQL only: the worker poller and the
maintenance loop are PG-only (Oracle slot intentionally absent, mirroring
``e5f6a7b8c9d0``). The routine lives in ``public`` and is CREATE OR REPLACE, so
it is installed exactly once (base / ``public`` run) to avoid the
``tuple concurrently updated`` race on concurrent per-tenant runs.
Revision ID: f4d1c2b3a5e6
Revises: c7e9f1a3b5d2
Create Date: 2026-06-23
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "f4d1c2b3a5e6"
down_revision: str | Sequence[str] | None = "c7e9f1a3b5d2"
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.*`` routine.
The routine physically lives in ``public``, so it is installed exactly once —
on the base run (no ``target_schema``) or the run that explicitly targets
``public``. Mirrors ``c7e9f1a3b5d2``.
"""
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
op.execute(
"""
CREATE OR REPLACE FUNCTION public.mental_models_with_cron()
RETURNS TABLE(schema_name text, bank_id text, mental_model_id text,
refresh_cron text, last_refreshed_at timestamptz)
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 = 'mental_models' AND c.relkind = 'r'
LOOP
BEGIN
RETURN QUERY EXECUTE format($q$
SELECT %1$L::text, mm.bank_id::text, mm.id::text,
mm.trigger->>'refresh_cron', mm.last_refreshed_at
FROM %1$I.mental_models mm
WHERE COALESCE(mm.trigger->>'refresh_cron', '') <> ''
AND NOT EXISTS (
SELECT 1 FROM %1$I.async_operations o
WHERE o.bank_id = mm.bank_id
AND o.operation_type = 'refresh_mental_model'
AND o.status IN ('pending', 'processing')
AND o.task_payload->>'mental_model_id' = mm.id::text
)
$q$, sch);
EXCEPTION
-- Schema or its tables vanished between the pg_class
-- snapshot and this query (tenant dropped or migrating).
WHEN undefined_table OR invalid_schema_name OR undefined_column THEN
CONTINUE;
END;
END LOOP;
END;
$fn$;
"""
)
def _pg_downgrade() -> None:
if not _should_install_public_routines(context.config.get_main_option("target_schema")):
return
op.execute("DROP FUNCTION IF EXISTS public.mental_models_with_cron()")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -122,6 +122,7 @@ _TABLES: tuple[str, ...] = (
text_signals CLOB,
consolidation_failed_at TIMESTAMP WITH TIME ZONE,
search_vector CLOB,
edited_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_memory_units PRIMARY KEY (id),
@@ -138,6 +139,50 @@ _TABLES: tuple[str, ...] = (
PARTITION BY LIST (bank_id) AUTOMATIC
(PARTITION p_default VALUES ('__default__'))
""",
# Cold archive for curation: invalidated facts are MOVED here out of
# memory_units so the recall hot-path never sees them. Mirrors memory_units
# plus invalidation bookkeeping and an entity-id snapshot for lossless revert.
# No `embedding` column: the archive is cold storage and revert recomputes the
# embedding, so there is no archive vector to fall out of sync with the live
# model's dimension on a model switch (#2209).
"""
CREATE TABLE IF NOT EXISTS invalidated_memory_units (
id RAW(16) NOT NULL,
bank_id VARCHAR2(256) NOT NULL,
document_id VARCHAR2(512),
chunk_id VARCHAR2(512),
text CLOB NOT NULL,
context CLOB,
event_date TIMESTAMP WITH TIME ZONE NOT NULL,
occurred_start TIMESTAMP WITH TIME ZONE,
occurred_end TIMESTAMP WITH TIME ZONE,
mentioned_at TIMESTAMP WITH TIME ZONE,
fact_type VARCHAR2(64) DEFAULT 'world' NOT NULL,
confidence_score BINARY_DOUBLE,
access_count NUMBER(10) DEFAULT 0 NOT NULL,
consolidated_at TIMESTAMP WITH TIME ZONE,
observation_scopes CLOB CONSTRAINT imu_obs_scopes_json CHECK (observation_scopes IS JSON OR observation_scopes IS NULL),
tags CLOB DEFAULT '[]' NOT NULL,
metadata CLOB DEFAULT '{}' NOT NULL
CONSTRAINT imu_metadata_json CHECK (metadata IS JSON),
proof_count NUMBER(10) DEFAULT 1,
source_memory_ids CLOB,
history CLOB DEFAULT '[]'
CONSTRAINT imu_history_json CHECK (history IS JSON OR history IS NULL),
text_signals CLOB,
consolidation_failed_at TIMESTAMP WITH TIME ZONE,
search_vector CLOB,
edited_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
invalidation_reason CLOB,
invalidated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP,
entity_ids CLOB CONSTRAINT imu_entity_ids_json CHECK (entity_ids IS JSON OR entity_ids IS NULL),
CONSTRAINT pk_invalidated_memory_units PRIMARY KEY (id),
CONSTRAINT fk_imu_document FOREIGN KEY (document_id, bank_id)
REFERENCES documents(id, bank_id) ON DELETE CASCADE
)
""",
"""
CREATE TABLE IF NOT EXISTS entities (
id RAW(16) DEFAULT SYS_GUID() NOT NULL,
@@ -16,9 +16,7 @@ retention parameters, retrieval settings, etc.) in Python field name format.
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
from sqlalchemy.dialects.postgresql import JSONB
from hindsight_api.alembic._dialect import run_for_dialect
@@ -0,0 +1,97 @@
"""Client-disconnect detection that works behind ``BaseHTTPMiddleware``.
``Request.is_disconnected()`` is the obvious way to notice an abandoned HTTP
request, but it is silently broken once any ``@app.middleware("http")``
(Starlette ``BaseHTTPMiddleware``) is installed: that middleware runs the route
in a child task behind anyio memory streams, so the ``http.disconnect`` ASGI
event never reaches the route's ``Request``. This app has such middlewares, so
the recall/reflect cancellation in #2122/#2127 never actually fired in
production — the disconnect was never observed.
This pure-ASGI middleware sits *outside* the ``BaseHTTPMiddleware`` layer, where
it still owns the real ``receive`` channel. For the recall and reflect routes it
drains ``receive`` in a background task and trips a :class:`CancellationToken`
the moment ``http.disconnect`` arrives, stashing the token on the ASGI ``scope``.
The route copies that token onto its ``RequestContext`` and the engine checks it
at stage boundaries — so abandoned work stops instead of running to completion.
It only wraps recall/reflect (small JSON bodies); every other request — uploads,
MCP streams, etc. — passes straight through untouched, so there is no buffering
or latency cost elsewhere.
"""
from __future__ import annotations
import asyncio
import contextlib
from collections.abc import Awaitable, Callable, MutableMapping
from typing import Any
from ..cancellation import CancellationToken
# Key under which the per-request CancellationToken is stored on the ASGI scope.
# A dedicated top-level scope key (not scope["state"]) avoids any interaction
# with Starlette's per-request state copying.
SCOPE_CANCELLATION_TOKEN = "hindsight.cancellation_token"
_CLIENT_DISCONNECTED_REASON = "client disconnected"
Scope = MutableMapping[str, Any]
Receive = Callable[[], Awaitable[MutableMapping[str, Any]]]
Send = Callable[[MutableMapping[str, Any]], Awaitable[None]]
def _should_monitor(path: str) -> bool:
"""Only the two long-running, abandon-prone read endpoints need monitoring."""
return path.endswith("/memories/recall") or path.endswith("/reflect")
class ClientDisconnectCancellationMiddleware:
"""Trip a scope-level CancellationToken when the client disconnects.
Must be installed *outside* any ``BaseHTTPMiddleware`` so it owns the real
ASGI ``receive`` channel.
"""
def __init__(self, app: Callable) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http" or not _should_monitor(scope.get("path", "")):
await self.app(scope, receive, send)
return
token = CancellationToken()
scope[SCOPE_CANCELLATION_TOKEN] = token
# The downstream app still needs to read the request body, so we cannot
# simply consume `receive` ourselves. Instead a single pump task drains
# the real channel, forwards every message to a queue the app reads from,
# and trips the token the instant `http.disconnect` shows up — which the
# app would otherwise never pull once it has finished reading the body.
queue: asyncio.Queue = asyncio.Queue()
async def pump() -> None:
while True:
message = await receive()
if message["type"] == "http.disconnect":
token.cancel(_CLIENT_DISCONNECTED_REASON)
await queue.put(message)
return
await queue.put(message)
async def proxied_receive() -> MutableMapping[str, Any]:
return await queue.get()
pump_task = asyncio.create_task(pump())
try:
await self.app(scope, proxied_receive, send)
finally:
pump_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await pump_task
def get_scope_cancellation_token(scope: Scope) -> CancellationToken | None:
"""Return the CancellationToken the middleware attached, if any."""
return scope.get(SCOPE_CANCELLATION_TOKEN)
File diff suppressed because it is too large Load Diff
+22 -1
View File
@@ -9,7 +9,7 @@ from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api import __version__ as HINDSIGHT_VERSION
from hindsight_api.config import _get_raw_config
from hindsight_api.config import DEFAULT_MCP_RECALL_DESCRIPTION, DEFAULT_MCP_RETAIN_DESCRIPTION, _get_raw_config
from hindsight_api.engine.memory_engine import _current_schema
from hindsight_api.extensions import MCPExtension, load_extension
from hindsight_api.extensions.tenant import AuthenticationError
@@ -78,6 +78,19 @@ def get_current_mcp_authenticated() -> bool:
return _current_mcp_authenticated.get()
def _build_mcp_tool_descriptions(extra_instructions: str | None) -> tuple[str | None, str | None]:
"""Return custom retain/recall descriptions when server-level MCP instructions are set."""
if not isinstance(extra_instructions, str):
return None, None
extra_instructions = extra_instructions.strip()
if not extra_instructions:
return None, None
suffix = f"\n\nAdditional instructions: {extra_instructions}"
return DEFAULT_MCP_RETAIN_DESCRIPTION + suffix, DEFAULT_MCP_RECALL_DESCRIPTION + suffix
def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
"""
Create and configure the Hindsight MCP server.
@@ -113,6 +126,8 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
"delete_directive",
"list_memories",
"get_memory",
"update_memory",
"invalidate_memory",
"list_documents",
"get_document",
"delete_document",
@@ -133,6 +148,10 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
allowed = frozenset(global_config.mcp_enabled_tools)
base_tools = (base_tools if base_tools is not None else _ALL_TOOLS) & allowed
retain_description, recall_description = _build_mcp_tool_descriptions(
getattr(global_config, "mcp_instructions", None)
)
# Configure and register tools using shared module
config = MCPToolsConfig(
bank_id_resolver=get_current_bank_id,
@@ -142,6 +161,8 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
mcp_authenticated_resolver=get_current_mcp_authenticated, # Propagate MCP pre-auth flag
include_bank_id_param=multi_bank,
tools=base_tools,
retain_description=retain_description,
recall_description=recall_description,
)
register_mcp_tools(mcp, memory, config)
+263
View File
@@ -0,0 +1,263 @@
"""Open Knowledge Format (OKF) projection for knowledge pages.
Knowledge pages are a *read-only* OKF view over the existing mental models: each
mental model is projected into an OKF document — a markdown body with YAML
frontmatter (``type`` required; ``title``/``description``/``tags``/``timestamp``
optional) — and pages are linked into a constellation graph via shared tags.
See the Open Knowledge Format spec:
https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf
This module is intentionally pure: every function transforms the mental-model
dicts returned by ``MemoryEngine.list_mental_models`` / ``get_mental_model`` and
never touches the database. That keeps the OKF contract unit-testable without a
DB or LLM and lets the HTTP layer stay a thin wrapper.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
# OKF requires exactly one frontmatter field — ``type``. We default to this when
# a page does not declare one via a ``type:<x>`` tag.
DEFAULT_PAGE_TYPE = "knowledge-page"
# A page declares its OKF ``type`` through a tag of the form ``type:runbook``.
# This keeps the projection schema-free (no new mental_models column): the type
# is lifted from the existing tags array.
TYPE_TAG_PREFIX = "type:"
INDEX_FILENAME = "index.md"
# Deterministic, colour-blind-friendly palette. Type → colour is stable across
# requests so the constellation keeps the same colours between reloads.
_PALETTE = (
"#0074d9", # blue
"#2ecc40", # green
"#b10dc9", # purple
"#ff851b", # orange
"#39cccc", # teal
"#f012be", # magenta
"#3d9970", # olive
"#ff4136", # red
)
_EDGE_COLOR = "#9aa5b1"
@dataclass(frozen=True)
class PageType:
"""A page's OKF ``type`` and the tags that remain after the type tag is split off."""
type: str
display_tags: list[str]
@dataclass(frozen=True)
class KnowledgeGraph:
"""Cytoscape-style node/edge graph of knowledge pages linked by shared tags."""
nodes: list[dict[str, Any]] = field(default_factory=list)
edges: list[dict[str, Any]] = field(default_factory=list)
def _color_for(key: str) -> str:
"""Stable colour for a string key (FNV-ish hash into the fixed palette)."""
h = 0
for ch in key:
h = (h * 31 + ord(ch)) & 0xFFFFFFFF
return _PALETTE[h % len(_PALETTE)]
def _scalar(value: Any) -> str:
"""Emit a YAML-safe double-quoted scalar.
We always double-quote so arbitrary page names / source queries can't be
misread as YAML special forms (``true``, ``2026-01-01``, ``- x``, etc.).
"""
text = str(value)
escaped = text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "")
return f'"{escaped}"'
def page_type(tags: list[str] | None) -> PageType:
"""Split an OKF ``type`` out of the tag list.
The first ``type:<x>`` tag wins; all ``type:`` tags are removed from the
returned ``display_tags`` so they don't pollute the constellation's
shared-tag edges. Falls back to :data:`DEFAULT_PAGE_TYPE`.
"""
resolved = DEFAULT_PAGE_TYPE
display: list[str] = []
for tag in tags or []:
if tag.startswith(TYPE_TAG_PREFIX):
suffix = tag[len(TYPE_TAG_PREFIX) :].strip()
if suffix and resolved == DEFAULT_PAGE_TYPE:
resolved = suffix
continue
display.append(tag)
return PageType(type=resolved, display_tags=display)
def _timestamp(mm: dict[str, Any]) -> str | None:
return mm.get("last_refreshed_at") or mm.get("created_at")
def frontmatter(mm: dict[str, Any]) -> dict[str, Any]:
"""Build the ordered OKF frontmatter mapping for a mental model.
``None``/empty values are dropped by :func:`render_frontmatter`.
"""
pt = page_type(mm.get("tags"))
return {
"id": mm.get("id"),
"type": pt.type,
"title": mm.get("name"),
"description": mm.get("source_query"),
"tags": pt.display_tags,
"timestamp": _timestamp(mm),
}
def render_frontmatter(fm: dict[str, Any]) -> str:
"""Render a frontmatter mapping into a ``---`` fenced YAML block."""
lines = ["---"]
for key, value in fm.items():
if value is None:
continue
if isinstance(value, list):
if not value:
continue
lines.append(f"{key}:")
lines.extend(f" - {_scalar(item)}" for item in value)
else:
lines.append(f"{key}: {_scalar(value)}")
lines.append("---")
return "\n".join(lines)
def render_document(mm: dict[str, Any]) -> str:
"""Render a full OKF document: frontmatter block + markdown body."""
body = (mm.get("content") or "").strip()
return f"{render_frontmatter(frontmatter(mm))}\n\n{body}\n" if body else f"{render_frontmatter(frontmatter(mm))}\n"
def page_filename(page_id: str) -> str:
"""OKF bundle filename for a page id."""
return f"{page_id}.md"
def log_filename(page_id: str) -> str:
"""OKF reserved per-page history filename."""
return f"{page_id}.log.md"
def render_index(nodes: list[dict[str, Any]]) -> str:
"""Render the reserved ``index.md`` — nested OKF navigation over the tree.
``nodes`` is the flat folder/page list (each with ``id``, ``kind``, ``name``,
``parent_id``); folders nest their children, pages link to their ``.md``.
"""
fm = render_frontmatter({"type": "index", "title": "Knowledge base"})
lines = [fm, "", "# Knowledge base", ""]
children: dict[Any, list[dict[str, Any]]] = {}
for node in nodes:
children.setdefault(node.get("parent_id"), []).append(node)
def walk(parent: Any, depth: int) -> None:
ordered = sorted(children.get(parent, []), key=lambda n: (n.get("sort_order", 0), n.get("name") or ""))
for node in ordered:
indent = " " * depth
if node.get("kind") == "folder":
lines.append(f"{indent}- **{node['name']}/**")
walk(node["id"], depth + 1)
else:
description = node.get("source_query") or node.get("description")
link = f"{indent}- [{node['name']}](./{page_filename(node['id'])})"
lines.append(f"{link}{description}" if description else link)
walk(None, 0)
if len(lines) == 4:
lines.append("_No knowledge pages yet._")
return "\n".join(lines) + "\n"
def render_log(mm: dict[str, Any], history: list[dict[str, Any]]) -> str:
"""Render the reserved per-page ``log.md`` from refresh history.
Each history entry is ``{previous_content, previous_reflect_response,
changed_at}`` (newest first), capturing the content *before* a refresh.
"""
name = mm.get("name") or mm.get("id")
fm = render_frontmatter({"type": "log", "title": f"{name} — history"})
lines = [fm, "", f"# {name} — history", ""]
if not history:
lines.append("_No refresh history._")
return "\n".join(lines) + "\n"
for entry in history:
changed_at = entry.get("changed_at") or "unknown"
previous = (entry.get("previous_content") or "").strip()
lines.append(f"## {changed_at}")
lines.append("")
lines.append(previous if previous else "_(empty)_")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def knowledge_graph(
pages: list[dict[str, Any]],
cluster_for: "Callable[[dict[str, Any]], str] | None" = None,
) -> KnowledgeGraph:
"""Derive the constellation graph: pages as nodes, shared tags as edges.
Two pages are linked when they share at least one (non-``type:``) tag; the
edge weight is the number of shared tags. Each node's cluster (``type`` field
+ colour) comes from ``cluster_for(page)`` — the knowledge base groups by
parent folder; the default groups by OKF ``type``.
"""
nodes: list[dict[str, Any]] = []
tag_sets: list[tuple[str, frozenset[str]]] = []
for mm in pages:
page_id = mm["id"]
pt = page_type(mm.get("tags"))
cluster = cluster_for(mm) if cluster_for else pt.type
tag_sets.append((page_id, frozenset(pt.display_tags)))
nodes.append(
{
"data": {
"id": page_id,
"label": mm.get("name") or page_id,
"type": cluster,
"tagCount": len(pt.display_tags),
"color": _color_for(cluster),
}
}
)
edges: list[dict[str, Any]] = []
for i in range(len(tag_sets)):
source_id, source_tags = tag_sets[i]
if not source_tags:
continue
for j in range(i + 1, len(tag_sets)):
target_id, target_tags = tag_sets[j]
shared = source_tags & target_tags
if not shared:
continue
edges.append(
{
"data": {
"id": f"{source_id}--{target_id}",
"source": source_id,
"target": target_id,
"sharedTags": sorted(shared),
"weight": len(shared),
"color": _EDGE_COLOR,
}
}
)
return KnowledgeGraph(nodes=nodes, edges=edges)
@@ -0,0 +1,85 @@
"""Cooperative cancellation for long-running engine operations.
Recall runs as a staged pipeline whose heavy stages — graph expansion and
cross-encoder reranking — execute in worker threads (``run_in_executor``) that
asyncio task cancellation cannot interrupt once they have started. Cancelling
the awaiting task only unblocks the ``await``; the thread keeps burning CPU to
completion. So rather than rely on task cancellation, callers thread a
``CancellationToken`` through ``RequestContext`` and the engine checks it at
stage boundaries (``raise_if_cancelled``), bailing out *before* dispatching the
next expensive stage.
This is cooperative by design: it cannot stop a computation already inside a
worker thread, but it does stop an abandoned recall from progressing into — or
past — that work, which is what starves the instance in issue #2122. The token
lives on ``RequestContext``, so any operation that receives one (recall today;
reflect/consolidation/MCP later) can adopt the same checkpoints, and any driver
(client disconnect today; a deadline tomorrow) can fire it.
"""
from __future__ import annotations
import asyncio
class OperationCancelledError(Exception):
"""Raised at a checkpoint when the operation has been cancelled.
Carries the ``reason`` set by whoever cancelled (e.g. "client disconnected")
so the HTTP layer can translate it into the appropriate status code instead
of a generic 500.
NOTE: this is a plain ``Exception`` on purpose, NOT ``BaseException``. The
recall/reflect pipelines have broad ``except Exception`` handlers that would
otherwise swallow it — those handlers re-raise ``OperationCancelledError``
explicitly (see ``_search_with_retries``) so cancellation propagates to the
HTTP layer. A ``BaseException`` would dodge those handlers but also slip past
legitimate ``isinstance(result, Exception)`` checks (e.g. the reflect agent's
``asyncio.gather(..., return_exceptions=True)`` tool-result handling), which
expect every non-tuple result to be an ``Exception``.
"""
def __init__(self, reason: str = "operation cancelled") -> None:
super().__init__(reason)
self.reason = reason
class CancellationToken:
"""A one-shot, cooperative cancellation signal.
Cheap to poll (``raise_if_cancelled``) at stage boundaries and awaitable
(``wait``) so a driver task can block until cancellation. Safe to share
across an engine call tree; polling is a no-op until something cancels, and
cancellation is idempotent (the first reason wins).
"""
__slots__ = ("_event", "_reason")
def __init__(self) -> None:
self._event = asyncio.Event()
self._reason = "operation cancelled"
def cancel(self, reason: str = "operation cancelled") -> None:
"""Signal cancellation. Idempotent; the first reason recorded wins."""
if not self._event.is_set():
self._reason = reason
self._event.set()
@property
def cancelled(self) -> bool:
"""Whether cancellation has been signalled."""
return self._event.is_set()
@property
def reason(self) -> str:
"""The reason recorded by the first ``cancel`` call."""
return self._reason
def raise_if_cancelled(self) -> None:
"""Raise ``OperationCancelledError`` if cancellation has been signalled."""
if self._event.is_set():
raise OperationCancelledError(self._reason)
async def wait(self) -> None:
"""Block until cancellation is signalled."""
await self._event.wait()
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,7 @@ Config values are resolved on every request to ensure consistency across
multiple API servers.
"""
import asyncio
import json
import logging
from dataclasses import asdict, replace
@@ -18,6 +19,8 @@ from hindsight_api.config import (
HindsightConfig,
_get_raw_config,
normalize_config_dict,
validate_retain_chunking_config,
validate_retain_completion_token_budget,
)
from hindsight_api.engine.memory_engine import fq_table
from hindsight_api.extensions.tenant import TenantExtension
@@ -29,6 +32,35 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _validate_retain_strategy_chunking(base_config: HindsightConfig, strategies: Any) -> None:
"""Validate retain strategy chunking with the same semantics as apply_strategy()."""
if not isinstance(strategies, dict):
return
configurable = HindsightConfig.get_configurable_fields()
for strategy_name, overrides in strategies.items():
if not isinstance(overrides, dict):
raise ValueError(f"Invalid retain strategy {strategy_name!r}: must be an object")
filtered = {k: v for k, v in overrides.items() if k in configurable}
if not filtered:
continue
try:
resolved = replace(base_config, **filtered)
validate_retain_chunking_config(
resolved.retain_chunk_size,
resolved.retain_structured_chunk_size,
)
validate_retain_completion_token_budget(
llm_provider=resolved.llm_provider,
retain_max_completion_tokens=resolved.retain_max_completion_tokens,
retain_chunk_size=resolved.retain_chunk_size,
retain_llm_model=resolved.retain_llm_model,
llm_model=resolved.llm_model,
retain_llm_provider=resolved.retain_llm_provider,
)
except ValueError as e:
raise ValueError(f"Invalid retain strategy {strategy_name!r}: {e}") from e
class ConfigResolver:
"""Resolves hierarchical configuration with tenant/bank overrides."""
@@ -46,6 +78,26 @@ class ConfigResolver:
self._configurable_fields = HindsightConfig.get_configurable_fields()
self._credential_fields = HindsightConfig.get_credential_fields()
async def _resolve_parent_config_dict(self, bank_id: str, context: RequestContext | None = None) -> dict[str, Any]:
"""Resolve global + tenant config before bank-level overrides."""
config_dict = asdict(self._global_config)
if self.tenant_extension and context:
try:
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
if tenant_overrides:
# Normalize keys and filter to configurable fields only
normalized_tenant = normalize_config_dict(tenant_overrides)
configurable_tenant = {k: v for k, v in normalized_tenant.items() if k in self._configurable_fields}
config_dict.update(configurable_tenant)
logger.debug(
f"Applied tenant config overrides for bank {bank_id}: {list(configurable_tenant.keys())}"
)
except Exception as e:
logger.warning(f"Failed to load tenant config for bank {bank_id}: {e}")
return config_dict
async def resolve_full_config(self, bank_id: str, context: RequestContext | None = None) -> HindsightConfig:
"""
Resolve full HindsightConfig for a bank with hierarchical overrides applied.
@@ -65,23 +117,7 @@ class ConfigResolver:
Returns:
Complete HindsightConfig with hierarchical overrides applied
"""
# Start with global config (all fields)
config_dict = asdict(self._global_config)
# Load tenant config overrides (if tenant extension available)
if self.tenant_extension and context:
try:
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
if tenant_overrides:
# Normalize keys and filter to configurable fields only
normalized_tenant = normalize_config_dict(tenant_overrides)
configurable_tenant = {k: v for k, v in normalized_tenant.items() if k in self._configurable_fields}
config_dict.update(configurable_tenant)
logger.debug(
f"Applied tenant config overrides for bank {bank_id}: {list(configurable_tenant.keys())}"
)
except Exception as e:
logger.warning(f"Failed to load tenant config for bank {bank_id}: {e}")
config_dict = await self._resolve_parent_config_dict(bank_id, context)
# Load bank config overrides
bank_overrides = await self._load_bank_config(bank_id)
@@ -92,6 +128,25 @@ class ConfigResolver:
# Return full config object (dataclass doesn't have __init__ that accepts kwargs, so we update the object)
# Create a new config instance by copying the global config and updating fields
resolved_config = HindsightConfig(**config_dict)
# Multi-LLM chains are static credential fields (never tenant/bank-overridable),
# but asdict() above flattened their member dataclasses into plain dicts. Restore
# the original typed objects from the global config so the resolved object stays
# well-typed for any consumer that reads them.
resolved_config = replace(
resolved_config,
llm_members=self._global_config.llm_members,
llm_strategy=self._global_config.llm_strategy,
retain_llm_members=self._global_config.retain_llm_members,
retain_llm_strategy=self._global_config.retain_llm_strategy,
reflect_llm_members=self._global_config.reflect_llm_members,
reflect_llm_strategy=self._global_config.reflect_llm_strategy,
consolidation_llm_members=self._global_config.consolidation_llm_members,
consolidation_llm_strategy=self._global_config.consolidation_llm_strategy,
)
validate_retain_chunking_config(
resolved_config.retain_chunk_size,
resolved_config.retain_structured_chunk_size,
)
return resolved_config
async def get_bank_config(self, bank_id: str, context: RequestContext | None = None) -> dict[str, Any]:
@@ -122,26 +177,83 @@ class ConfigResolver:
resolved_config = await self.resolve_full_config(bank_id, context)
config_dict = asdict(resolved_config)
# SECURITY: Filter to only configurable fields (exclude static/infrastructure)
filtered = {k: v for k, v in config_dict.items() if k in self._configurable_fields}
# SECURITY: drop static/infrastructure + credential fields, then permission-filter.
filtered = self._strip_static_and_credential_fields(config_dict)
return await self._apply_permission_filter(filtered, bank_id, context)
# SECURITY: Remove ALL credential fields (API keys, base URLs, etc.)
filtered = {k: v for k, v in filtered.items() if k not in self._credential_fields}
def _strip_static_and_credential_fields(self, config_dict: dict[str, Any]) -> dict[str, Any]:
"""Keep only configurable, non-credential fields.
# PERMISSIONS: Further filter based on tenant/bank permissions
SECURITY: excludes static/infrastructure fields and ALL credential fields
(API keys, base URLs, etc.) so a resolved config is safe to return over the API.
"""
return {
k: v for k, v in config_dict.items() if k in self._configurable_fields and k not in self._credential_fields
}
async def _apply_permission_filter(
self, filtered: dict[str, Any], bank_id: str, context: RequestContext | None
) -> dict[str, Any]:
"""Further restrict already-stripped config to the tenant/bank permission allow-list.
On extension error, leaves ``filtered`` unchanged (parity with the historical
single-bank path: a permissions lookup failure must not leak or drop fields).
"""
if not (self.tenant_extension and context):
return filtered
try:
allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id)
if allowed_fields is not None: # None means "allow all"
filtered = {k: v for k, v in filtered.items() if k in allowed_fields}
logger.debug(
f"Applied permission filter for bank {bank_id}: allowed={len(allowed_fields)} fields, "
f"returned={len(filtered)} fields"
)
except Exception as e:
logger.warning(f"Failed to load permissions for bank {bank_id}: {e}")
return filtered
async def get_bank_configs(
self, bank_ids: list[str], context: RequestContext | None = None
) -> dict[str, dict[str, Any]]:
"""Batch variant of :meth:`get_bank_config` for many banks.
Equivalent to calling ``get_bank_config`` per bank, but resolves the
global + tenant base once and loads every bank's ``banks.config`` JSONB
in a single query, instead of one config round-trip per bank. Used by
``list_banks`` to overlay disposition + mission without an N+1.
Returns a mapping of bank_id -> filtered configurable-field dict. A bank
with no config row still appears, mapped to the global+tenant base.
"""
if not bank_ids:
return {}
# Global + tenant base, resolved once (tenant override is per-request, not per-bank).
base_dict = asdict(self._global_config)
if self.tenant_extension and context:
try:
allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id)
if allowed_fields is not None: # None means "allow all"
filtered = {k: v for k, v in filtered.items() if k in allowed_fields}
logger.debug(
f"Applied permission filter for bank {bank_id}: allowed={len(allowed_fields)} fields, "
f"returned={len(filtered)} fields"
)
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
if tenant_overrides:
normalized_tenant = normalize_config_dict(tenant_overrides)
base_dict.update({k: v for k, v in normalized_tenant.items() if k in self._configurable_fields})
except Exception as e:
logger.warning(f"Failed to load permissions for bank {bank_id}: {e}")
logger.warning(f"Failed to load tenant config for bulk resolve: {e}")
return filtered
# All bank overrides in one query, then merge + strip per bank.
bank_overrides = await self._load_bank_configs(bank_ids)
stripped = {
bank_id: self._strip_static_and_credential_fields({**base_dict, **bank_overrides.get(bank_id, {})})
for bank_id in bank_ids
}
# Permission filter is per-bank; resolve concurrently when an extension is present.
if not (self.tenant_extension and context):
return stripped
permission_filtered = await asyncio.gather(
*(self._apply_permission_filter(stripped[bank_id], bank_id, context) for bank_id in bank_ids)
)
return dict(zip(bank_ids, permission_filtered, strict=True))
async def _load_bank_config(self, bank_id: str) -> dict[str, Any]:
"""
@@ -180,6 +292,45 @@ class ConfigResolver:
return {}
async def _load_bank_configs(self, bank_ids: list[str]) -> dict[str, dict[str, Any]]:
"""Bulk variant of :meth:`_load_bank_config`: load many banks' overrides in one query.
Returns a mapping of bank_id -> normalized active overrides. Banks with no row
(or an empty/all-tombstone config) are simply absent from the mapping.
"""
result: dict[str, dict[str, Any]] = {}
if not bank_ids:
return result
try:
async with self._backend.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT bank_id, config FROM {fq_table("banks")} WHERE bank_id = ANY($1)
""",
bank_ids,
)
for row in rows:
config_data = row["config"]
if not config_data:
continue
# Handle case where JSONB is returned as JSON string
if isinstance(config_data, str):
config_data = json.loads(config_data)
# Normalize keys (handle both env var format and Python field format)
normalized = normalize_config_dict(config_data)
# Only active overrides for configurable fields. JSON null is a tombstone
# for "Server Default" in the bank-config UI and must not override defaults.
overrides = {
k: v for k, v in normalized.items() if k in self._configurable_fields and v is not None
}
if overrides:
result[row["bank_id"]] = overrides
except Exception as e:
logger.error(f"Failed to bulk-load bank configs: {e}")
return result
async def update_bank_config(
self, bank_id: str, updates: dict[str, Any], context: RequestContext | None = None
) -> None:
@@ -266,6 +417,32 @@ class ConfigResolver:
# Validate recall budget fields
_validate_recall_budget_updates(normalized_updates)
# Validate disposition trait fields (1-5 integer scale)
_validate_disposition_updates(normalized_updates)
chunking_fields_updated = (
"retain_chunk_size" in normalized_updates
or "retain_structured_chunk_size" in normalized_updates
or "retain_strategies" in normalized_updates
)
if chunking_fields_updated:
config_dict = await self._resolve_parent_config_dict(bank_id, context)
active_bank_overrides = await self._load_bank_config(bank_id)
for key, value in normalized_updates.items():
if key not in self._configurable_fields:
continue
if value is None:
active_bank_overrides.pop(key, None)
else:
active_bank_overrides[key] = value
config_dict.update(active_bank_overrides)
base_config = HindsightConfig(**config_dict)
validate_retain_chunking_config(
base_config.retain_chunk_size,
base_config.retain_structured_chunk_size,
)
_validate_retain_strategy_chunking(base_config, base_config.retain_strategies)
# 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
@@ -357,6 +534,31 @@ def _validate_recall_budget_updates(updates: dict[str, Any]) -> None:
)
_DISPOSITION_KEYS = (
"disposition_skepticism",
"disposition_literalism",
"disposition_empathy",
)
def _validate_disposition_updates(updates: dict[str, Any]) -> None:
"""Validate disposition trait config updates. Raises ValueError on invalid input.
Each trait is an integer on a 1-5 scale (or None to clear the per-bank
override). The read overlay injects the stored value verbatim into a strict
``DispositionTraits(int, ge=1, le=5)``; an out-of-contract value (a float, a
0-1 scale, or an int outside 1-5) accepted here would later 500 the whole
bank list when any bank profile is serialized (issue #2348).
"""
for key in _DISPOSITION_KEYS:
if key in updates:
value = updates[key]
if value is None:
continue
if not isinstance(value, int) or isinstance(value, bool) or not (1 <= value <= 5):
raise ValueError(f"{key} must be an integer between 1 and 5, got {value!r}")
def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConfig:
"""
Apply a named retain strategy's overrides on top of a resolved config.
@@ -364,7 +566,8 @@ def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConf
A strategy is a named set of hierarchical field overrides stored in
config.retain_strategies. Any field in _HIERARCHICAL_FIELDS can be
overridden, including retain_extraction_mode, retain_chunk_size,
entity_labels, entities_allow_free_form, etc.
retain_structured_chunk_size, entity_labels,
entities_allow_free_form, etc.
Unknown strategy names log a warning and return config unchanged.
Unknown or non-hierarchical fields in the strategy are silently ignored.
@@ -386,4 +589,17 @@ def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConf
return config
logger.debug(f"Applying retain strategy '{strategy_name}': {list(filtered.keys())}")
return replace(config, **filtered)
resolved = replace(config, **filtered)
validate_retain_chunking_config(
resolved.retain_chunk_size,
resolved.retain_structured_chunk_size,
)
validate_retain_completion_token_budget(
llm_provider=resolved.llm_provider,
retain_max_completion_tokens=resolved.retain_max_completion_tokens,
retain_chunk_size=resolved.retain_chunk_size,
retain_llm_model=resolved.retain_llm_model,
llm_model=resolved.llm_model,
retain_llm_provider=resolved.retain_llm_provider,
)
return resolved
@@ -107,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,
@@ -119,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."""
@@ -176,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,34 @@
"""Per-bank provider cost attribution via the OpenAI ``user`` field.
Shared by the OpenAI-compatible LLM path and the OpenAI embeddings path so both
tag outbound requests identically. Opt-in via ``HINDSIGHT_API_LLM_SEND_BANK_AS_USER``;
downstream cost gateways (OpenRouter usage accounting, LiteLLM, Helicone) key spend
on the OpenAI ``user`` field.
Note: when enabled, the bank id is transmitted to the upstream provider as the
end-user identifier. Banks that are themselves end-user identifiers are therefore
forwarded to the provider — which is exactly what the OpenAI ``user`` field is for,
but operators should opt in with that in mind.
"""
from typing import Any
def apply_bank_attribution(request: dict[str, Any]) -> None:
"""Tag ``request`` with ``user=<bank_id>`` for per-bank cost attribution.
Mutates ``request`` in place. No-op when the flag is off, no bank is in context,
or the caller already set ``user`` — we never override an explicit value.
"""
if "user" in request:
return
# Lazy imports: memory_engine imports the embeddings/provider modules that call
# this, so a top-level import of memory_engine here would be circular.
from ..config import get_config
from .memory_engine import get_current_bank_id
if not get_config().llm_send_bank_as_user:
return
bank_id = get_current_bank_id()
if bank_id:
request["user"] = bank_id
@@ -0,0 +1,254 @@
"""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 json
import logging
import time
from collections import OrderedDict
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from .db_utils import acquire_with_retry
if TYPE_CHECKING:
from .db.base import DatabaseBackend
logger = logging.getLogger(__name__)
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]]],
*,
force_refresh: bool = False,
) -> 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. When ``force_refresh`` is set the cached value is
ignored: the loader runs and its result replaces the cached entry.
"""
if not self.enabled:
return await loader()
key = (schema, bank_id)
if force_refresh:
value = await loader()
async with self._lock:
self._store_unlocked(key, value)
# Supersede any loader that was in flight for this key.
self._in_flight.pop(key, None)
return value
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:
# Invalidation may have detached this loader and allowed a new
# one to claim the key. Never remove that newer loader's slot.
if self._in_flight.get(key) is in_flight:
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:
# Only the loader that still owns the key may populate the cache.
# An invalidated loader can finish for its original callers, but its
# pre-invalidation result must not overwrite a newer load.
if self._in_flight.get(key) is in_flight:
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:
key = (schema, bank_id)
self._entries.pop(key, None)
# Detach rather than cancel: existing callers may finish with the
# snapshot they requested, while post-invalidation callers reload.
self._in_flight.pop(key, None)
async def clear(self) -> None:
async with self._lock:
self._entries.clear()
self._in_flight.clear()
class DistributedBankStatsCache:
"""Table-backed (cross-process) TTL cache for `get_bank_stats`.
Same ``get_or_load`` / ``invalidate`` / ``clear`` contract as
:class:`BankStatsCache`, but the store is the per-schema ``bank_stats_cache``
table instead of a per-process dict — so one worker's computation is shared
with every other worker, and no caller recomputes while a fresh row exists.
On a hit, a call is a single primary-key ``SELECT`` (sub-millisecond); only a
miss runs the (expensive) ``loader`` and writes the row back. Concurrent
misses are *not* coalesced across processes (that would need a lock): they
each compute and ``UPSERT``, last write wins — all results are correct, at the
cost of a brief redundant compute at expiry.
Every DB touch is best-effort: if the cache table is unreachable or missing
(e.g. a schema mid-migration), the call degrades to computing without caching
rather than failing ``get_bank_stats``. PostgreSQL only — the engine keeps the
in-process :class:`BankStatsCache` for Oracle.
"""
def __init__(self, *, backend: "DatabaseBackend", ttl_seconds: float) -> None:
self._backend = backend
self._ttl = float(ttl_seconds)
@property
def enabled(self) -> bool:
return self._ttl > 0
@staticmethod
def _qualified(schema: str) -> str:
return f'"{schema}".bank_stats_cache' if schema else "bank_stats_cache"
async def get_or_load(
self,
schema: str,
bank_id: str,
loader: Callable[[], Awaitable[dict[str, Any]]],
*,
force_refresh: bool = False,
) -> dict[str, Any]:
if not self.enabled:
return await loader()
table = self._qualified(schema)
# 1. Fresh row? Single PK lookup; ``payload::text`` sidesteps any
# jsonb->object codec so we always decode the same way. Skipped when
# the caller forces a refresh — then we recompute and overwrite below.
if not force_refresh:
try:
async with acquire_with_retry(self._backend) as conn:
row = await conn.fetchrow(
f"SELECT payload::text AS payload FROM {table} "
f"WHERE bank_id = $1 AND computed_at > now() - make_interval(secs => $2::double precision)",
bank_id,
self._ttl,
)
if row is not None:
return json.loads(row["payload"])
except Exception as exc: # noqa: BLE001 — cache read must never break the endpoint
logger.debug("bank_stats_cache read failed for %s.%s (%s); computing uncached", schema, bank_id, exc)
return await loader()
# 2. Miss — compute, then write the row back (best-effort).
value = await loader()
try:
async with acquire_with_retry(self._backend) as conn:
await conn.execute(
f"INSERT INTO {table} (bank_id, payload, computed_at) VALUES ($1, $2::jsonb, now()) "
f"ON CONFLICT (bank_id) DO UPDATE SET payload = EXCLUDED.payload, computed_at = now()",
bank_id,
json.dumps(value),
)
except Exception as exc: # noqa: BLE001 — a failed write just means no caching this round
logger.warning("bank_stats_cache write failed for %s.%s (%s)", schema, bank_id, exc)
return value
async def invalidate(self, schema: str, bank_id: str) -> None:
"""Drop the cached row so the next read recomputes."""
if not self.enabled:
return
try:
async with acquire_with_retry(self._backend) as conn:
await conn.execute(f"DELETE FROM {self._qualified(schema)} WHERE bank_id = $1", bank_id)
except Exception as exc: # noqa: BLE001 — invalidation must never break the write path
logger.debug("bank_stats_cache invalidate failed for %s.%s (%s)", schema, bank_id, exc)
async def clear(self) -> None:
"""Drop all cached rows in the current schema (best-effort)."""
if not self.enabled:
return
from .memory_engine import get_current_schema
try:
async with acquire_with_retry(self._backend) as conn:
await conn.execute(f"DELETE FROM {self._qualified(get_current_schema())}")
except Exception as exc: # noqa: BLE001
logger.debug("bank_stats_cache clear failed (%s)", exc)
File diff suppressed because it is too large Load Diff
@@ -22,14 +22,16 @@ 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 fnmatch import fnmatchcase
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,
@@ -41,7 +43,10 @@ from ..llm_trace import (
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
@@ -85,6 +90,232 @@ def _duplicate_create_target(
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"] = "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.
search_vector_clause = (
f",\n search_vector = to_tsvector('{config.text_search_extension_native_language}'::regconfig, COALESCE($1, ''))"
if config.text_search_extension == "native"
else ""
)
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(){search_vector_clause}
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).
search_vector_clause = (
f",\n search_vector = to_tsvector('{config.text_search_extension_native_language}'::regconfig, COALESCE($1, ''))"
if config.text_search_extension == "native"
else ""
)
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(){search_vector_clause}
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.
@@ -114,7 +345,15 @@ def _resolve_obs_tags_list(memory: dict[str, Any]) -> list[list[str]] | None:
Returns ``None`` for the default ``combined``-mode single pass (caller uses
the memory's own tags). Returns a list[list[str]] when the memory requested
multi-pass scoping (``per_tag``, ``all_combinations``, or an explicit list).
multi-pass scoping (``per_tag``, ``all_combinations``, ``shared``, or an
explicit list).
``shared`` resolves to ``[[]]`` — a single pass over the empty (untagged)
scope. The created observation carries no tags and recall/dedup match it with
``tags_match="any"``, so every memory consolidates into one shared observation
regardless of its own tags. Use it to deduplicate across volatile per-call
provenance tags (e.g. per-session ids) without dropping those tags from the
source facts.
"""
parsed = _parse_observation_scopes(memory)
tags = list(memory.get("tags") or [])
@@ -125,6 +364,8 @@ def _resolve_obs_tags_list(memory: dict[str, Any]) -> list[list[str]] | None:
if not tags:
return None
return [list(c) for r in range(1, len(tags) + 1) for c in combinations(tags, r)]
if parsed == "shared":
return [[]]
if parsed == "combined" or parsed is None:
return None
return parsed # explicit list[list[str]]
@@ -141,6 +382,7 @@ def _resolve_write_scopes(memory: dict[str, Any]) -> list[frozenset[str]]:
- ``combined`` / ``None`` -> ``[frozenset(memory.tags)]``
- ``per_tag`` -> ``[frozenset({t}) for t in memory.tags]``
- ``all_combinations`` -> one frozenset per nonempty subset of tags
- ``shared`` -> ``[frozenset()]`` (the single untagged scope)
- explicit ``list[list[str]]`` -> one frozenset per declared scope
Empty-tag memories collapse to a single ``frozenset()`` in all modes so they
@@ -155,6 +397,8 @@ def _resolve_write_scopes(memory: dict[str, Any]) -> list[frozenset[str]]:
if not tags:
return [frozenset()]
return [frozenset(c) for r in range(1, len(tags) + 1) for c in combinations(tags, r)]
if parsed == "shared":
return [frozenset()]
if parsed == "combined" or parsed is None:
return [frozenset(tags)]
return [frozenset(s) for s in parsed] # explicit list[list[str]]
@@ -215,6 +459,13 @@ class _CreateAction(BaseModel):
def sanitize_text(cls, v: str) -> str:
return sanitize_llm_output(v) or ""
@field_validator("source_fact_ids", mode="before")
@classmethod
def ensure_list(cls, v: str | list[str]) -> list[str]:
if isinstance(v, str):
return [v]
return v
class _UpdateAction(BaseModel):
text: str
@@ -227,6 +478,13 @@ class _UpdateAction(BaseModel):
def sanitize_text(cls, v: str) -> str:
return sanitize_llm_output(v) or ""
@field_validator("source_fact_ids", mode="before")
@classmethod
def ensure_list(cls, v: str | list[str]) -> list[str]:
if isinstance(v, str):
return [v]
return v
class _DeleteAction(BaseModel):
observation_id: str # UUID of the observation to remove
@@ -303,6 +561,86 @@ async def _count_observations_for_scope(
)
@dataclass(frozen=True)
class _ScopeLimitRule:
"""One ``observation_scope_limits`` rule: a scope pattern -> an observation cap.
``globs`` is a tuple of fnmatch tag-globs describing one consolidation scope.
A concrete scope (the set of ``fact_tags`` for a consolidation pass) matches
under *exact cover*: every tag is matched by some glob AND every glob matches
some tag. So ``["shared"]`` matches the scope ``{shared}`` but not
``{run_1, shared}``, and ``["run_*", "shared"]`` matches ``{run_1, shared}``
but not ``{shared}``.
``limit`` is the cap applied to matching scopes (-1 = unlimited, 0 = no new
observations, >0 = hard cap), mirroring ``max_observations_per_scope``.
"""
globs: tuple[str, ...]
limit: int
def _parse_scope_limit_rules(raw: Any) -> list[_ScopeLimitRule]:
"""Parse the raw ``observation_scope_limits`` config into ordered rules.
The config round-trips as JSON through env and the bank-config API, so this
is defensive: malformed entries are skipped rather than raising, and list
order is preserved (first match wins in :func:`_effective_scope_limit`).
"""
if not isinstance(raw, list):
return []
rules: list[_ScopeLimitRule] = []
for entry in raw:
if not isinstance(entry, dict):
continue
scope = entry.get("scope")
limit = entry.get("limit")
if not isinstance(scope, list) or not scope:
continue
if not all(isinstance(g, str) and g for g in scope):
continue
# bool is an int subclass — reject True/False masquerading as a limit.
if not isinstance(limit, int) or isinstance(limit, bool):
continue
rules.append(_ScopeLimitRule(globs=tuple(scope), limit=limit))
return rules
def _scope_matches_globs(globs: tuple[str, ...], tags: list[str]) -> bool:
"""Exact-cover match between a scope pattern and a concrete tag set.
True iff every tag is covered by at least one glob AND every glob covers at
least one tag (no uncovered tags, no vacuous globs). Untagged scopes never
match, so a scope limit never applies to untagged observations (consistent
with the ``and fact_tags`` guard at the call site). Matching is
case-sensitive (``fnmatchcase``) for deterministic cross-platform behaviour.
"""
tagset = set(tags)
if not tagset:
return False
if not all(any(fnmatchcase(t, g) for g in globs) for t in tagset):
return False
if not all(any(fnmatchcase(t, g) for t in tagset) for g in globs):
return False
return True
def _effective_scope_limit(config: Any, fact_tags: list[str]) -> int:
"""Resolve the observation cap for one concrete consolidation scope.
The first rule in ``observation_scope_limits`` whose pattern exact-covers
``fact_tags`` wins; otherwise falls back to the bank-wide
``max_observations_per_scope``. Wildcards live only here, matched against the
already-resolved concrete tags — the SQL count stays exact and indexed.
"""
if config is None:
return -1
for rule in _parse_scope_limit_rules(getattr(config, "observation_scope_limits", None)):
if _scope_matches_globs(rule.globs, fact_tags):
return rule.limit
return config.max_observations_per_scope
def _build_response_model(max_creates: int | None = None) -> type[_ConsolidationBatchResponse]:
"""Build a response model, optionally constraining max creates via JSON schema."""
if max_creates is None or max_creates < 0:
@@ -326,6 +664,7 @@ class ConsolidationPerfLog:
self.start_time = time.time()
self.lines: list[str] = []
self.timings: dict[str, float] = {}
self.timing_counts: dict[str, int] = {}
self.llm_calls: int = 0
self.total_obs_in_context: int = 0
self.total_prompt_chars: int = 0
@@ -335,11 +674,13 @@ class ConsolidationPerfLog:
self.lines.append(message)
def record_timing(self, key: str, duration: float) -> None:
"""Record a timing measurement."""
if key in self.timings:
self.timings[key] += duration
else:
self.timings[key] = duration
"""Record a timing measurement.
Tracks both total seconds and call count so the summary can
distinguish one slow call from many fast calls in aggregate.
"""
self.timings[key] = self.timings.get(key, 0.0) + duration
self.timing_counts[key] = self.timing_counts.get(key, 0) + 1
def record_llm_call(self, obs_count: int, prompt_chars: int) -> None:
"""Record stats for a single LLM call."""
@@ -362,6 +703,8 @@ class ConsolidationPerfLog:
"""
for key, value in other.timings.items():
self.timings[key] = self.timings.get(key, 0.0) + value
for key, count in other.timing_counts.items():
self.timing_counts[key] = self.timing_counts.get(key, 0) + count
self.llm_calls += other.llm_calls
self.total_obs_in_context += other.total_obs_in_context
self.total_prompt_chars += other.total_prompt_chars
@@ -498,6 +841,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,
@@ -518,10 +899,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 = (
@@ -742,12 +1131,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
@@ -775,6 +1170,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
@@ -889,16 +1305,22 @@ async def _run_consolidation_job(
f"{stats['skipped']} skipped)"
)
# Add timing breakdown
# Add timing breakdown. Each phase is recorded once per call, so the count
# disambiguates a single slow call from many fast calls — important for
# operators triaging "the recall phase took 15s" log lines, where the
# total is the sum of many serial sub-calls rather than one slow query.
def _fmt(key: str) -> str:
total = perf.timings[key]
count = perf.timing_counts.get(key, 0)
if count > 1:
avg_ms = total * 1000.0 / count
return f"{key}={total:.3f}s ({count} calls, avg={avg_ms:.0f}ms)"
return f"{key}={total:.3f}s"
timing_parts = []
if "recall" in perf.timings:
timing_parts.append(f"recall={perf.timings['recall']:.3f}s")
if "llm" in perf.timings:
timing_parts.append(f"llm={perf.timings['llm']:.3f}s")
if "embedding" in perf.timings:
timing_parts.append(f"embedding={perf.timings['embedding']:.3f}s")
if "db_write" in perf.timings:
timing_parts.append(f"db_write={perf.timings['db_write']:.3f}s")
for key in ("recall", "llm", "embedding", "db_write"):
if key in perf.timings:
timing_parts.append(_fmt(key))
if perf.llm_calls > 0:
timing_parts.append(f"avg_obs={perf.total_obs_in_context / perf.llm_calls:.1f}")
@@ -913,6 +1335,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,
@@ -1103,11 +1532,15 @@ async def _process_memory_batch(
# All memories in the batch share the same tag set (enforced by batching)
fact_tags = memories[0].get("tags") or [] if memories else []
# 2b. Compute remaining observation slots for this scope (if limit configured)
max_obs = config.max_observations_per_scope if config is not None else -1
# 2b. Compute remaining observation slots for this scope (if limit configured).
# The cap is resolved per-scope: an observation_scope_limits rule may override
# the bank-wide max_observations_per_scope for scopes matching its tag pattern.
max_obs = _effective_scope_limit(config, fact_tags)
remaining_observation_slots: int | None = None
if max_obs > 0 and fact_tags:
current_count = await _count_observations_for_scope(conn, bank_id, fact_tags)
if max_obs >= 0 and fact_tags:
# max_obs == 0 means "no new observations": there are no slots regardless
# of the current count, so skip the count query for that case.
current_count = await _count_observations_for_scope(conn, bank_id, fact_tags) if max_obs > 0 else 0
remaining_observation_slots = max(max_obs - current_count, 0)
if remaining_observation_slots == 0:
logger.info(
@@ -1138,6 +1571,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:
@@ -1162,7 +1609,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,
@@ -1178,6 +1625,21 @@ 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
@@ -1212,6 +1674,22 @@ async def _process_memory_batch(
)
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,
@@ -1256,6 +1734,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",
@@ -1269,12 +1805,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:
@@ -1292,15 +1831,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
@@ -1316,8 +1854,11 @@ 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 ""
search_vector_clause = (
f",\n search_vector = to_tsvector('{config.text_search_extension_native_language}'::regconfig, COALESCE($1, ''))"
if config.text_search_extension == "native"
else ""
)
t0 = time.time()
@@ -1326,19 +1867,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)){search_vector_clause}
WHERE id = $5
""",
new_text,
embedding_str,
json.dumps([history_entry]),
source_ids,
len(source_ids),
uuid.UUID(observation_id),
@@ -1348,6 +1887,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)
@@ -1371,6 +1919,7 @@ async def _execute_update_action(
# 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(
@@ -1635,7 +2184,7 @@ async def _consolidate_batch_with_llm(
# Build capacity note for the prompt when observation limit is configured
observation_capacity_note: str | None = None
if remaining_observation_slots is not None and max_observations_per_scope > 0:
if remaining_observation_slots is not None and max_observations_per_scope >= 0:
if remaining_observation_slots == 0:
observation_capacity_note = (
f"OBSERVATION LIMIT REACHED ({max_observations_per_scope}/{max_observations_per_scope}). "
@@ -1648,16 +2197,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)
@@ -1677,12 +2248,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
@@ -1699,7 +2281,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
@@ -1711,7 +2293,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(
@@ -1758,29 +2342,33 @@ 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
"""
else: # native, pg_textsearch, pgroonga, or pg_search
# pg_textsearch / pgroonga / pg_search: indexes operate on base text
# columns directly, so the dummy search_vector column is left NULL.
# Native: the migration p4q5r6s7t8u9 dropped the GENERATED expression on
# search_vector to allow per-deployment language configuration; the
# batch insert path in ops_postgresql.insert_facts_batch now populates
# it via to_tsvector($lang, ...). This single-observation INSERT does
# not, so observations under the native backend currently land with
# NULL search_vector and are not BM25-searchable until reflected/
# re-ingested. Tracking a separate fix for that gap.
elif config.text_search_extension == "native":
# Native: search_vector is populated with to_tsvector() using the
# configured native language dictionary, matching the batch insert
# path in ops_postgresql.insert_facts_batch.
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, $6, $7, $8, $9, $10,
to_tsvector('{config.text_search_extension_native_language}'::regconfig, COALESCE($3, '')))
RETURNING id
"""
else: # pg_textsearch, pgroonga, pg_search: indexes operate on base text columns directly
query = f"""
INSERT INTO {fq_table("memory_units")} (
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
@@ -146,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)
@@ -27,35 +27,21 @@ from ..config import (
DEFAULT_RERANKER_LITELLM_MODEL,
DEFAULT_RERANKER_LITELLM_SDK_MODEL,
DEFAULT_RERANKER_LOCAL_BATCH_SIZE,
DEFAULT_RERANKER_LOCAL_FORCE_CPU,
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT,
DEFAULT_RERANKER_LOCAL_MODEL,
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE,
DEFAULT_RERANKER_PROVIDER,
DEFAULT_RERANKER_SILICONFLOW_BASE_URL,
DEFAULT_RERANKER_SILICONFLOW_MODEL,
DEFAULT_RERANKER_TEI_BATCH_SIZE,
DEFAULT_RERANKER_TEI_HTTP_TIMEOUT,
DEFAULT_RERANKER_TEI_MAX_CONCURRENT,
DEFAULT_RERANKER_ZEROENTROPY_MODEL,
DEFAULT_ZEROENTROPY_BASE_URL,
ENV_RERANKER_ALIBABA_API_KEY,
ENV_RERANKER_COHERE_API_KEY,
ENV_RERANKER_COHERE_MODEL,
ENV_RERANKER_FLASHRANK_CACHE_DIR,
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,
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE,
ENV_RERANKER_PROVIDER,
ENV_RERANKER_SILICONFLOW_API_KEY,
ENV_RERANKER_TEI_BATCH_SIZE,
ENV_RERANKER_TEI_HTTP_TIMEOUT,
ENV_RERANKER_TEI_MAX_CONCURRENT,
ENV_RERANKER_TEI_URL,
ENV_RERANKER_ZEROENTROPY_API_KEY,
)
@@ -226,7 +212,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
device = "cpu"
logger.info("Reranker: forcing CPU mode (HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=1)")
else:
# Check for GPU (CUDA) or Apple Silicon (MPS)
# Check for GPU (CUDA), Apple Silicon (MPS), or Intel XPU
# Wrap in try-except to gracefully handle any device detection issues
# (e.g., in CI environments or when PyTorch is built without GPU support)
device = "cpu" # Default to CPU
@@ -234,10 +220,13 @@ class LocalSTCrossEncoder(CrossEncoderModel):
has_gpu = torch.cuda.is_available() or (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
)
# Intel Arc XPU support — torch.xpu is available when the XPU build is loaded
if not has_gpu and hasattr(torch, "xpu"):
has_gpu = torch.xpu.is_available()
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS
device = None # Let sentence-transformers auto-detect GPU/MPS/XPU
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
logger.warning(f"Failed to detect GPU/MPS/XPU, falling back to CPU: {e}")
# Patch transformers 5.x compatibility for models using XLM-RoBERTa
# (e.g., jina-reranker-v2-base-multilingual). transformers 5.x removed
@@ -304,7 +293,6 @@ class LocalSTCrossEncoder(CrossEncoderModel):
- bucket_batching: sort pairs by token length to reduce padding waste (36-54% speedup)
- batch_size: explicit batch size for predict() calls (MPS optimal: 32)
"""
import numpy as np
try:
if self.bucket_batching and len(pairs) > 1:
@@ -1199,7 +1187,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 +1197,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 +1273,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
@@ -1678,7 +1668,7 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
return CohereCrossEncoder(
api_key=api_key,
model=config.reranker_openrouter_model,
base_url="https://openrouter.ai/api/v1/rerank",
base_url=config.reranker_openrouter_base_url,
timeout=config.reranker_openrouter_timeout,
)
elif provider == "flashrank":
@@ -1697,13 +1687,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,
@@ -19,7 +19,6 @@ and mirrors Django's ``DatabaseOperations`` architecture.
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
from uuid import UUID
from .base import DatabaseConnection
from .result import ResultRow
@@ -8,8 +8,6 @@ columns can't appear in GROUP BY).
import json
import uuid as uuid_mod
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from .base import DatabaseConnection
from .ops import DataAccessOps, TagListingParts
@@ -258,13 +256,21 @@ class OracleOps(DataAccessOps):
# Oracle doesn't support ON CONFLICT; rely on the PK and the
# IGNORE_ROW_ON_DUPKEY_INDEX hint to skip duplicates server-side.
# The hint name must match the PK constraint exactly.
#
# Sort to enforce a global lock-acquisition order on the
# (bank_id, unit_id) PK. Without this, two concurrent
# transactions inserting overlapping unit_id sets in different
# orders can deadlock on the unique-check row locks. Sorting
# gives every concurrent caller the same lock order, so
# conflicting inserts queue cleanly instead of cycling.
sorted_unit_ids = sorted(unit_ids)
await conn.executemany(
f"""
INSERT /*+ IGNORE_ROW_ON_DUPKEY_INDEX({table}, pk_graph_maintenance_queue) */
INTO {table} (bank_id, unit_id)
VALUES ($1, $2)
""",
[(bank_id, uid) for uid in unit_ids],
[(bank_id, uid) for uid in sorted_unit_ids],
)
async def claim_graph_maintenance_batch(
@@ -4,11 +4,6 @@ Uses unnest(), LATERAL, DISTINCT ON, and native array operations for
efficient batch operations.
"""
import json
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from .base import DatabaseConnection
from .ops import DataAccessOps, TagListingParts
from .result import ResultRow
@@ -353,6 +348,15 @@ class PostgreSQLOps(DataAccessOps):
) -> None:
if not unit_ids:
return
# Sort to enforce a global lock-acquisition order on the
# (bank_id, unit_id) unique-key. Without this, two concurrent
# transactions inserting overlapping unit_id sets in different
# orders can deadlock on the ON CONFLICT row locks — Postgres
# acquires a short-lived lock per row being checked, and cycle
# detection then aborts one transaction. Sorting gives every
# concurrent caller the same lock order, so conflicting inserts
# queue cleanly instead of cycling.
sorted_unit_ids = sorted(unit_ids)
await conn.execute(
f"""
INSERT INTO {table} (bank_id, unit_id)
@@ -360,7 +364,7 @@ class PostgreSQLOps(DataAccessOps):
ON CONFLICT (bank_id, unit_id) DO NOTHING
""",
bank_id,
unit_ids,
sorted_unit_ids,
)
async def claim_graph_maintenance_batch(
@@ -620,7 +624,6 @@ class PostgreSQLOps(DataAccessOps):
per_entity_limit: int,
) -> tuple[list[ResultRow], list[ResultRow], list[ResultRow]]:
# v0.5.6 array ops: unnest, &&, COUNT(DISTINCT) on source_memory_ids.
from ..schema import fq_table
entity_rows = await conn.fetch(
f"""
@@ -106,6 +106,15 @@ SCHEMAS_WITH_PENDING_WORK = OptionalRoutine(
deployment.
* Should be cheap and idempotent — called every poll cycle (~30s).
The poller trusts the result wholesale: any schema the routine does
not return is treated as having no work this cycle. It does NOT
second-guess omissions with a per-schema scan — that would re-run the
exact queries this routine exists to avoid. Consequently the routine
is *only* appropriate for multi-tenant deployments. Single-schema
(default/public only) installs should NOT create it: the per-schema
fallback below is a single cheap EXISTS check that covers ``public``
correctly and cannot starve.
Fallback when the routine is absent: per-schema ``EXISTS`` queries
from Python (~4ms per schema). The server-side path is a single-
round-trip optimisation worth ~200ms in deployments with thousands
@@ -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())
@@ -26,11 +26,8 @@ from ..config import (
DEFAULT_EMBEDDINGS_GEMINI_MODEL,
DEFAULT_EMBEDDINGS_LITELLM_MODEL,
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL,
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU,
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
DEFAULT_EMBEDDINGS_OPENAI_MODEL,
DEFAULT_EMBEDDINGS_PROVIDER,
DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE,
DEFAULT_EMBEDDINGS_ZEROENTROPY_DIMENSIONS,
DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT,
@@ -40,9 +37,6 @@ from ..config import (
DEFAULT_ZEROENTROPY_BASE_URL,
ENV_EMBEDDINGS_COHERE_API_KEY,
ENV_EMBEDDINGS_GEMINI_API_KEY,
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
ENV_EMBEDDINGS_LOCAL_MODEL,
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
ENV_EMBEDDINGS_OPENAI_API_KEY,
ENV_EMBEDDINGS_OPENAI_BASE_URL,
ENV_EMBEDDINGS_OPENAI_MODEL,
@@ -53,6 +47,7 @@ from ..config import (
ENV_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT,
ENV_LLM_API_KEY,
)
from .bank_attribution import apply_bank_attribution
logger = logging.getLogger(__name__)
@@ -195,7 +190,7 @@ class LocalSTEmbeddings(Embeddings):
device = "cpu"
logger.info("Embeddings: forcing CPU mode")
else:
# Check for GPU (CUDA) or Apple Silicon (MPS)
# Check for GPU (CUDA), Apple Silicon (MPS), or Intel XPU
# Wrap in try-except to gracefully handle any device detection issues
# (e.g., in CI environments or when PyTorch is built without GPU support)
device = "cpu" # Default to CPU
@@ -203,10 +198,13 @@ class LocalSTEmbeddings(Embeddings):
has_gpu = torch.cuda.is_available() or (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
)
# Intel Arc XPU support — torch.xpu is available when the XPU build is loaded
if not has_gpu and hasattr(torch, "xpu"):
has_gpu = torch.xpu.is_available()
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS
device = None # Let sentence-transformers auto-detect GPU/MPS/XPU
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
logger.warning(f"Failed to detect GPU/MPS/XPU, falling back to CPU: {e}")
# Suppress verbose transformers warnings during model loading
# This suppresses the "UNEXPECTED" warnings from BertModel which are harmless
@@ -252,6 +250,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.
@@ -535,6 +699,7 @@ class OpenAIEmbeddings(Embeddings):
}
if self.dimensions is not None:
request["dimensions"] = self.dimensions
apply_bank_attribution(request)
response = self._client.embeddings.create(**request)
@@ -547,7 +712,8 @@ class OpenAIEmbeddings(Embeddings):
class CodexOAuthEmbeddings(OpenAIEmbeddings):
"""
OpenAI embeddings using the Codex/ChatGPT OAuth token from ``~/.codex/auth.json``.
OpenAI embeddings using the Codex/ChatGPT OAuth token from the Codex
``auth.json`` (``$CODEX_HOME/auth.json``, or ``~/.codex/auth.json`` when unset).
Codex OAuth is an LLM-provider auth path in Hindsight, but the same bearer token
can also authenticate against the standard OpenAI embeddings endpoint. This keeps
@@ -1177,6 +1343,21 @@ class LiteLLMSDKEmbeddings(Embeddings):
return all_embeddings
# Gemini Embedding 2+ multimodal models return a SINGLE aggregated embedding
# for a multi-input request instead of one vector per input (see
# https://ai.google.dev/gemini-api/docs/embeddings#embedding-aggregation). For
# these models we must embed one input per call to preserve the 1:1 input→vector
# alignment the rest of the pipeline relies on. The marker matches preview and GA
# names (e.g. "gemini-embedding-2-preview", "gemini-embedding-2"), with or
# without a "google/" or "models/" prefix.
_GEMINI_AGGREGATING_MODEL_MARKER = "gemini-embedding-2"
def _gemini_model_aggregates_inputs(model: str) -> bool:
"""Whether the model aggregates a multi-input request into one embedding."""
return _GEMINI_AGGREGATING_MODEL_MARKER in model.lower()
class GeminiEmbeddings(Embeddings):
"""
Google embeddings via the google.genai SDK.
@@ -1186,6 +1367,10 @@ class GeminiEmbeddings(Embeddings):
2. Vertex AI with service account or Application Default Credentials (ADC)
Uses the embed_content API: client.models.embed_content(model, contents)
Gemini Embedding 2+ multimodal models aggregate a multi-input request into a
single embedding, so for those the batch size is forced to 1 (one input per
call) to keep one vector per input.
"""
def __init__(
@@ -1340,9 +1525,13 @@ class GeminiEmbeddings(Embeddings):
all_embeddings = []
# Gemini Embedding 2+ multimodal models return one aggregated vector for a
# multi-input request, so embed one input per call to keep 1:1 alignment.
batch_size = 1 if _gemini_model_aggregates_inputs(self.model) else self.batch_size
# Process in batches
for i in range(0, len(texts), self.batch_size):
batch = texts[i : i + self.batch_size]
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
embed_kwargs = {"model": self.model, "contents": batch}
if self._embed_config is not None:
@@ -1350,7 +1539,13 @@ class GeminiEmbeddings(Embeddings):
result = self._client.models.embed_content(**embed_kwargs)
all_embeddings.extend([emb.values for emb in result.embeddings])
embeddings = result.embeddings or []
if len(embeddings) != len(batch):
raise RuntimeError(
f"Gemini embeddings backend returned {len(embeddings)} vectors for "
f"{len(batch)} input texts (model {self.model}); expected exact 1:1 alignment"
)
all_embeddings.extend([emb.values for emb in embeddings])
# L2-normalize when output_dimensionality is set — Gemini only returns
# normalized vectors at full 3072 dims; truncated dims need re-normalization
@@ -1391,6 +1586,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)
@@ -1429,6 +1638,20 @@ def create_embeddings_from_env() -> Embeddings:
batch_size=config.embeddings_openai_batch_size,
dimensions=config.embeddings_openai_dimensions,
)
elif provider == "requesty":
api_key = config.embeddings_requesty_api_key
if not api_key:
raise ValueError(
"HINDSIGHT_API_EMBEDDINGS_REQUESTY_API_KEY, HINDSIGHT_API_REQUESTY_API_KEY, "
f"or {ENV_LLM_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'requesty'"
)
return OpenAIEmbeddings(
api_key=api_key,
model=config.embeddings_requesty_model,
base_url="https://router.requesty.ai/v1",
batch_size=config.embeddings_openai_batch_size,
dimensions=config.embeddings_openai_dimensions,
)
elif provider == "zeroentropy":
api_key = config.embeddings_zeroentropy_api_key
if not api_key:
@@ -1492,6 +1715,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', 'requesty', 'cohere', 'google', "
f"'zeroentropy', 'litellm', 'litellm-sdk'"
)
@@ -782,239 +782,6 @@ class EntityResolver:
return entity_ids
async def resolve_entity(
self,
bank_id: str,
entity_text: str,
context: str,
nearby_entities: list[dict],
unit_event_date,
) -> str:
"""
Resolve an entity to a canonical entity ID.
Args:
bank_id: bank ID (entities are scoped to agents)
entity_text: Entity text ("Alice", "Google", etc.)
context: Context where entity appears
nearby_entities: Other entities in the same unit
unit_event_date: When this unit was created
Returns:
Entity ID (creates new entity if needed)
"""
async with acquire_with_retry(self.pool) as conn:
# Find candidate entities with similar name
candidates = await conn.fetch(
f"""
SELECT id, canonical_name, metadata, last_seen
FROM {fq_table("entities")}
WHERE bank_id = $1
AND (
canonical_name ILIKE $2
OR canonical_name ILIKE $3
OR $2 ILIKE canonical_name || '%%'
)
ORDER BY mention_count DESC
""",
bank_id,
entity_text,
f"%{entity_text}%",
)
if not candidates:
# New entity - create it
return await self._create_entity(conn, bank_id, entity_text, unit_event_date)
# Score candidates based on:
# 1. Name similarity
# 2. Context overlap (TODO: could use embeddings)
# 3. Co-occurring entities
# 4. Temporal proximity
best_candidate = None
best_score = 0.0
best_name_similarity = 0.0
nearby_entity_set = {e["text"].lower() for e in nearby_entities if e["text"] != entity_text}
for row in candidates:
candidate_id = row["id"]
canonical_name = row["canonical_name"]
metadata = row["metadata"]
last_seen = row["last_seen"]
score = 0.0
# 1. Name similarity (0-1)
name_similarity = SequenceMatcher(None, entity_text.lower(), canonical_name.lower()).ratio()
score += name_similarity * 0.5
# 2. Co-occurring entities (0-0.5)
# Get entities that co-occurred with this candidate before
# Use the materialized co-occurrence cache for fast lookup
co_entity_rows = await conn.fetch(
f"""
SELECT e.canonical_name, ec.cooccurrence_count
FROM {fq_table("entity_cooccurrences")} ec
JOIN {fq_table("entities")} e ON (
CASE
WHEN ec.entity_id_1 = $1 THEN ec.entity_id_2
WHEN ec.entity_id_2 = $1 THEN ec.entity_id_1
END = e.id
)
WHERE ec.entity_id_1 = $1 OR ec.entity_id_2 = $1
""",
candidate_id,
)
co_entities = {r["canonical_name"].lower() for r in co_entity_rows}
# Check overlap with nearby entities
overlap = len(nearby_entity_set & co_entities)
if nearby_entity_set:
co_entity_score = overlap / len(nearby_entity_set)
score += co_entity_score * 0.3
# 3. Temporal proximity (0-0.2)
if last_seen:
# Normalize both to UTC-aware to avoid naive/aware mismatch
# (Oracle returns naive datetimes from fromisoformat)
_evt = unit_event_date if unit_event_date.tzinfo else unit_event_date.replace(tzinfo=UTC)
_seen = last_seen if last_seen.tzinfo else last_seen.replace(tzinfo=UTC)
days_diff = abs((_evt - _seen).total_seconds() / 86400)
if days_diff < 7: # Within a week
temporal_score = max(0, 1.0 - (days_diff / 7))
score += temporal_score * 0.2
if score > best_score:
best_score = score
best_candidate = candidate_id
best_name_similarity = name_similarity
# Threshold for considering it the same entity
threshold = 0.6
if best_score > threshold:
# Update entity
await conn.execute(
f"""
UPDATE {fq_table("entities")}
SET mention_count = mention_count + 1,
last_seen = $1
WHERE id = $2
""",
unit_event_date,
best_candidate,
)
return best_candidate
else:
# Not confident - create new entity
return await self._create_entity(conn, bank_id, entity_text, unit_event_date)
async def _create_entity(
self,
conn,
bank_id: str,
entity_text: str,
event_date,
) -> str:
"""
Create a new entity or get existing one if it already exists.
Uses INSERT ... ON CONFLICT to handle race conditions where
two concurrent transactions try to create the same entity.
Args:
conn: Database connection
bank_id: bank ID
entity_text: Entity text
event_date: When first seen
Returns:
Entity ID
"""
entity_id = await conn.fetchval(
f"""
INSERT INTO {fq_table("entities")} (bank_id, canonical_name, first_seen, last_seen, mention_count)
VALUES ($1, $2, COALESCE($3, now()), COALESCE($4, now()), 1)
ON CONFLICT (bank_id, LOWER(canonical_name))
DO UPDATE SET
mention_count = {fq_table("entities")}.mention_count + 1,
last_seen = EXCLUDED.last_seen
RETURNING id
""",
bank_id,
entity_text,
event_date,
event_date,
)
return entity_id
async def link_unit_to_entity(self, unit_id: str, entity_id: str):
"""
Link a memory unit to an entity.
Also updates co-occurrence cache with other entities in the same unit.
Args:
unit_id: Memory unit ID
entity_id: Entity ID
"""
async with acquire_with_retry(self.pool) as conn:
# Insert unit-entity link
await conn.execute(
f"""
INSERT INTO {fq_table("unit_entities")} (unit_id, entity_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
""",
unit_id,
entity_id,
)
# Update co-occurrence cache: find other entities in this unit
rows = await conn.fetch(
f"""
SELECT entity_id
FROM {fq_table("unit_entities")}
WHERE unit_id = $1 AND entity_id != $2
""",
unit_id,
entity_id,
)
other_entities = [row["entity_id"] for row in rows]
# Update co-occurrences for each pair
for other_entity_id in other_entities:
await self._update_cooccurrence(conn, entity_id, other_entity_id)
async def _update_cooccurrence(self, conn, entity_id_1: str, entity_id_2: str):
"""
Update the co-occurrence cache for two entities.
Uses CHECK constraint ordering (entity_id_1 < entity_id_2) to avoid duplicates.
Args:
conn: Database connection
entity_id_1: First entity ID
entity_id_2: Second entity ID
"""
# Ensure consistent ordering (smaller UUID first)
if entity_id_1 > entity_id_2:
entity_id_1, entity_id_2 = entity_id_2, entity_id_1
await conn.execute(
f"""
INSERT INTO {fq_table("entity_cooccurrences")} (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred)
VALUES ($1, $2, 1, NOW())
ON CONFLICT (entity_id_1, entity_id_2)
DO UPDATE SET
cooccurrence_count = {fq_table("entity_cooccurrences")}.cooccurrence_count + 1,
last_cooccurred = NOW()
""",
entity_id_1,
entity_id_2,
)
async def link_units_to_entities_batch(
self,
unit_entity_pairs: list[tuple[str, str]] | list[tuple[str, str, datetime | None]],
@@ -10,7 +10,7 @@ from datetime import datetime
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.memory_engine import BankLlmHealthInfo, Budget
from hindsight_api.engine.response_models import RecallResult, ReflectResult
from hindsight_api.engine.search.tags import TagsMatch
from hindsight_api.models import RequestContext
@@ -449,6 +449,7 @@ class MemoryEngineInterface(ABC):
bank_id: str,
*,
request_context: "RequestContext",
force_refresh: bool = False,
) -> dict[str, Any]:
"""
Get statistics about memory nodes and links for a bank.
@@ -456,10 +457,46 @@ class MemoryEngineInterface(ABC):
Args:
bank_id: The memory bank ID.
request_context: Request context for authentication.
force_refresh: Bypass the cached value and recompute (also refreshes
the cache for subsequent callers).
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).
"""
...
@abstractmethod
async def check_bank_llm(
self,
bank_id: str,
*,
request_context: "RequestContext",
) -> "BankLlmHealthInfo":
"""
Probe the LLM consolidation would use for this bank. Deliberate connectivity
test (one real minimal call); never returns the API key. See
MemoryEngine.check_bank_llm.
"""
...
@@ -6,9 +6,10 @@ enabling support for multiple LLM backends (OpenAI, Anthropic, Gemini, Codex, et
"""
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Any
from .response_models import LLMToolCallResult, TokenUsage
from .response_models import LLMToolCallResult
class LLMInterface(ABC):
@@ -69,6 +70,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 +85,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 +115,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 +145,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]],
@@ -205,3 +253,11 @@ class OutputTooLongError(Exception):
"""
pass
class ProviderRateLimitResetError(Exception):
"""Raised when an upstream provider says quota will reopen at a known time."""
def __init__(self, retry_at: datetime, message: str = "") -> None:
self.retry_at = retry_at
super().__init__(message)
@@ -35,8 +35,6 @@ from .db_utils import acquire_with_retry
logger = logging.getLogger(__name__)
_SWEEP_INTERVAL_SECONDS = 3600 # Run retention sweep every hour
# ── bank/operation attribution (carried across the async call chain) ──────────
@@ -78,6 +76,51 @@ _request_ctx: ContextVar[dict[str, Any] | None] = ContextVar("hindsight_llm_requ
_call_metadata_ctx: ContextVar[dict[str, Any] | None] = ContextVar("hindsight_llm_call_metadata_ctx", default=None)
@dataclass
class LLMResponseUsage:
"""Provider-reported token usage for the in-flight LLM call.
Stashed by provider implementations as soon as a response is received
*before* local JSON parsing / schema validation, which may still fail. The
wrapper reads it to attach real token counts to an error trace when the
provider call itself succeeded but the structured output couldn't be parsed
or validated (providers charge for those tokens regardless). See #2387.
"""
input_tokens: int = 0
output_tokens: int = 0
cached_tokens: int = 0
# Per-call provider usage, set by providers right after a response is received.
_response_usage_ctx: ContextVar[LLMResponseUsage | None] = ContextVar("hindsight_llm_response_usage_ctx", default=None)
def set_response_usage(usage: LLMResponseUsage | None) -> Token:
"""Bind provider-reported usage for the current call. Returns a reset token."""
return _response_usage_ctx.set(usage)
def stash_response_usage(usage: LLMResponseUsage | None) -> None:
"""Record provider-reported usage so an error trace can attach it later.
Called by provider implementations once a response (with usage) is in hand,
before parsing/validation that may raise. Overwrites any prior value from an
earlier retry attempt so the last attempt's usage wins.
"""
_response_usage_ctx.set(usage)
def reset_response_usage(token: Token) -> None:
"""Unwind a binding made by :func:`set_response_usage`."""
_response_usage_ctx.reset(token)
def current_response_usage() -> LLMResponseUsage | None:
"""Return the active call's provider-reported usage, or None."""
return _response_usage_ctx.get()
def set_trace_context(ctx: LLMTraceContext | None) -> Token:
"""Bind trace attribution to the current context. Returns a reset token."""
return _trace_ctx.set(ctx)
@@ -310,8 +353,8 @@ class LLMTraceRecorder:
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; an optional
retention sweep deletes rows older than ``retention_days``.
fire-and-forget and never surface errors into the calling path. Retention of
old rows is handled by the background :class:`MaintenanceLoop`.
"""
def __init__(
@@ -320,16 +363,13 @@ class LLMTraceRecorder:
schema_getter: Callable[[], str],
enabled: bool,
allowed_scopes: list[str],
retention_days: int = -1,
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._retention_days = retention_days
self._max_chars = max_chars
self._sweep_task: asyncio.Task | None = None
# 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
@@ -543,46 +583,3 @@ class LLMTraceRecorder:
)
except Exception as e:
logger.warning(f"LLM trace memory_id attach failed for trace={trace_id}: {e}")
# ── retention sweep ───────────────────────────────────────────────────────
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 llm trace 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:
while True:
await self._run_sweep()
await asyncio.sleep(_SWEEP_INTERVAL_SECONDS)
async def _run_sweep(self) -> None:
"""Delete trace rows older than retention_days. Concurrent-safe."""
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:
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"LLM trace retention sweep: {result}")
except Exception as e:
logger.warning(f"LLM trace retention sweep failed: {e}")
@@ -10,15 +10,10 @@ import re
import time
import uuid
from contextlib import AsyncExitStack
from pathlib import Path
from typing import Any
import httpx
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
from typing import TYPE_CHECKING, Any
# Vertex AI imports (conditional - for LLMProvider to pass credentials to GeminiLLM)
try:
import google.auth
from google.oauth2 import service_account
VERTEXAI_AVAILABLE = True
@@ -27,16 +22,14 @@ except ImportError:
from ..config import (
DEFAULT_LLM_MAX_CONCURRENT,
DEFAULT_LLM_TIMEOUT,
ENV_CONSOLIDATION_LLM_MAX_CONCURRENT,
ENV_LLM_GROQ_SERVICE_TIER,
ENV_LLM_MAX_CONCURRENT,
ENV_LLM_TIMEOUT,
ENV_REFLECT_LLM_MAX_CONCURRENT,
ENV_RETAIN_LLM_MAX_CONCURRENT,
)
from ..metrics import get_metrics_collector
from .response_models import TokenUsage
if TYPE_CHECKING:
from .response_models import LLMToolCallResult
# Seed applied to every Groq request for deterministic behavior.
DEFAULT_LLM_SEED = 4242
@@ -232,6 +225,7 @@ _PROVIDERS_WITHOUT_API_KEY = frozenset(
"litellm",
"litellmrouter",
"bedrock",
"nous",
}
)
@@ -249,13 +243,17 @@ def create_llm_provider(
reasoning_effort: str,
groq_service_tier: str | None = None,
openai_service_tier: str | None = None,
bedrock_service_tier: str | None = None,
extra_body: dict[str, Any] | None = None,
default_headers: dict[str, str] | None = None,
vertexai_project_id: str | None = None,
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,
gemini_service_tier: str | None = None,
timeout: float | None = None,
) -> Any: # Returns LLMInterface
"""
Factory function to create the appropriate LLM provider implementation.
@@ -268,18 +266,31 @@ 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.
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.
bedrock_service_tier: Bedrock service tier (for Bedrock provider) - None (default), "flex", "priority", or "reserved".
gemini_service_tier: Gemini service tier (for Gemini provider) - None (default) or "flex" (50% cheaper).
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 to provider SDK clients (used by operators
routing through proxies / request-tracing middleware). Wired into the Anthropic
provider (SDK ``default_headers``) and the LiteLLM-backed providers ``litellm``,
``litellmrouter`` and ``bedrock`` as the LiteLLM ``extra_headers`` completion
kwarg; other providers may opt in as needed.
vertexai_project_id: Vertex AI project ID (for VertexAI provider).
vertexai_region: Vertex AI region (for VertexAI provider).
vertexai_credentials: Vertex AI credentials object (for VertexAI provider).
timeout: Per-request LLM timeout in seconds (resolved by the caller from the
per-operation/global config). Threaded into the providers that honour a
configurable request timeout (LiteLLM, LiteLLM Router, OpenAI-compatible,
Nous). ``None`` lets each provider fall back to its own default
(``HINDSIGHT_API_LLM_TIMEOUT`` / ``DEFAULT_LLM_TIMEOUT`` for those four;
Anthropic and Gemini keep their provider-specific defaults).
Returns:
LLMInterface implementation for the specified provider.
"""
from .llm_interface import LLMInterface
from .providers import (
AnthropicLLM,
ClaudeCodeLLM,
@@ -295,6 +306,12 @@ def create_llm_provider(
)
provider_lower = provider.lower()
if provider_lower == "gemini":
from ..config import parse_gemini_service_tier
gemini_service_tier = parse_gemini_service_tier(gemini_service_tier)
else:
gemini_service_tier = None
if provider_lower == "openai-codex":
return CodexLLM(
@@ -343,6 +360,9 @@ def create_llm_provider(
vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials,
gemini_safety_settings=gemini_safety_settings,
gemini_service_tier=gemini_service_tier,
prompt_cache_enabled=prompt_cache_enabled,
extra_body=extra_body,
)
elif provider_lower == "anthropic":
@@ -353,6 +373,7 @@ def create_llm_provider(
model=model,
reasoning_effort=reasoning_effort,
default_headers=default_headers,
extra_body=extra_body,
)
elif provider_lower == "litellm":
@@ -362,6 +383,9 @@ def create_llm_provider(
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
default_headers=default_headers,
timeout=timeout,
)
elif provider_lower == "litellmrouter":
@@ -379,6 +403,9 @@ def create_llm_provider(
model=model,
config=litellmrouter_config,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
default_headers=default_headers,
timeout=timeout,
)
elif provider_lower == "bedrock":
@@ -390,6 +417,10 @@ def create_llm_provider(
base_url=base_url,
model=bedrock_model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
default_headers=default_headers,
bedrock_service_tier=bedrock_service_tier,
timeout=timeout,
)
elif provider_lower == "llamacpp":
@@ -423,6 +454,22 @@ def create_llm_provider(
extra_body=extra_body,
)
elif provider_lower == "nous":
# Nous Portal is OpenAI-compatible on the wire; NousLLM adds rotating
# inference:invoke JWT auth read natively from ~/.hermes/auth.json
# (no static api_key, no hermes_cli dependency — same shape as Codex).
from hindsight_api.engine.providers.nous_llm import NousLLM
return NousLLM(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
timeout=timeout,
)
elif provider_lower in (
"openai",
"groq",
@@ -433,8 +480,10 @@ def create_llm_provider(
"deepseek",
"volcano",
"openrouter",
"requesty",
"zai",
"opencode-go",
"atlas",
):
return OpenAICompatibleLLM(
provider=provider,
@@ -445,6 +494,7 @@ def create_llm_provider(
groq_service_tier=groq_service_tier,
openai_service_tier=openai_service_tier,
extra_body=extra_body,
timeout=timeout,
)
else:
@@ -467,10 +517,20 @@ class LLMProvider:
reasoning_effort: str = "low",
groq_service_tier: str | None = None,
openai_service_tier: str | None = None,
bedrock_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,
gemini_service_tier: str | None = None,
vertexai_project_id: str | None = None,
vertexai_region: str | None = None,
vertexai_service_account_key: str | None = None,
timeout: float | None = None,
max_retries: int | None = None,
initial_backoff: float | None = None,
max_backoff: float | None = None,
):
"""
Initialize LLM provider.
@@ -483,42 +543,74 @@ class LLMProvider:
reasoning_effort: Reasoning effort level for supported providers.
groq_service_tier: Groq service tier ("on_demand", "flex", "auto") - from config.
openai_service_tier: OpenAI service tier (None or "flex") - from config.
bedrock_service_tier: Bedrock service tier (None, "flex", "priority", "reserved") - from config.
gemini_service_tier: Gemini 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``)
when ``None``.
Used by operators routing through proxies / request-tracing middleware.
litellmrouter_config: Provider-specific config for ``provider="litellmrouter"``.
JSON object passed verbatim to ``litellm.Router(**config)`` see
https://docs.litellm.ai/docs/routing. Ignored unless ``provider == "litellmrouter"``.
When None and the provider is ``litellmrouter``, falls back to
``HindsightConfig.llm_litellmrouter_config``.
vertexai_project_id: Vertex AI project ID for ``provider="vertexai"`` (required for
that provider).
vertexai_region: Vertex AI region for ``provider="vertexai"`` (defaults to
``"us-central1"`` when ``None``).
vertexai_service_account_key: Path to a Vertex AI service-account key file for
``provider="vertexai"`` (uses ADC when ``None``).
timeout: Per-request LLM timeout in seconds. Resolved by the caller from the
per-operation/global config (``retain_llm_timeout`` falling back to
``llm_timeout``, etc.). ``None`` lets each provider apply its own default.
max_retries: Default retry-attempt budget for ``call`` / ``call_with_tools``
when the per-call argument is omitted. Resolved by the caller from the
per-operation/global config (``reflect_llm_max_retries`` falling back to
``llm_max_retries``, etc.). ``None`` keeps each method's own fallback.
initial_backoff: Default initial retry backoff (seconds), same resolution as
``max_retries``. ``None`` keeps each method's own fallback.
max_backoff: Default maximum retry backoff (seconds), same resolution as
``max_retries``. ``None`` keeps each method's own fallback.
This constructor uses every argument as passed and does not read global
``HindsightConfig``: resolving the server-level default for a ``None`` argument is the
caller's responsibility (see ``MemoryEngine``'s per-op builds, ``_member_to_llm``, and
``LLMProvider.from_env``). Keeping it config-free makes a provider's effective settings a
pure function of its arguments which is what lets each member of a multi-LLM chain be
configured independently.
"""
self.provider = provider.lower()
self.api_key = api_key
self.base_url = base_url
self.model = model
self.reasoning_effort = reasoning_effort
# Per-request timeout (seconds). Used verbatim — the caller resolves the
# per-operation/global fallback. ``None`` defers to the provider default.
self.timeout = timeout
# Default retry policy for call()/call_with_tools(). The caller resolves the
# per-operation/global fallback; ``None`` keeps each method's own fallback so
# providers built without a resolved config (from_env, tests) are unchanged.
self.max_retries = max_retries
self.initial_backoff = initial_backoff
self.max_backoff = max_backoff
self.litellmrouter_config = litellmrouter_config
# Service tiers from hierarchical config (not env vars)
self.groq_service_tier = groq_service_tier
self.openai_service_tier = openai_service_tier
self.bedrock_service_tier = bedrock_service_tier
self.gemini_service_tier = gemini_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).
# Same pattern as ``gemini_safety_settings``: explicit override wins; otherwise read
# the static server-level default from ``HindsightConfig`` via ``_get_raw_config()``.
# Used verbatim — callers resolve the global fallback (see _member_to_llm /
# the per-op builds in MemoryEngine, and LLMProvider.from_env).
self.default_headers = default_headers
if self.default_headers is None:
from ..config import _get_raw_config
try:
self.default_headers = _get_raw_config().llm_default_headers
except Exception:
pass # Config may not be initialized in test environments
# Validate provider
valid_providers = [
@@ -542,9 +634,12 @@ class LLMProvider:
"bedrock",
"volcano",
"openrouter",
"requesty",
"zai",
"opencode-go",
"atlas",
"fireworks",
"nous",
]
if self.provider not in valid_providers:
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
@@ -565,30 +660,31 @@ class LLMProvider:
self.base_url = "https://api.deepseek.com"
elif self.provider == "openrouter":
self.base_url = "https://openrouter.ai/api/v1"
elif self.provider == "requesty":
self.base_url = "https://router.requesty.ai/v1"
elif self.provider == "zai":
self.base_url = "https://api.z.ai/api/coding/paas/v4"
elif self.provider == "opencode-go":
self.base_url = "https://opencode.ai/zen/go/v1"
elif self.provider == "atlas":
self.base_url = "https://api.atlascloud.ai/v1"
elif self.provider == "nous":
self.base_url = "https://inference-api.nousresearch.com/v1"
# Prepare Vertex AI config (if applicable)
vertexai_project_id = None
vertexai_region = None
# Prepare Vertex AI config (if applicable). Values are used as passed; the
# caller resolves the global-config fallback (MemoryEngine builds /
# _member_to_llm / from_env). The region keeps a constant default here.
vertexai_credentials = None
if self.provider == "vertexai":
from ..config import get_config
config = get_config()
vertexai_project_id = config.llm_vertexai_project_id
if not vertexai_project_id:
raise ValueError(
"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID is required for Vertex AI provider. "
"Set it to your GCP project ID."
)
vertexai_region = config.llm_vertexai_region or "us-central1"
service_account_key = config.llm_vertexai_service_account_key
vertexai_region = vertexai_region or "us-central1"
service_account_key = vertexai_service_account_key
# Load explicit service account credentials if provided
if service_account_key:
@@ -612,30 +708,20 @@ class LLMProvider:
f"model={self.model}, auth={'service_account' if service_account_key else 'ADC'}"
)
# For Gemini/VertexAI providers: read safety settings from global config if not explicitly provided
# Use _get_raw_config() to bypass StaticConfigProxy (which blocks configurable fields),
# since LLMProvider initialization legitimately needs the server-level default.
if self.provider in ("gemini", "vertexai") and self.gemini_safety_settings is None:
from ..config import _get_raw_config
# Normalize the Gemini service tier (pure: maps/validates the passed value,
# no global config read). Non-Gemini providers never carry a tier. The
# server-level default is resolved by the caller, like the other fields.
if self.provider == "gemini":
from ..config import parse_gemini_service_tier
try:
raw_config = _get_raw_config()
self.gemini_safety_settings = raw_config.llm_gemini_safety_settings
except Exception:
pass # Config may not be initialized in test environments
self.gemini_service_tier = parse_gemini_service_tier(self.gemini_service_tier)
else:
self.gemini_service_tier = None
# 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
# ad-hoc constructions (e.g. ``LLMProvider.from_env()``) keep working.
# gemini_safety_settings / prompt_cache_enabled / litellmrouter_config are
# used as passed — the caller resolves the global-config fallback. Providers
# that don't support prompt caching ignore the flag.
router_config: dict[str, Any] | None = self.litellmrouter_config
if self.provider == "litellmrouter" and router_config is None:
from ..config import _get_raw_config
try:
router_config = _get_raw_config().llm_litellmrouter_config
except Exception:
router_config = None
# Create provider implementation using factory
self._provider_impl = create_llm_provider(
@@ -646,13 +732,17 @@ class LLMProvider:
reasoning_effort=self.reasoning_effort,
groq_service_tier=self.groq_service_tier,
openai_service_tier=self.openai_service_tier,
bedrock_service_tier=self.bedrock_service_tier,
gemini_service_tier=self.gemini_service_tier,
extra_body=self.extra_body,
default_headers=self.default_headers,
vertexai_project_id=vertexai_project_id,
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,
timeout=self.timeout,
)
# Backward compatibility: Keep mock provider properties
@@ -709,12 +799,13 @@ class LLMProvider:
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
max_retries: int | None = None,
initial_backoff: float | None = None,
max_backoff: float | None = None,
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.
@@ -725,11 +816,17 @@ class LLMProvider:
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
max_retries: Maximum retry attempts. ``None`` uses the provider's configured
default (per-operation/global ``llm_max_retries``), else 10.
initial_backoff: Initial backoff time in seconds. ``None`` uses the provider's
configured default (``llm_initial_backoff``), else 1.0.
max_backoff: Maximum backoff time in seconds. ``None`` uses the provider's
configured default (``llm_max_backoff``), else 60.0.
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:
@@ -749,13 +846,43 @@ class LLMProvider:
structured = "+structured" if response_format is not None else ""
set_stage(f"llm.{self.provider}.{scope}{structured}")
# Resolve the retry policy: explicit per-call arg wins, else the provider's
# configured per-operation/global default, else this method's own fallback.
max_retries = (
max_retries if max_retries is not None else (self.max_retries if self.max_retries is not None else 10)
)
initial_backoff = (
initial_backoff
if initial_backoff is not None
else (self.initial_backoff if self.initial_backoff is not None else 1.0)
)
max_backoff = (
max_backoff if max_backoff is not None else (self.max_backoff if self.max_backoff is not None else 60.0)
)
# 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
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
from .llm_trace import (
current_response_usage,
reset_request_context,
reset_response_usage,
set_request_context,
set_response_usage,
)
call_start = time.monotonic()
request_token = set_request_context(
@@ -766,11 +893,19 @@ class LLMProvider:
response_format=response_format,
)
)
# Cleared per call; the provider stashes real usage once a response is in
# hand so the error path below can attach it if parsing/validation fails.
usage_token = set_response_usage(None)
try:
async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
# 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(
@@ -785,16 +920,22 @@ class LLMProvider:
skip_validation=skip_validation,
strict_schema=strict_schema,
return_usage=return_usage,
**cache_kwarg,
)
except Exception as e:
# The provider call may have succeeded (and incurred token
# cost) before local parsing/validation raised; attach the
# provider-reported usage to the error trace when available.
usage = current_response_usage()
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,
input_tokens=usage.input_tokens if usage else 0,
output_tokens=usage.output_tokens if usage else 0,
cached_tokens=usage.cached_tokens if usage else 0,
duration=time.monotonic() - call_start,
error=e,
)
@@ -810,6 +951,7 @@ class LLMProvider:
self._mock_calls = self._provider_impl.get_mock_calls()
finally:
reset_request_context(request_token)
reset_response_usage(usage_token)
return result
@@ -820,10 +962,11 @@ class LLMProvider:
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
max_retries: int | None = None,
initial_backoff: float | None = None,
max_backoff: float | None = None,
tool_choice: str | dict[str, Any] = "auto",
cached_prefix: str | None = None,
) -> "LLMToolCallResult":
"""
Make an LLM API call with tool/function calling support.
@@ -834,9 +977,12 @@ class LLMProvider:
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
max_retries: Maximum retry attempts. ``None`` uses the provider's configured
default (per-operation/global ``llm_max_retries``), else 5.
initial_backoff: Initial backoff time in seconds. ``None`` uses the provider's
configured default (``llm_initial_backoff``), else 1.0.
max_backoff: Maximum backoff time in seconds. ``None`` uses the provider's
configured default (``llm_max_backoff``), else 30.0.
tool_choice: How to choose tools - "auto", "none", "required", or {"type": "function", "function": {"name": "..."}}
Returns:
@@ -846,9 +992,29 @@ class LLMProvider:
set_stage(f"llm.{self.provider}.{scope}+tools")
# Resolve the retry policy: explicit per-call arg wins, else the provider's
# configured per-operation/global default, else this method's own fallback.
max_retries = (
max_retries if max_retries is not None else (self.max_retries if self.max_retries is not None else 5)
)
initial_backoff = (
initial_backoff
if initial_backoff is not None
else (self.initial_backoff if self.initial_backoff is not None else 1.0)
)
max_backoff = (
max_backoff if max_backoff is not None else (self.max_backoff if self.max_backoff is not None else 30.0)
)
# 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
from .llm_trace import (
current_response_usage,
reset_request_context,
reset_response_usage,
set_request_context,
set_response_usage,
)
call_start = time.monotonic()
request_token = set_request_context(
@@ -859,11 +1025,18 @@ class LLMProvider:
tool_choice=tool_choice,
)
)
# Cleared per call; the provider stashes real usage once a response is in
# hand so the error path below can attach it if parsing/validation fails.
usage_token = set_response_usage(None)
try:
async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
# 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(
@@ -876,16 +1049,22 @@ class LLMProvider:
initial_backoff=initial_backoff,
max_backoff=max_backoff,
tool_choice=tool_choice,
**cache_kwarg,
)
except Exception as e:
# The provider call may have succeeded (and incurred token
# cost) before local parsing/validation raised; attach the
# provider-reported usage to the error trace when available.
usage = current_response_usage()
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,
input_tokens=usage.input_tokens if usage else 0,
output_tokens=usage.output_tokens if usage else 0,
cached_tokens=usage.cached_tokens if usage else 0,
duration=time.monotonic() - call_start,
error=e,
)
@@ -901,6 +1080,7 @@ class LLMProvider:
self._mock_calls = self._provider_impl.get_mock_calls()
finally:
reset_request_context(request_token)
reset_response_usage(usage_token)
return result
@@ -944,7 +1124,9 @@ class LLMProvider:
def _load_codex_auth(self) -> tuple[str, str]:
"""
Load OAuth credentials from ~/.codex/auth.json.
Load OAuth credentials from the Codex ``auth.json``.
Honors ``CODEX_HOME`` (falling back to ``~/.codex``).
Returns:
Tuple of (access_token, account_id).
@@ -953,7 +1135,9 @@ class LLMProvider:
FileNotFoundError: If auth file doesn't exist.
ValueError: If auth file is invalid.
"""
auth_file = Path.home() / ".codex" / "auth.json"
from .providers.codex_auth import default_codex_auth_file
auth_file = default_codex_auth_file()
if not auth_file.exists():
raise FileNotFoundError(
@@ -1055,17 +1239,38 @@ class LLMProvider:
@classmethod
def from_env(cls) -> "LLMProvider":
"""Create provider from environment variables using config.py constants."""
# Read every field straight from the environment. The constructor no longer
# resolves global-config fallbacks, so this factory must supply them — and it
# does so without building the full HindsightConfig, keeping from_env() a
# lightweight env-only loader (see test_llm_provider_from_env_keeps_lightweight_loader).
from ..config import (
DEFAULT_LLM_GROQ_SERVICE_TIER,
DEFAULT_LLM_OPENAI_SERVICE_TIER,
DEFAULT_LLM_PROMPT_CACHE_ENABLED,
DEFAULT_LLM_PROVIDER,
DEFAULT_LLM_REASONING_EFFORT,
DEFAULT_LLM_TIMEOUT,
ENV_LLM_API_KEY,
ENV_LLM_BASE_URL,
ENV_LLM_BEDROCK_SERVICE_TIER,
ENV_LLM_DEFAULT_HEADERS,
ENV_LLM_EXTRA_BODY,
ENV_LLM_GEMINI_SAFETY_SETTINGS,
ENV_LLM_GEMINI_SERVICE_TIER,
ENV_LLM_GROQ_SERVICE_TIER,
ENV_LLM_LITELLMROUTER_CONFIG,
ENV_LLM_MODEL,
ENV_LLM_OPENAI_SERVICE_TIER,
ENV_LLM_PROMPT_CACHE_ENABLED,
ENV_LLM_PROVIDER,
ENV_LLM_REASONING_EFFORT,
ENV_LLM_TIMEOUT,
ENV_LLM_VERTEXAI_PROJECT_ID,
ENV_LLM_VERTEXAI_REGION,
ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
_get_default_model_for_provider,
_parse_llm_router_config,
parse_gemini_service_tier,
)
provider = os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER)
@@ -1082,6 +1287,14 @@ class LLMProvider:
model = os.getenv(ENV_LLM_MODEL) or _get_default_model_for_provider(provider)
extra_body = json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null"))
default_headers = json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null"))
prompt_cache_enabled = os.getenv(
ENV_LLM_PROMPT_CACHE_ENABLED, str(DEFAULT_LLM_PROMPT_CACHE_ENABLED)
).lower() in (
"1",
"true",
"yes",
"on",
)
return cls(
provider=provider,
@@ -1091,6 +1304,21 @@ class LLMProvider:
reasoning_effort=os.getenv(ENV_LLM_REASONING_EFFORT, DEFAULT_LLM_REASONING_EFFORT),
extra_body=extra_body,
default_headers=default_headers,
groq_service_tier=os.getenv(ENV_LLM_GROQ_SERVICE_TIER, DEFAULT_LLM_GROQ_SERVICE_TIER),
openai_service_tier=os.getenv(ENV_LLM_OPENAI_SERVICE_TIER, DEFAULT_LLM_OPENAI_SERVICE_TIER),
bedrock_service_tier=os.getenv(ENV_LLM_BEDROCK_SERVICE_TIER) or None,
gemini_service_tier=(
parse_gemini_service_tier(os.getenv(ENV_LLM_GEMINI_SERVICE_TIER))
if provider.lower() == "gemini"
else None
),
gemini_safety_settings=json.loads(os.getenv(ENV_LLM_GEMINI_SAFETY_SETTINGS, "null")),
prompt_cache_enabled=prompt_cache_enabled,
litellmrouter_config=_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG),
vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or None,
vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION) or None,
vertexai_service_account_key=os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY) or None,
timeout=float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT))),
)
@@ -0,0 +1,347 @@
"""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.
- **Scheduled mental model refresh** (configurable check cadence, default 60s):
refresh mental models whose ``trigger.refresh_cron`` schedule is due, but only
when the model is stale (new memories in its scope since its last refresh), so
a scheduled tick never burns an LLM call to regenerate identical content. The
per-model schedule lives in the cron expression; this loop only decides when to
*check*.
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 collections.abc import Coroutine
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from ..config import HindsightConfig, get_config
from ..models import RequestContext
from .db_utils import acquire_with_retry
from .schema import _is_oracle, fq_table
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
mm_refresh_on = cfg.mental_model_refresh_tick_seconds > 0
return reconcile_on or audit_on or llm_on or mm_refresh_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_timed("retention", self._run_retention(cfg))
interval = cfg.consolidation_reconcile_interval_seconds
if interval > 0 and self._is_due("reconcile", interval):
await self._run_timed("consolidation reconcile", self._run_reconcile())
mm_interval = cfg.mental_model_refresh_tick_seconds
if mm_interval > 0 and self._is_due("mm_refresh", mm_interval):
await self._run_timed("scheduled mental model refresh", self._run_scheduled_mm_refresh())
async def _run_timed(self, name: str, coro: Coroutine[Any, Any, None]) -> None:
"""Run a maintenance job and emit one timing line for it.
Each job keeps its own summary log (counts of work done); this adds a
single, uniform line per run so the cost of every sweep is observable.
"""
start = time.monotonic()
try:
await coro
finally:
logger.info(f"Maintenance: {name} took {time.monotonic() - start:.3f}s")
# ── 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 "")
)
# ── scheduled mental model refresh ───────────────────────────────────────
async def _run_scheduled_mm_refresh(self) -> None:
"""Refresh mental models whose ``trigger.refresh_cron`` is due.
Discovery (the set of cron-scheduled models, minus any with an in-flight
refresh) is one cross-tenant round-trip via
``public.mental_models_with_cron()``. Cron *due-ness* is evaluated here in
Python a scheduled fire has elapsed when the most recent cron boundary at
or before now is later than ``last_refreshed_at`` because cron arithmetic
isn't expressible in plain SQL. Each due model is refreshed only when it is
actually stale, so a schedule that fires while nothing changed costs a
cheap staleness query, not an LLM call.
"""
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, mental_model_id, refresh_cron, last_refreshed_at "
"FROM public.mental_models_with_cron()"
)
except Exception as e:
logger.warning(f"Scheduled mental model refresh discovery failed: {e}")
return
if not rows:
return
from croniter import croniter
now = datetime.now(timezone.utc)
due = []
for row in rows:
cron = row["refresh_cron"]
last = row["last_refreshed_at"]
try:
prev_fire = croniter(cron, now).get_prev(datetime)
except (ValueError, KeyError) as e:
logger.warning(
f"Scheduled mental model refresh: skipping invalid cron {cron!r} for "
f"{row['schema_name']}/{row['mental_model_id']}: {e}"
)
continue
if last is None or prev_fire > last:
due.append(row)
if not due:
return
# Only enqueue into schemas the worker actually polls (tenant discovery),
# otherwise the op would never be claimed. The tenant_id (when provided)
# lets config resolution honor tenant-level overrides.
try:
tenants = await engine._tenant_extension.list_tenants()
except Exception as e:
logger.warning(f"Scheduled mental model refresh 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
skipped_fresh = 0
for row in due:
schema = row["schema_name"]
bank_id = row["bank_id"]
mm_id = row["mental_model_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)
# Skip if nothing in the model's scope changed since its last
# refresh — a scheduled refresh must not regenerate identical
# content. compute_mental_model_is_stale needs the model's tags +
# trigger, which the discovery routine doesn't return, so re-read
# the row under the bank's schema context.
async with acquire_with_retry(engine._backend, max_retries=1) as conn:
mm_row = await conn.fetchrow(
f"SELECT id, tags, trigger, last_refreshed_at FROM {fq_table('mental_models')} "
"WHERE bank_id = $1 AND id = $2",
bank_id,
mm_id,
)
if mm_row is None:
continue
is_stale = await engine.compute_mental_model_is_stale(conn, bank_id, mm_row)
if not is_stale:
skipped_fresh += 1
continue
await engine.submit_async_refresh_mental_model(
bank_id=bank_id, mental_model_id=mm_id, request_context=context
)
submitted += 1
except Exception as e:
logger.warning(f"Scheduled mental model refresh failed for {mm_id} in {schema}: {e}")
finally:
_current_schema.reset(token)
if submitted or skipped_unknown or skipped_fresh:
logger.info(
f"Scheduled mental model refresh: scheduled {submitted} model(s)"
+ (f", {skipped_fresh} up-to-date" if skipped_fresh else "")
+ (f", skipped {skipped_unknown} in unrecognized schema(s)" if skipped_unknown else "")
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,204 @@
"""Multi-LLM routing: failover and (weighted) round-robin across N providers.
``MultiLLMProvider`` wraps an ordered list of :class:`LLMProvider` members and a
:class:`~hindsight_api.config.LLMStrategyConfig`, exposing the same public surface
as a single ``LLMProvider`` so it drops into every existing call path (including
``with_config()`` / ``ConfiguredLLMProvider``).
Member 0 is the **primary** (the operation's unindexed/base LLM); members 1..N are
the indexed extras (``HINDSIGHT_API_<OP>LLM_<n>_*``). Each member keeps its own
internal retry budget, so we only advance to the next member after a member has
exhausted its retries and raised.
Strategies:
- ``failover``: try members in declared order ``[0..N]``.
- ``round-robin``: rotate the starting member per request (optionally weighted),
then fall through the remaining members on error.
Batch retain and any direct ``_provider_impl`` access operate on the **primary
member only** (via attribute passthrough) failover/round-robin apply to the
interactive ``call`` / ``call_with_tools`` paths.
"""
import logging
import threading
import uuid
from typing import TYPE_CHECKING, Any
from ..config import LLM_STRATEGY_FAILOVER, LLMStrategyConfig
from .llm_wrapper import LLMProvider, OutputTooLongError
if TYPE_CHECKING:
from .llm_wrapper import ConfiguredLLMProvider, LLMToolCallResult
logger = logging.getLogger(__name__)
def _should_failover(exc: BaseException) -> bool:
"""Whether ``exc`` from one member should trigger a try on the next member.
Generic ``Exception`` instances (network errors, provider 5xx, timeouts after
a member's own retries) fail over. ``OutputTooLongError`` is propagated — a
different provider won't fit an over-length output either. ``CancelledError``,
``KeyboardInterrupt`` and ``SystemExit`` are ``BaseException`` (not
``Exception``) and therefore propagate unchanged.
"""
if isinstance(exc, OutputTooLongError):
return False
return isinstance(exc, Exception)
class _WeightedRoundRobin:
"""Smooth weighted round-robin scheduler (nginx SWRR).
Produces a starting member index per request such that, over time, member
``i`` is chosen in proportion to ``weights[i]`` while keeping selections
interleaved rather than bursty. Uniform weights degrade to plain round-robin.
The tiny selection critical section is mutex-guarded so concurrent callers
don't corrupt the running totals (they may still interleave, which only
affects distribution, never correctness).
"""
def __init__(self, weights: list[int]) -> None:
self._weights = list(weights)
self._current = [0] * len(weights)
self._total = sum(weights)
self._lock = threading.Lock()
def next(self) -> int:
with self._lock:
best = 0
for i, w in enumerate(self._weights):
self._current[i] += w
if self._current[i] > self._current[best]:
best = i
self._current[best] -= self._total
return best
class MultiLLMProvider:
"""Route LLM calls across multiple members per a failover / round-robin strategy."""
def __init__(self, members: list[LLMProvider], strategy: LLMStrategyConfig) -> None:
if not members:
raise ValueError("MultiLLMProvider requires at least one member")
self._members = members
self._strategy = strategy
weights = strategy.weights or [1] * len(members)
if len(weights) != len(members):
raise ValueError(
f"LLM strategy 'weights' has {len(weights)} entries but the chain has "
f"{len(members)} members (primary + indexed); they must match."
)
self._scheduler = _WeightedRoundRobin(weights)
# ── routing ────────────────────────────────────────────────────────────────
def _member_order(self) -> list[int]:
"""Indices to try, in order, for one request."""
n = len(self._members)
if self._strategy.mode == LLM_STRATEGY_FAILOVER:
return list(range(n))
start = self._scheduler.next()
return [(start + i) % n for i in range(n)]
async def _dispatch(self, method_name: str, **kwargs: Any) -> Any:
last_exc: BaseException | None = None
order = self._member_order()
for position, idx in enumerate(order):
member = self._members[idx]
try:
return await getattr(member, method_name)(**kwargs)
except BaseException as e: # noqa: BLE001 - re-raised unless it should fail over
if not _should_failover(e):
raise
last_exc = e
remaining = len(order) - position - 1
logger.warning(
"LLM member %d (%s/%s) failed on %s: %s%s",
idx,
member.provider,
member.model,
method_name,
e,
f"; trying next member ({remaining} left)" if remaining else "; no members left",
)
# All members failed; surface the last error (loop ran at least once).
assert last_exc is not None
raise last_exc
async def call(self, messages: list[dict[str, Any]], **kwargs: Any) -> Any:
return await self._dispatch("call", messages=messages, **kwargs)
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
**kwargs: Any,
) -> "LLMToolCallResult":
return await self._dispatch("call_with_tools", messages=messages, tools=tools, **kwargs)
# ── lifecycle ────────────────────────────────────────────────────────────────
async def verify_connection(self) -> None:
"""Strictly verify the primary; soft-verify the rest (warn, don't fail).
A failover member being unreachable at startup must not block the server
it may come back before it's needed. The primary is the steady-state path,
so its failure is still surfaced (the caller already wraps this in a
warn-only try/except at startup).
"""
await self._members[0].verify_connection()
for member in self._members[1:]:
try:
await member.verify_connection()
except Exception as e: # noqa: BLE001 - soft verification
logger.warning(
"Failover LLM member %s/%s failed connection verification: %s. "
"It will be tried at request time if the primary fails.",
member.provider,
member.model,
e,
)
async def cleanup(self) -> None:
for member in self._members:
await member.cleanup()
def with_config(
self,
config: Any,
*,
bank_id: str | None = None,
operation: str | None = None,
metadata: dict[str, Any] | None = None,
) -> "ConfiguredLLMProvider":
"""Mirror ``LLMProvider.with_config`` so the strategy runs inside the
per-operation configured wrapper (gemini-safety + trace contextvars wrap
every member call)."""
from .llm_trace import LLMTraceContext
from .llm_wrapper import ConfiguredLLMProvider
trace_ctx = None
if bank_id is not None or operation is not None or metadata:
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)
# ── attribute passthrough ────────────────────────────────────────────────────
@property
def members(self) -> list[LLMProvider]:
return self._members
def __getattr__(self, name: str) -> Any:
# Anything not defined here (provider, model, api_key, base_url,
# _provider_impl, mock helpers, batch helpers, ...) delegates to the
# primary member so existing call sites keep working unchanged.
return getattr(object.__getattribute__(self, "_members")[0], name)
@@ -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."""
@@ -3,43 +3,138 @@
import asyncio
import logging
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from hindsight_api.config import DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT
from .base import FileParser
if TYPE_CHECKING:
from markitdown import StreamInfo
logger = logging.getLogger(__name__)
# Extensions whose markitdown converters decode the raw bytes as text. markitdown
# samples only the first chunk for charset detection, so a UTF-8 file with a long
# ASCII-only prefix is mis-detected as ASCII; the JSON/ipynb converter then crashes
# decoding the first multibyte byte. Passing an explicit UTF-8 hint when the bytes
# are valid UTF-8 sidesteps the faulty detection without affecting other encodings.
_TEXT_EXTENSIONS = {
".json",
".jsonl",
".ipynb",
".txt",
".text",
".md",
".markdown",
".csv",
".html",
".htm",
}
@dataclass(frozen=True)
class MarkitdownOcrOptions:
"""OpenAI-compatible OCR options passed through to MarkItDown."""
# Keep this typed as object so the OpenAI SDK import stays lazy for non-OCR users.
llm_client: object
llm_model: str
llm_prompt: str
class MarkitdownParser(FileParser):
"""
Markitdown file parser.
Uses Microsoft's markitdown library to convert various file formats
to markdown including PDF, Office docs, images (via OCR), audio, HTML.
to markdown including PDF, Office docs, images with optional OCR,
audio, HTML.
Supported formats:
- PDF (.pdf)
- Word (.docx, .doc)
- PowerPoint (.pptx, .ppt)
- Excel (.xlsx, .xls)
- Images (.jpg, .jpeg, .png) - with OCR
- Images (.jpg, .jpeg, .png) - optional OCR
- HTML (.html, .htm)
- Text (.txt, .md)
- Audio (.mp3, .wav) - with transcription
"""
def __init__(self):
def __init__(
self,
*,
ocr_enabled: bool = False,
ocr_api_key: str | None = None,
ocr_base_url: str | None = None,
ocr_model: str | None = None,
ocr_prompt: str | None = None,
):
"""Initialize markitdown parser."""
# Lazy import to avoid requiring markitdown for all users
try:
from markitdown import MarkItDown
self._markitdown = MarkItDown()
except ImportError as e:
raise ImportError(
"markitdown package is required for file parsing. Install with: pip install markitdown"
) from e
self._ocr_enabled = ocr_enabled
if ocr_enabled:
ocr_options = self._build_ocr_options(
api_key=ocr_api_key,
base_url=ocr_base_url,
model=ocr_model,
prompt=ocr_prompt,
)
self._markitdown = MarkItDown(
llm_client=ocr_options.llm_client,
llm_model=ocr_options.llm_model,
llm_prompt=ocr_options.llm_prompt,
)
else:
self._markitdown = MarkItDown()
def _build_ocr_options(
self,
*,
api_key: str | None,
base_url: str | None,
model: str | None,
prompt: str | None,
) -> MarkitdownOcrOptions:
"""Build MarkItDown options for OpenAI-compatible image OCR."""
if not model or not model.strip():
raise ValueError(
"Markitdown OCR is enabled but no model is configured. "
"Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL to an OpenAI-compatible OCR/vision model "
"with image-input support."
)
if not api_key:
raise ValueError(
"Markitdown OCR is enabled but no API key is configured. "
"Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY."
)
if not base_url or not base_url.strip():
raise ValueError(
"Markitdown OCR is enabled but no base URL is configured. "
"Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL to an OpenAI-compatible OCR/vision endpoint."
)
try:
from openai import OpenAI
except ImportError as e:
raise RuntimeError("openai package is required when Markitdown OCR is enabled.") from e
return MarkitdownOcrOptions(
llm_client=OpenAI(api_key=api_key, base_url=base_url.strip()),
llm_model=model.strip(),
llm_prompt=prompt or DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT,
)
async def convert(self, file_data: bytes, filename: str) -> str:
"""Parse file to markdown using markitdown."""
# markitdown is synchronous, so we run it in executor to avoid blocking
@@ -48,14 +143,22 @@ class MarkitdownParser(FileParser):
def _convert_sync(self, file_data: bytes, filename: str) -> str:
"""Synchronous parsing (runs in thread pool)."""
if self._is_image_file(filename) and not self._ocr_enabled:
raise RuntimeError(
"Image OCR is not enabled for the markitdown parser. "
"Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED=true and configure an OpenAI-compatible "
"OCR/vision endpoint with image-input support, or choose an OCR-capable parser."
)
# Write to temp file (markitdown requires file path)
with tempfile.NamedTemporaryFile(suffix=Path(filename).suffix, delete=False) as tmp:
tmp.write(file_data)
tmp_path = tmp.name
try:
# Parse using markitdown
result = self._markitdown.convert(tmp_path)
# Parse using markitdown, passing an explicit charset hint for text
# files to avoid markitdown's sample-based (and crash-prone) detection.
result = self._markitdown.convert(tmp_path, stream_info=self._utf8_stream_info(file_data, filename))
if not result or not result.text_content:
raise RuntimeError(f"No content extracted from '{filename}'")
@@ -73,6 +176,28 @@ class MarkitdownParser(FileParser):
except Exception:
pass
@staticmethod
def _utf8_stream_info(file_data: bytes, filename: str) -> "StreamInfo | None":
"""Return a UTF-8 charset hint for text files that decode cleanly as UTF-8.
Returns None for binary files or non-UTF-8 text so markitdown falls back
to its own detection.
"""
if Path(filename).suffix.lower() not in _TEXT_EXTENSIONS:
return None
try:
file_data.decode("utf-8")
except UnicodeDecodeError:
return None
from markitdown import StreamInfo
return StreamInfo(charset="utf-8")
@staticmethod
def _is_image_file(filename: str) -> bool:
"""Return whether the file type needs OCR to extract useful text."""
return Path(filename).suffix.lower() in {".jpg", ".jpeg", ".png"}
def supports(self, filename: str, content_type: str | None = None) -> bool:
"""Check if markitdown supports this file type."""
# Supported extensions (from markitdown docs)
@@ -85,7 +210,7 @@ class MarkitdownParser(FileParser):
".ppt",
".xlsx",
".xls",
# Images (with OCR)
# Images (optional OCR)
".jpg",
".jpeg",
".png",
@@ -14,13 +14,26 @@ import logging
import time
from typing import Any
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.llm_interface import LLMInterface
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
logger = logging.getLogger(__name__)
def _usage_from_anthropic_response(response: Any) -> LLMResponseUsage:
"""Extract input/output/cached token counts from an Anthropic usage block."""
usage = getattr(response, "usage", None)
if not usage:
return LLMResponseUsage()
return LLMResponseUsage(
input_tokens=usage.input_tokens or 0,
output_tokens=usage.output_tokens or 0,
cached_tokens=getattr(usage, "cache_read_input_tokens", 0) or 0,
)
class AnthropicLLM(LLMInterface):
"""
LLM provider using Anthropic's Claude models.
@@ -38,6 +51,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 +68,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 +79,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
@@ -128,7 +149,9 @@ class AnthropicLLM(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 Anthropic).
strict_schema: Route structured output through a forced tool_use tool for
native constrained decoding (issue #1002). When False, falls back to
schema-in-prompt + JSON parse.
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
Returns:
@@ -159,14 +182,21 @@ class AnthropicLLM(LLMInterface):
else:
anthropic_messages.append({"role": role, "content": content})
# Add JSON schema instruction if response_format is provided
# Structured output: prefer Anthropic-native constrained decoding via a single
# forced tool_use tool (strict_schema) over text-injecting the schema and
# parsing the reply. Native constrained decoding guarantees schema-valid JSON,
# eliminating the invalid-JSON retry storm (issue #1002). When strict_schema is
# off we keep the text-inject + json.loads fallback for backward compatibility.
schema = None
use_forced_tool = False
_tool_name = "structured_response"
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)}"
if system_prompt:
system_prompt += schema_msg
if strict_schema:
use_forced_tool = True
else:
system_prompt = schema_msg
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
system_prompt = (system_prompt + schema_msg) if system_prompt else schema_msg
# Prepare parameters
call_params: dict[str, Any] = {
@@ -178,45 +208,77 @@ class AnthropicLLM(LLMInterface):
if system_prompt:
call_params["system"] = system_prompt
if use_forced_tool:
# Single tool whose input_schema IS the response schema; force the model to
# emit it via tool_choice so the SDK does constrained decoding for us.
call_params["tools"] = [
{"name": _tool_name, "description": "Return the structured response.", "input_schema": schema}
]
call_params["tool_choice"] = {"type": "tool", "name": _tool_name}
if self._extra_body:
call_params["extra_body"] = self._extra_body
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._client.messages.create(**call_params)
# Stash usage before parse/validate, which may raise locally
# even though the provider charged for these tokens (#2387).
stash_response_usage(_usage_from_anthropic_response(response))
# Anthropic response content is a list of blocks
content = ""
for block in response.content:
if block.type == "text":
content += block.text
if response_format is not None:
# Models may wrap JSON in markdown code blocks
clean_content = content
if "```json" in content:
clean_content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
clean_content = content.split("```")[1].split("```")[0].strip()
try:
json_data = json.loads(clean_content)
except json.JSONDecodeError:
# Fallback to parsing raw content if markdown stripping failed
json_data = json.loads(content)
if skip_validation:
result = json_data
else:
result = response_format.model_validate(json_data)
if use_forced_tool:
# Forced tool_use → the validated args are already a dict; no parsing,
# no markdown-strip, no JSON-decode retry possible.
tool_input = None
for block in response.content:
if block.type == "tool_use" and block.name == _tool_name:
tool_input = block.input or {}
break
if tool_input is None:
# Model ignored the forced tool (rare, e.g. a gateway that drops
# tool_choice). Fall back to text parse so we don't hard-fail; the
# existing retry loop still covers genuine errors.
content = "".join(b.text for b in response.content if b.type == "text")
tool_input = json.loads(content)
content = json.dumps(tool_input)
result = tool_input if skip_validation else response_format.model_validate(tool_input)
else:
result = content
# Anthropic response content is a list of blocks
content = ""
for block in response.content:
if block.type == "text":
content += block.text
if response_format is not None:
# Models may wrap JSON in markdown code blocks
clean_content = content
if "```json" in content:
clean_content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
clean_content = content.split("```")[1].split("```")[0].strip()
try:
json_data = json.loads(clean_content)
except json.JSONDecodeError:
# Fallback to parsing raw content if markdown stripping failed
json_data = json.loads(content)
if skip_validation:
result = json_data
else:
result = response_format.model_validate(json_data)
else:
result = content
# Record metrics and log slow calls
duration = time.time() - start_time
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
response_usage = _usage_from_anthropic_response(response)
input_tokens = response_usage.input_tokens
output_tokens = response_usage.output_tokens
total_tokens = input_tokens + output_tokens
cached_tokens = getattr(response.usage, "cache_read_input_tokens", 0) or 0 if response.usage else 0
cached_tokens = response_usage.cached_tokens
# Record LLM metrics
metrics = get_metrics_collector()
@@ -397,10 +459,14 @@ 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:
response = await self._client.messages.create(**call_params)
stash_response_usage(_usage_from_anthropic_response(response))
# Extract content and tool calls
content_parts = []
@@ -15,7 +15,8 @@ from typing import Any
from pydantic import ValidationError
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.llm_interface import LLMInterface
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
@@ -118,12 +119,14 @@ class ClaudeCodeLLM(LLMInterface):
Raises:
RuntimeError: If the connection test fails.
"""
from ...config import get_config
try:
test_messages = [{"role": "user", "content": "test"}]
await self.call(
messages=test_messages,
max_completion_tokens=10,
temperature=0.0,
temperature=get_config().llm_temperature_verification,
scope="verification",
max_retries=0,
)
@@ -226,6 +229,16 @@ class ClaudeCodeLLM(LLMInterface):
if isinstance(block, TextBlock):
full_text += block.text
# The Claude Agent SDK doesn't report exact counts; stash the same
# char/4 estimate the success path traces so a later parse/validate
# failure records consistent (estimated) tokens, not zero (#2387).
stash_response_usage(
LLMResponseUsage(
input_tokens=sum(len(m.get("content", "")) for m in messages) // 4,
output_tokens=len(full_text) // 4,
)
)
# Handle structured output
if response_format is not None:
# Models may wrap JSON in markdown
@@ -60,6 +60,22 @@ _CODEX_TERMINAL_REFRESH_ERROR_CODES = frozenset(
)
def default_codex_auth_file() -> Path:
"""Return the path to Codex's ``auth.json``.
Honors the ``CODEX_HOME`` environment variable the same variable the
canonical ``@openai/codex`` CLI uses to relocate its config/credentials
directory and falls back to ``~/.codex`` when it is unset or empty.
Resolved lazily on each call (rather than cached at import time) so that
the environment is read at the point of use.
"""
codex_home = os.environ.get("CODEX_HOME")
if codex_home:
return Path(codex_home) / "auth.json"
return Path.home() / ".codex" / "auth.json"
class CodexRefreshExpiredError(RuntimeError):
"""Raised when the Codex refresh_token itself is no longer valid.
@@ -86,7 +102,7 @@ class CodexAuthManager:
The OAuth refresh token. May be ``None`` when the auth file omits it;
the provider still works as a one-shot loader in that case.
auth_file:
Path to ``~/.codex/auth.json``. Used for re-reading the refresh token
Path to the Codex ``auth.json``. Used for re-reading the refresh token
on demand and for atomic persistence of rotated credentials.
"""
@@ -115,7 +131,8 @@ class CodexAuthManager:
Parameters
----------
auth_file:
Defaults to ``~/.codex/auth.json``.
Defaults to ``$CODEX_HOME/auth.json`` (or ``~/.codex/auth.json``
when ``CODEX_HOME`` is unset).
Raises
------
@@ -126,7 +143,7 @@ class CodexAuthManager:
``auth_mode``.
"""
if auth_file is None:
auth_file = Path.home() / ".codex" / "auth.json"
auth_file = default_codex_auth_file()
if not auth_file.exists():
raise FileNotFoundError(f"Codex auth file not found: {auth_file}. Run 'codex auth login' to authenticate.")
@@ -2,8 +2,9 @@
OpenAI Codex LLM provider using ChatGPT Plus/Pro OAuth authentication.
This provider enables using ChatGPT Plus/Pro subscriptions for API calls
without separate OpenAI Platform API credits. It uses OAuth tokens from
~/.codex/auth.json and communicates with the ChatGPT backend API.
without separate OpenAI Platform API credits. It uses OAuth tokens from the
Codex ``auth.json`` (``$CODEX_HOME/auth.json``, or ``~/.codex/auth.json`` when
``CODEX_HOME`` is unset) and communicates with the ChatGPT backend API.
Tokens are refreshed automatically: the provider decodes the access_token
JWT's ``exp`` claim and proactively refreshes via
@@ -24,7 +25,8 @@ from typing import Any
import httpx
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.llm_interface import LLMInterface
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
@@ -35,6 +37,7 @@ from .codex_auth import (
_CODEX_TOKEN_REFRESH_SKEW_SECONDS,
CodexAuthManager,
CodexRefreshExpiredError,
default_codex_auth_file,
)
# Re-export for backward compatibility (tests import from this module).
@@ -55,14 +58,15 @@ class CodexLLM(LLMInterface):
"""
LLM provider using OpenAI Codex OAuth authentication.
Authenticates using ChatGPT Plus/Pro credentials stored in ~/.codex/auth.json
and makes API calls to chatgpt.com/backend-api/codex/responses.
Authenticates using ChatGPT Plus/Pro credentials stored in the Codex
``auth.json`` (honoring ``CODEX_HOME``, default ``~/.codex``) and makes API
calls to chatgpt.com/backend-api/codex/responses.
"""
def __init__(
self,
provider: str,
api_key: str, # Will be ignored, reads from ~/.codex/auth.json
api_key: str, # Will be ignored, reads from the Codex auth.json (CODEX_HOME or ~/.codex)
base_url: str,
model: str,
reasoning_effort: str = "low",
@@ -81,12 +85,14 @@ class CodexLLM(LLMInterface):
refresh_token = self._load_codex_refresh_token()
logger.info(f"Loaded Codex OAuth credentials for account: {account_id}")
except Exception as e:
auth_file = default_codex_auth_file()
raise RuntimeError(
f"Failed to load Codex OAuth credentials from ~/.codex/auth.json: {e}\n\n"
f"Failed to load Codex OAuth credentials from {auth_file}: {e}\n\n"
"To set up Codex authentication:\n"
"1. Install Codex CLI: npm install -g @openai/codex\n"
"2. Login: codex auth login\n"
"3. Verify: ls ~/.codex/auth.json\n\n"
f"3. Verify: ls {auth_file}\n\n"
"(Set CODEX_HOME to use a credentials directory other than ~/.codex.)\n\n"
"Or use a different provider (openai, anthropic, gemini) with API keys."
) from e
@@ -94,7 +100,7 @@ class CodexLLM(LLMInterface):
access_token=access_token,
account_id=account_id,
refresh_token=refresh_token,
auth_file=Path.home() / ".codex" / "auth.json",
auth_file=default_codex_auth_file(),
)
# Use ChatGPT backend API endpoint. Codex auth is tied to
@@ -156,7 +162,7 @@ class CodexLLM(LLMInterface):
def _load_codex_auth(self) -> tuple[str, str]:
"""
Load OAuth credentials from ~/.codex/auth.json.
Load OAuth credentials from the Codex ``auth.json`` (CODEX_HOME or ~/.codex).
Returns:
Tuple of (access_token, account_id).
@@ -165,7 +171,7 @@ class CodexLLM(LLMInterface):
FileNotFoundError: If auth file doesn't exist.
ValueError: If auth file is invalid.
"""
auth_file = Path.home() / ".codex" / "auth.json"
auth_file = default_codex_auth_file()
if not auth_file.exists():
raise FileNotFoundError(
@@ -197,9 +203,7 @@ class CodexLLM(LLMInterface):
pre- and post-``__init__`` because it does not depend on
``_auth_manager`` being constructed yet.
"""
auth_file = (
self._auth_manager._auth_file if hasattr(self, "_auth_manager") else Path.home() / ".codex" / "auth.json"
)
auth_file = self._auth_manager._auth_file if hasattr(self, "_auth_manager") else default_codex_auth_file()
return CodexAuthManager.load_refresh_token_from_file(auth_file)
@staticmethod
@@ -397,7 +401,6 @@ class CodexLLM(LLMInterface):
}
url = f"{self.base_url}/codex/responses"
last_exception = None
# Manual attempt tracking instead of ``for attempt in range(...)`` so
# that the reactive-refresh path can retry once without consuming a
@@ -412,6 +415,16 @@ class CodexLLM(LLMInterface):
# Parse SSE stream
content = await self._parse_sse_stream(response)
# Codex SSE carries no usage block; stash the same char/4 estimate
# the success path traces so a later parse/validate failure records
# consistent (estimated) token counts rather than zero (#2387).
stash_response_usage(
LLMResponseUsage(
input_tokens=sum(len(m.get("content", "")) for m in messages) // 4,
output_tokens=len(content) // 4,
)
)
# Handle structured output
if response_format is not None:
# Models may wrap JSON in markdown
@@ -428,7 +441,6 @@ class CodexLLM(LLMInterface):
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = e
attempt += 1
continue
raise
@@ -490,7 +502,6 @@ class CodexLLM(LLMInterface):
return result
except httpx.HTTPStatusError as e:
last_exception = e
status_code = e.response.status_code
# Auth error: try one OAuth refresh + retry before giving up.
@@ -549,7 +560,6 @@ class CodexLLM(LLMInterface):
raise
except httpx.RequestError as e:
last_exception = e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
logger.warning(f"Codex connection error (attempt {attempt + 1}/{max_retries + 1}): {e}")
@@ -564,10 +574,6 @@ class CodexLLM(LLMInterface):
logger.error(f"Unexpected Codex error: {type(e).__name__}: {e}")
raise
if last_exception:
raise last_exception
raise RuntimeError("Codex call failed after all retries")
async def _parse_sse_stream(self, response: httpx.Response) -> str:
"""
Parse Server-Sent Events (SSE) stream from Codex API.
@@ -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)
@@ -8,9 +8,9 @@ This provider supports both:
import asyncio
import base64
import io
import json
import logging
import os
import time
from contextvars import ContextVar
from typing import Any
@@ -19,7 +19,8 @@ from google import genai
from google.genai import errors as genai_errors
from google.genai import types as genai_types
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.llm_interface import LLMInterface
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
from hindsight_api.engine.llm_wrapper import parse_llm_json
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
@@ -35,7 +36,6 @@ _safety_settings_ctx: ContextVar[list | None] = ContextVar("gemini_safety_settin
# Vertex AI imports (optional)
try:
import google.auth
from google.oauth2 import service_account
VERTEXAI_AVAILABLE = True
@@ -43,6 +43,26 @@ except ImportError:
VERTEXAI_AVAILABLE = False
def _to_int(value: Any) -> int:
"""Coerce Gemini's optional/string completion counts to int, defaulting to 0."""
try:
return int(value)
except (ValueError, TypeError):
return 0
def _usage_from_gemini_response(response: Any) -> LLMResponseUsage:
"""Extract prompt/candidate/cached token counts from a Gemini usage_metadata block."""
usage = getattr(response, "usage_metadata", None)
if not usage:
return LLMResponseUsage()
return LLMResponseUsage(
input_tokens=usage.prompt_token_count or 0,
output_tokens=usage.candidates_token_count or 0,
cached_tokens=getattr(usage, "cached_content_token_count", 0) or 0,
)
class GeminiLLM(LLMInterface):
"""
LLM provider for Google Gemini and Vertex AI.
@@ -69,6 +89,23 @@ class GeminiLLM(LLMInterface):
# Safety settings: None means use Gemini's defaults
self._safety_settings: list | None = kwargs.get("gemini_safety_settings")
self._service_tier: str | None = kwargs.get("gemini_service_tier")
# 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)
@@ -83,6 +120,16 @@ class GeminiLLM(LLMInterface):
self._client = genai.Client(api_key=self.api_key)
logger.info(f"Gemini API: model={self.model}")
def _apply_service_tier(self, config_kwargs: dict[str, Any]) -> None:
if not self._service_tier:
return
http_options = dict(config_kwargs.get("http_options") or {})
extra_body = dict(http_options.get("extra_body") or {})
extra_body.setdefault("service_tier", self._service_tier)
http_options["extra_body"] = extra_body
config_kwargs["http_options"] = http_options
def _init_vertexai(self, **kwargs: Any) -> None:
"""Initialize Vertex AI client with project, region, and credentials."""
# Extract Vertex AI config from kwargs
@@ -168,6 +215,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 +230,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 +248,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,41 +271,60 @@ 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
if response_format is not None and hasattr(response_format, "model_json_schema"):
def _system_instruction_with_schema() -> str:
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)}"
if system_instruction:
system_instruction += schema_msg
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
schema_msg = (
f"\n\nYou must respond with valid JSON matching this schema:\n"
f"{json.dumps(schema, indent=2, ensure_ascii=False)}"
)
return (system_instruction + schema_msg) if system_instruction else schema_msg
# 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)
self._apply_service_tier(config_kwargs)
if use_cache:
config_kwargs["cached_content"] = cached_prefix
elif (
use_schema_prompt_fallback
and response_format is not None
and hasattr(response_format, "model_json_schema")
):
config_kwargs["system_instruction"] = _system_instruction_with_schema()
elif system_instruction:
config_kwargs["system_instruction"] = system_instruction
if response_format is not None and not use_schema_prompt_fallback:
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
use_schema_prompt_fallback = False
generation_config = _build_generation_config(cache_active)
last_exception = None
@@ -259,6 +340,9 @@ class GeminiLLM(LLMInterface):
),
timeout=90.0, # Safety net for network hangs; valid slow responses are <90s
)
# Stash usage before parse/validate, which may raise locally
# even though the provider charged for these tokens (#2387).
stash_response_usage(_usage_from_gemini_response(response))
content = response.text
@@ -288,15 +372,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_tokens = getattr(usage, "cached_content_token_count", 0) 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
@@ -309,6 +402,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
@@ -349,12 +444,26 @@ class GeminiLLM(LLMInterface):
output_tokens=output_tokens,
total_tokens=input_tokens + output_tokens,
cached_tokens=cached_tokens,
thoughts_tokens=thoughts_tokens,
)
return result, token_usage
return result
except json.JSONDecodeError as e:
last_exception = e
if (
attempt < max_retries
and response_format is not None
and hasattr(response_format, "model_json_schema")
and not cache_active
and not use_schema_prompt_fallback
):
logger.warning("Gemini returned invalid JSON, retrying with prompt-side schema guidance...")
cache_active = False
use_schema_prompt_fallback = True
generation_config = _build_generation_config(cache_active)
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
continue
if attempt < max_retries:
logger.warning("Gemini returned invalid JSON, retrying...")
backoff = min(initial_backoff * (2**attempt), max_backoff)
@@ -370,6 +479,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
@@ -403,6 +526,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.
@@ -417,27 +541,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
@@ -450,6 +586,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":
@@ -497,49 +637,64 @@ 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)
self._apply_service_tier(config_kwargs)
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):
@@ -554,6 +709,7 @@ class GeminiLLM(LLMInterface):
),
timeout=90.0, # Safety net for network hangs; valid slow responses are <90s
)
stash_response_usage(_usage_from_gemini_response(response))
# Extract content and tool calls
content = None
@@ -582,12 +738,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
@@ -600,6 +762,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
@@ -624,6 +788,7 @@ class GeminiLLM(LLMInterface):
finish_reason=finish_reason,
error=None,
tool_calls=tool_calls_dict,
cached_tokens=cached_input_tokens,
)
return LLMToolCallResult(
@@ -632,6 +797,8 @@ class GeminiLLM(LLMInterface):
finish_reason=finish_reason,
input_tokens=input_tokens,
output_tokens=output_tokens,
cached_tokens=cached_input_tokens,
thoughts_tokens=thoughts_tokens,
)
except genai_errors.APIError as e:
@@ -640,6 +807,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:
@@ -656,6 +835,330 @@ 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,
)
# ── Batch API (Gemini API only — not Vertex AI) ─────────────────────────
#
# Google's Gemini Batch API gives a flat 50% discount on input + output
# tokens with a 24h completion SLA (https://ai.google.dev/gemini-api/docs/batch-api).
# The retain orchestrator and ``fact_extraction`` consumer speak the
# OpenAI-batch interface contract, so these overrides translate that shape
# to/from Gemini's file-upload → ``batches.create`` → ``batches.get`` →
# download flow — nothing downstream changes (same pattern as FireworksLLM).
#
# Interface contract preserved (see fact_extraction.py result handling)::
# result["response"]["body"]["choices"][0]["message"]["content"]
async def supports_batch_api(self) -> bool:
"""True for the Gemini API; False for Vertex AI.
Only ``provider="gemini"`` is supported: it exposes the file-upload
Batch API used below. Vertex AI's batch path is GCS/BigQuery-backed (no
file-upload analogue), so it stays unsupported here the startup
validation then surfaces a clear error instead of silently falling back
to synchronous, full-price calls.
"""
return self.provider == "gemini"
async def submit_batch(
self,
requests: list[dict[str, Any]],
endpoint: str = "/v1/chat/completions",
completion_window: str = "24h",
) -> dict[str, Any]:
"""Submit a batch of (OpenAI-shaped) requests to the Gemini Batch API."""
if not await self.supports_batch_api():
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
# endpoint/completion_window are part of the shared LLMInterface batch
# contract (used by the OpenAI path) but have no analogue on Gemini: the
# request shape is fixed (generateContent) and the SLA is server-side.
# Kept for signature compatibility with the shared retain driver.
logger.info(f"Submitting Gemini batch with {len(requests)} requests")
jsonl = self._translate_requests(requests)
# Upload the JSONL as a Gemini file (mime_type must be "jsonl"; a
# BytesIO has no path for the SDK to infer it from).
file_obj = io.BytesIO(jsonl.encode("utf-8"))
uploaded = await self._client.aio.files.upload(
file=file_obj,
config=genai_types.UploadFileConfig(mime_type="jsonl", display_name="hindsight-batch-input"),
)
batch = await self._client.aio.batches.create(
model=self.model,
src=uploaded.name,
config=genai_types.CreateBatchJobConfig(display_name="hindsight-batch"),
)
logger.info(f"Gemini batch submitted: {batch.name}, state={self._state_name(batch.state)}")
return {
"batch_id": batch.name,
"status": self._normalize_state(batch.state),
"input_file_id": uploaded.name,
"request_count": len(requests),
}
async def get_batch_status(self, batch_id: str) -> dict[str, Any]:
"""Get the status of a Gemini batch job, in the shared status shape."""
if not await self.supports_batch_api():
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
batch = await self._client.aio.batches.get(name=batch_id)
stats = batch.completion_stats
successful = _to_int(getattr(stats, "successful_count", None)) if stats else 0
failed = _to_int(getattr(stats, "failed_count", None)) if stats else 0
incomplete = _to_int(getattr(stats, "incomplete_count", None)) if stats else 0
result: dict[str, Any] = {
"batch_id": batch.name,
"status": self._normalize_state(batch.state),
"request_counts": {
"total": successful + failed + incomplete,
"completed": successful,
"failed": failed,
},
}
if batch.dest and getattr(batch.dest, "file_name", None):
result["output_file_id"] = batch.dest.file_name
if batch.error:
result["errors"] = self._error_to_dict(batch.error)
return result
async def retrieve_batch_results(self, batch_id: str) -> list[dict[str, Any]]:
"""Download and normalize completed Gemini batch results."""
if not await self.supports_batch_api():
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
batch = await self._client.aio.batches.get(name=batch_id)
status = self._normalize_state(batch.state)
if status != "completed":
raise ValueError(f"Gemini batch {batch_id} is not completed yet (state: {self._state_name(batch.state)})")
dest = batch.dest
if not dest or not getattr(dest, "file_name", None):
raise ValueError(
f"Gemini batch {batch_id} completed but reported no output file "
f"(submit_batch always uses file mode, so this is unexpected)"
)
content = await self._client.aio.files.download(file=dest.file_name)
text = content.decode("utf-8") if isinstance(content, (bytes, bytearray)) else str(content)
# The output is a JSONL error file plus results merged into one stream;
# error lines carry an `error` so partial failures surface per key
# instead of vanishing (JOB_STATE_PARTIALLY_SUCCEEDED maps to completed).
results: list[dict[str, Any]] = []
for line in text.strip().split("\n"):
if line.strip():
results.append(self._normalize_output_line(json.loads(line)))
logger.info(f"Retrieved {len(results)} results for Gemini batch {batch_id}")
return results
# ----- pure translation/normalization helpers (unit-tested) ----------
@staticmethod
def _translate_requests(requests: list[dict[str, Any]]) -> str:
"""OpenAI batch requests -> Gemini batch input JSONL.
Each output line is ``{"key": <custom_id>, "request": <GenerateContentRequest>}``;
the model is supplied to ``batches.create`` so it is omitted per-line.
"""
lines = []
for req in requests:
gemini_request = GeminiLLM._openai_body_to_gemini_request(req.get("body") or {})
lines.append(json.dumps({"key": req.get("custom_id"), "request": gemini_request}, ensure_ascii=False))
return "\n".join(lines)
@staticmethod
def _openai_body_to_gemini_request(body: dict[str, Any]) -> dict[str, Any]:
"""OpenAI chat-completions body -> Gemini ``GenerateContentRequest`` JSON.
Mirrors the synchronous ``call`` path: system messages become
``systemInstruction``; a ``response_format`` json_schema forces JSON
output (``responseMimeType``), appends the schema as a textual hint, and
grammar-enforces via ``responseJsonSchema`` when ``strict`` is set.
"""
system_texts: list[str] = []
contents: list[dict[str, Any]] = []
for msg in body.get("messages") or []:
role = msg.get("role", "user")
text = msg.get("content", "") or ""
if role == "system":
system_texts.append(text)
elif role == "assistant":
contents.append({"role": "model", "parts": [{"text": text}]})
else:
contents.append({"role": "user", "parts": [{"text": text}]})
generation_config: dict[str, Any] = {}
if body.get("temperature") is not None:
generation_config["temperature"] = body["temperature"]
if body.get("max_completion_tokens") is not None:
generation_config["maxOutputTokens"] = body["max_completion_tokens"]
response_format = body.get("response_format")
if isinstance(response_format, dict) and response_format.get("type") == "json_schema":
json_schema = response_format.get("json_schema") or {}
schema = json_schema.get("schema")
generation_config["responseMimeType"] = "application/json"
if schema:
system_texts.append(
"You must respond with valid JSON matching this schema:\n" + json.dumps(schema, ensure_ascii=False)
)
if json_schema.get("strict"):
generation_config["responseJsonSchema"] = schema
request: dict[str, Any] = {"contents": contents}
if system_texts:
request["systemInstruction"] = {"parts": [{"text": "\n\n".join(system_texts)}]}
if generation_config:
request["generationConfig"] = generation_config
return request
@staticmethod
def _normalize_output_line(line: dict[str, Any]) -> dict[str, Any]:
"""Gemini batch output line -> OpenAI-batch-output shape.
Target: ``{"custom_id", "response": {"body": {"choices": [...], "usage": {...}}}, "error"}``
so the consumer's ``result["response"]["body"]["choices"][0]...`` works and
it can read ``body["usage"]`` for token accounting (the consumer reports
zero usage otherwise).
"""
custom_id = line.get("key") if line.get("key") is not None else line.get("custom_id")
error = line.get("error")
if error:
return {"custom_id": custom_id, "response": None, "error": error}
response = line.get("response") or {}
body: dict[str, Any] = {"choices": [{"message": {"content": GeminiLLM._extract_text_from_response(response)}}]}
usage = GeminiLLM._usage_from_response(response)
if usage is not None:
body["usage"] = usage
return {"custom_id": custom_id, "response": {"body": body}, "error": None}
@staticmethod
def _extract_text_from_response(response: dict[str, Any]) -> str:
"""Concatenate the text parts of a (JSON) GenerateContentResponse."""
candidates = response.get("candidates") or []
if not candidates:
return ""
content = candidates[0].get("content") or {}
parts = content.get("parts") or []
return "".join(p.get("text", "") for p in parts if isinstance(p, dict) and p.get("text"))
@staticmethod
def _usage_from_response(response: dict[str, Any]) -> dict[str, Any] | None:
"""Gemini ``usageMetadata`` -> OpenAI-shaped ``usage`` block, or None.
The batch consumer accumulates token usage from ``body["usage"]`` using
OpenAI key names, so translate here to keep the output contract uniform
across providers. Handles both the REST camelCase (downloaded JSONL) and
snake_case spellings defensively.
"""
meta = response.get("usageMetadata") or response.get("usage_metadata")
if not isinstance(meta, dict):
return None
prompt = meta.get("promptTokenCount") or meta.get("prompt_token_count") or 0
completion = meta.get("candidatesTokenCount") or meta.get("candidates_token_count") or 0
total = meta.get("totalTokenCount") or meta.get("total_token_count") or 0
return {"prompt_tokens": prompt, "completion_tokens": completion, "total_tokens": total}
@staticmethod
def _normalize_state(state: Any) -> str:
"""Gemini ``JobState`` -> the retain driver's status strings.
Unknown / in-flight states map to ``in_progress`` so the driver keeps
polling; ``PARTIALLY_SUCCEEDED`` maps to ``completed`` (per-line errors
surface the partial failures during retrieval).
"""
name = GeminiLLM._state_name(state).upper()
if name in ("JOB_STATE_SUCCEEDED", "JOB_STATE_PARTIALLY_SUCCEEDED"):
return "completed"
if name == "JOB_STATE_FAILED":
return "failed"
if name in ("JOB_STATE_CANCELLED", "JOB_STATE_CANCELLING"):
return "cancelled"
if name == "JOB_STATE_EXPIRED":
return "expired"
return "in_progress"
@staticmethod
def _state_name(state: Any) -> str:
"""Extract the bare ``JOB_STATE_*`` name from a JobState enum or string."""
if state is None:
return ""
name = getattr(state, "name", None)
if name:
return str(name)
text = str(state)
if "." in text:
text = text.rsplit(".", 1)[-1]
return text
@staticmethod
def _error_to_dict(error: Any) -> dict[str, Any]:
"""Coerce a Gemini JobError into a JSON-serializable dict for logging."""
if hasattr(error, "model_dump"):
try:
return error.model_dump(exclude_none=True)
except Exception:
pass
return {"message": str(error)}
async def cleanup(self) -> None:
"""Clean up resources (close connections, etc.)."""
# Gemini client doesn't require explicit cleanup
@@ -15,10 +15,15 @@ is handled automatically by LiteLLM.
import asyncio
import json
import logging
import os
import time
from typing import Any
from litellm.exceptions import Timeout as LiteLLMTimeout
from hindsight_api.config import DEFAULT_LLM_TIMEOUT, ENV_LLM_TIMEOUT
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
from hindsight_api.worker.stage import set_stage
@@ -26,6 +31,22 @@ from hindsight_api.worker.stage import set_stage
logger = logging.getLogger(__name__)
def _usage_from_litellm_response(response: Any) -> LLMResponseUsage:
"""Extract prompt/completion/cached token counts from a LiteLLM (OpenAI-shaped) usage block."""
usage = getattr(response, "usage", None)
if not usage:
return LLMResponseUsage()
cached_tokens = 0
details = getattr(usage, "prompt_tokens_details", None)
if details:
cached_tokens = getattr(details, "cached_tokens", 0) or 0
return LLMResponseUsage(
input_tokens=getattr(usage, "prompt_tokens", 0) or 0,
output_tokens=getattr(usage, "completion_tokens", 0) or 0,
cached_tokens=cached_tokens,
)
class LiteLLMLLM(LLMInterface):
"""
LLM provider using the LiteLLM SDK for universal model support.
@@ -47,12 +68,31 @@ class LiteLLMLLM(LLMInterface):
base_url: str,
model: str,
reasoning_effort: str = "low",
timeout: float = 300.0,
timeout: float | None = None,
extra_body: dict[str, Any] | None = None,
bedrock_service_tier: str | None = None,
default_headers: dict[str, Any] | None = None,
**kwargs: Any,
):
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
self.timeout = timeout
# ``None`` falls back to HINDSIGHT_API_LLM_TIMEOUT, then DEFAULT_LLM_TIMEOUT — never None,
# so the hard ``asyncio.wait_for`` backstop in ``call`` is always bounded.
self.timeout = timeout if timeout is not None else float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_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 {}
# Operator-configured default headers forwarded to litellm.acompletion as
# ``extra_headers`` (used by deployments routing through proxies / request-
# tracing middleware). Mirrors the Anthropic provider's default_headers
# wiring. Sourced from llm_default_headers (env: HINDSIGHT_API_LLM_DEFAULT_HEADERS).
# Copied so a caller-owned dict can't be mutated through us, and a fresh
# copy is handed to each call below to avoid cross-request contamination.
self._default_headers: dict[str, Any] = dict(default_headers or {})
self.bedrock_service_tier = bedrock_service_tier
try:
import litellm
@@ -68,12 +108,14 @@ class LiteLLMLLM(LLMInterface):
raise RuntimeError("LiteLLM SDK not installed. Run: uv add litellm or pip install litellm") from e
async def verify_connection(self) -> None:
from ...config import get_config
try:
test_messages = [{"role": "user", "content": "test"}]
await self.call(
messages=test_messages,
max_completion_tokens=50,
temperature=0.0,
temperature=get_config().llm_temperature_verification,
scope="verification",
max_retries=0,
)
@@ -107,6 +149,22 @@ 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)
# Forward operator-configured default headers as ``extra_headers`` so they
# reach the provider behind LiteLLM (proxies / request-tracing middleware).
# ``setdefault`` keeps any explicit per-call ``extra_headers`` authoritative;
# a per-call copy prevents LiteLLM/downstream from mutating the stored dict.
if self._default_headers:
kwargs.setdefault("extra_headers", dict(self._default_headers))
# Bedrock service tier: flex (50% cheaper), priority, or reserved
if self.model.startswith("bedrock/") and self.bedrock_service_tier is not None:
kwargs["service_tier"] = self.bedrock_service_tier
return kwargs
# ── per-model output-tokens cap (shared with Router subclass) ────────────
@@ -191,7 +249,14 @@ class LiteLLMLLM(LLMInterface):
if attempt > 0:
set_stage(f"llm.{self._stage_label}.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await self._acompletion(**call_kwargs)
response = await asyncio.wait_for(
self._acompletion(**call_kwargs),
timeout=self.timeout,
)
# Stash usage before the length check and parse/validate below,
# which may raise locally even though the provider charged for
# these tokens (#2387).
stash_response_usage(_usage_from_litellm_response(response))
content = response.choices[0].message.content or ""
finish_reason = response.choices[0].finish_reason
@@ -222,8 +287,9 @@ class LiteLLMLLM(LLMInterface):
result = content
# Extract usage
input_tokens = getattr(response.usage, "prompt_tokens", 0) or 0
output_tokens = getattr(response.usage, "completion_tokens", 0) or 0
response_usage = _usage_from_litellm_response(response)
input_tokens = response_usage.input_tokens
output_tokens = response_usage.output_tokens
total_tokens = input_tokens + output_tokens
# Record metrics
@@ -286,6 +352,25 @@ class LiteLLMLLM(LLMInterface):
logger.error(f"LiteLLM returned invalid JSON after {max_retries + 1} attempts")
raise
except (TimeoutError, asyncio.TimeoutError, LiteLLMTimeout) as e:
# litellm/httpx don't always honor their own ``timeout=`` (e.g. a connection held
# open with no token progress), so ``wait_for`` is the hard cap that cancels a hung
# call regardless — otherwise one straggler pins a worker slot and stalls its gather.
last_exception = e
exc_name = type(e).__name__
if attempt < max_retries:
logger.warning(
f"LiteLLM call exceeded timeout={self.timeout}s ({exc_name}, scope={scope}), retrying..."
)
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
logger.error(
f"LiteLLM call timed out after {self.timeout}s on {attempt + 1} attempts "
f"({exc_name}, scope={scope})"
)
raise
except Exception as e:
error_str = str(e).lower()
# Fast fail on auth errors
@@ -336,7 +421,18 @@ class LiteLLMLLM(LLMInterface):
if attempt > 0:
set_stage(f"llm.{self._stage_label}.tools.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await self._acompletion(**call_kwargs)
response = await asyncio.wait_for(
self._acompletion(**call_kwargs),
timeout=self.timeout,
)
# Stash usage before the tool-call argument parse below, which
# can raise json.JSONDecodeError locally even though the provider
# already billed for these tokens; without this the error trace
# records 0/0 tokens (#2387). Mirrors call() and the anthropic/
# gemini call_with_tools paths so the litellm tool path (and the
# LiteLLMRouterLLM subclass that inherits this method) completes
# the #2396 usage-on-error coverage.
stash_response_usage(_usage_from_litellm_response(response))
message = response.choices[0].message
content = message.content
@@ -406,6 +502,23 @@ class LiteLLMLLM(LLMInterface):
output_tokens=output_tokens,
)
except (TimeoutError, asyncio.TimeoutError, LiteLLMTimeout) as e:
# See ``call`` — hard cap so a hung completion cannot block
# forever and pin a worker slot / concurrency permit.
last_exception = e
exc_name = type(e).__name__
if attempt < max_retries:
logger.warning(
f"LiteLLM tool call exceeded timeout={self.timeout}s ({exc_name}, scope={scope}), retrying..."
)
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
continue
logger.error(
f"LiteLLM tool call timed out after {self.timeout}s on {attempt + 1} attempts "
f"({exc_name}, scope={scope})"
)
raise
except Exception as e:
error_str = str(e).lower()
if "401" in error_str or "403" in error_str or "unauthorized" in error_str:
@@ -67,7 +67,7 @@ class LiteLLMRouterLLM(LiteLLMLLM):
model: str,
config: dict[str, Any],
reasoning_effort: str = "low",
timeout: float = 300.0,
timeout: float | None = None,
**kwargs: Any,
):
super().__init__(
@@ -146,16 +146,28 @@ class LiteLLMRouterLLM(LiteLLMLLM):
kwargs["max_completion_tokens"] = self._cap_max_completion_tokens(max_completion_tokens)
if temperature is not None:
kwargs["temperature"] = temperature
# Forward operator-configured default headers as ``extra_headers`` so they
# reach the provider behind the Router (proxies / request-tracing middleware).
# This override deliberately omits api_key/base_url/extra_body (those live in
# the per-deployment Router config), but headers are a cross-cutting operator
# concern, so we inject them here too — mirroring the base provider.
# ``setdefault`` keeps any explicit per-call ``extra_headers`` authoritative;
# a per-call copy prevents LiteLLM/downstream from mutating the stored dict.
if self._default_headers:
kwargs.setdefault("extra_headers", dict(self._default_headers))
return kwargs
async def verify_connection(self) -> None:
from hindsight_api.engine.llm_interface import OutputTooLongError
from ...config import get_config
try:
await self.call(
messages=[{"role": "user", "content": "test"}],
max_completion_tokens=50,
temperature=0.0,
temperature=get_config().llm_temperature_verification,
scope="verification",
max_retries=0,
)
@@ -101,7 +101,7 @@ class MockLLM(LLMInterface):
messages: List of message dicts with 'role' and 'content'.
response_format: Optional Pydantic model for structured output.
max_completion_tokens: Not used in mock.
temperature: Not used in mock.
temperature: Recorded on the call record for test assertions.
scope: Scope identifier for tracking.
max_retries: Not used in mock.
initial_backoff: Not used in mock.
@@ -123,6 +123,9 @@ class MockLLM(LLMInterface):
if response_format and hasattr(response_format, "__name__")
else str(response_format),
"scope": scope,
# Record the temperature so tests can assert per-operation temperature
# wiring (None means the parameter was omitted from the call).
"temperature": temperature,
}
self._mock_calls.append(call_record)
logger.debug(f"Mock LLM call recorded: scope={scope}, model={self.model}")
@@ -162,6 +165,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."
@@ -202,7 +211,7 @@ class MockLLM(LLMInterface):
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Not used in mock.
temperature: Not used in mock.
temperature: Recorded on the call record for test assertions.
scope: Scope identifier for tracking.
max_retries: Not used in mock.
initial_backoff: Not used in mock.
@@ -219,6 +228,9 @@ class MockLLM(LLMInterface):
"messages": messages,
"tools": [t.get("function", {}).get("name") for t in tools],
"scope": scope,
# Record the temperature so tests can assert per-operation temperature
# wiring (None means the parameter was omitted from the call).
"temperature": temperature,
}
self._mock_calls.append(call_record)
@@ -0,0 +1,463 @@
"""
Native Nous Portal OAuth authentication manager.
The Nous Portal inference endpoint (https://inference-api.nousresearch.com/v1)
speaks the OpenAI-compatible wire format but authenticates with a short-lived,
inference-scoped JWT rather than a static API key. Hermes obtains that JWT once
via an interactive browser login (``hermes portal``) and persists the resulting
OAuth state ``access_token`` + ``refresh_token`` under ``providers.nous`` in
``~/.hermes/auth.json``.
This manager reads that file *directly* and refreshes the access token itself,
exactly mirroring ``codex_auth.py`` (read ``~/.codex/auth.json`` + native
refresh). It deliberately does **not** import the Hermes ``hermes_cli`` package:
that package is the interactive CLI, not a library Hindsight can depend on. The
refresh request shape is mirrored from Hermes' own resolver
(``POST {portal}/api/oauth/token`` with an ``x-nous-refresh-token`` header and a
``grant_type=refresh_token`` form body), so server-side changes affect both
clients identically. The inference bearer is the access token itself in
Hermes' state the ``agent_key`` field is literally ``= access_token``.
Single-use refresh tokens
-------------------------
Nous refresh tokens are single-use with server-side reuse-detection: if two
processes refresh with the same ``refresh_token``, or a rotated token is not
persisted back, the Portal revokes the whole session as a theft signal. Because
Hindsight shares ``~/.hermes/auth.json`` with a possibly-running Hermes agent,
every refresh here is performed while holding the **same cross-process advisory
lock Hermes uses** (``~/.hermes/auth.lock`` via ``fcntl.flock``) and re-reads the
latest ``refresh_token`` from disk under that lock before exchanging it. That is
the protocol Hermes follows too, so the two coordinate safely through the file.
Usage
-----
mgr = NousAuthManager.from_file()
token = mgr.ensure_fresh_token() # proactive; refreshes if near expiry
... # use token as Bearer
mgr.refresh_tokens(force=True) # reactive, on a 401
"""
from __future__ import annotations
import base64
import binascii
import contextlib
import json
import logging
import os
import tempfile
import threading
import time
from collections.abc import Iterator
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import httpx
try:
import fcntl
except ImportError: # pragma: no cover - Windows
fcntl = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants — mirrored from Hermes' canonical Nous resolver
# (hermes_cli/auth.py: DEFAULT_NOUS_* and _refresh_access_token). Endpoints and
# client id are overridable via the same env vars Hermes honours, so a staging
# Portal or a future change can be pointed at without a code change.
# ---------------------------------------------------------------------------
_NOUS_PORTAL_BASE_URL = (
os.environ.get("HERMES_PORTAL_BASE_URL")
or os.environ.get("NOUS_PORTAL_BASE_URL")
or "https://portal.nousresearch.com"
)
_NOUS_INFERENCE_BASE_URL = os.environ.get("NOUS_INFERENCE_BASE_URL") or "https://inference-api.nousresearch.com/v1"
_NOUS_CLIENT_ID = "hermes-cli"
# Proactively refresh this many seconds before the JWT ``exp`` claim — matches
# the 120s skew Hermes' own runtime resolver uses for Nous.
_NOUS_TOKEN_REFRESH_SKEW_SECONDS = 120
# OAuth error codes the Portal returns when the refresh_token itself is no
# longer usable. These are terminal — retrying will not succeed; the user must
# re-run ``hermes portal``.
_NOUS_TERMINAL_REFRESH_ERROR_CODES = frozenset(
{"invalid_grant", "invalid_token", "refresh_token_reused", "refresh_token_expired"}
)
_AUTH_LOCK_TIMEOUT_SECONDS = 20.0
def _default_auth_file() -> Path:
return Path.home() / ".hermes" / "auth.json"
class NousNotLoggedInError(RuntimeError):
"""Raised when ``~/.hermes/auth.json`` has no usable Nous OAuth state.
Remediation: run ``hermes portal`` to log in to Nous Portal.
"""
class NousRefreshExpiredError(RuntimeError):
"""Raised when the Nous refresh_token itself is permanently invalid.
The user must re-run ``hermes portal`` to obtain new credentials. Callers
should surface a clear remediation message and stop retrying.
"""
@contextlib.contextmanager
def _hermes_auth_lock(auth_file: Path, timeout_seconds: float = _AUTH_LOCK_TIMEOUT_SECONDS) -> Iterator[None]:
"""Cross-process advisory lock on the Hermes auth store.
Uses ``<auth_file>.lock`` (i.e. ``~/.hermes/auth.lock``) with
``fcntl.flock(LOCK_EX)`` the exact same lock file and primitive Hermes'
``_auth_store_lock`` takes so a refresh here is mutually exclusive with a
concurrently-running Hermes agent. Degrades to a no-op (with a debug log)
where ``fcntl`` is unavailable (Windows); the single-process in-memory lock
still serialises this process's own refreshes.
"""
if fcntl is None: # pragma: no cover - Windows
logger.debug("fcntl unavailable; Nous refresh proceeds without a cross-process lock.")
yield
return
lock_path = auth_file.with_suffix(".lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
with open(lock_path, "a+") as lock_file:
deadline = time.monotonic() + max(1.0, timeout_seconds)
while True:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except (BlockingIOError, OSError):
if time.monotonic() >= deadline:
raise TimeoutError("Timed out waiting for the Hermes auth store lock") from None
time.sleep(0.05)
try:
yield
finally:
with contextlib.suppress(OSError):
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
class NousAuthManager:
"""Sync Nous Portal OAuth credential manager.
Holds the access_token + refresh_token in memory and handles
proactive/reactive refresh. A ``threading.Lock`` gives single-flight
semantics within the process; the cross-process ``fcntl`` lock guards
against a concurrent Hermes agent (see module docstring).
"""
def __init__(
self,
access_token: str,
refresh_token: str | None,
auth_file: Path,
*,
portal_base_url: str = _NOUS_PORTAL_BASE_URL,
inference_base_url: str = _NOUS_INFERENCE_BASE_URL,
client_id: str = _NOUS_CLIENT_ID,
) -> None:
self.access_token = access_token
self.refresh_token = refresh_token
self._auth_file = auth_file
self._portal_base_url = portal_base_url.rstrip("/")
self._inference_base_url = inference_base_url.rstrip("/")
self._client_id = client_id
self._lock = threading.Lock()
self._http_client = httpx.Client(timeout=30.0)
# ------------------------------------------------------------------
# Construction
# ------------------------------------------------------------------
@classmethod
def from_file(cls, auth_file: Path | None = None) -> "NousAuthManager":
"""Build a manager from ``providers.nous`` in the Hermes auth store.
Raises
------
NousNotLoggedInError:
If the file is missing, unreadable, or has no Nous OAuth state with
an ``access_token``.
"""
if auth_file is None:
auth_file = _default_auth_file()
if not auth_file.exists():
raise NousNotLoggedInError(
f"Hermes auth file not found: {auth_file}. Run 'hermes portal' to log in to Nous Portal."
)
try:
with open(auth_file) as f:
data = json.load(f)
except (OSError, json.JSONDecodeError) as e:
raise NousNotLoggedInError(f"Could not read Hermes auth file {auth_file}: {type(e).__name__}") from e
state = cls._nous_state(data)
if not state:
raise NousNotLoggedInError(
"Hermes is not logged into Nous Portal (no providers.nous OAuth state). Run 'hermes portal'."
)
access_token = state.get("access_token")
if not isinstance(access_token, str) or not access_token:
raise NousNotLoggedInError("Nous OAuth state has no access_token. Re-authenticate with 'hermes portal'.")
return cls(
access_token=access_token,
refresh_token=state.get("refresh_token"),
auth_file=auth_file,
portal_base_url=cls._optional_url(state.get("portal_base_url")) or _NOUS_PORTAL_BASE_URL,
inference_base_url=cls._optional_url(state.get("inference_base_url")) or _NOUS_INFERENCE_BASE_URL,
client_id=str(state.get("client_id") or _NOUS_CLIENT_ID),
)
@staticmethod
def _nous_state(data: dict[str, Any]) -> dict[str, Any]:
"""Pull the ``providers.nous`` state dict out of a loaded auth store."""
providers = data.get("providers")
if not isinstance(providers, dict):
return {}
state = providers.get("nous")
return state if isinstance(state, dict) else {}
@staticmethod
def _optional_url(value: Any) -> str | None:
return value.rstrip("/") if isinstance(value, str) and value.strip() else None
@property
def base_url(self) -> str:
return self._inference_base_url
# ------------------------------------------------------------------
# Token state
# ------------------------------------------------------------------
@staticmethod
def load_refresh_token_from_file(auth_file: Path) -> str | None:
"""Read ``providers.nous.refresh_token`` from ``auth_file``.
Returns ``None`` when the file is unreadable or omits the field. Does
not raise the caller degrades to using the in-memory token.
"""
try:
with open(auth_file) as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return None
return NousAuthManager._nous_state(data).get("refresh_token")
@staticmethod
def _decode_jwt_exp_unixtime(token: str) -> int | None:
"""Return the JWT ``exp`` claim as a unix timestamp, or None on failure.
The signature is not verified the server is the source of truth on
acceptance. This only schedules proactive refresh.
"""
try:
parts = token.split(".")
if len(parts) < 2:
return None
payload_b64 = parts[1]
padding = "=" * (-len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding).decode("utf-8"))
exp = payload.get("exp")
return int(exp) if exp is not None else None
except (ValueError, TypeError, json.JSONDecodeError, binascii.Error):
return None
def _token_is_stale(self, skew_seconds: int = _NOUS_TOKEN_REFRESH_SKEW_SECONDS) -> bool:
"""True when the cached access_token is past expiry (with skew).
Returns False when expiry cannot be determined we'd rather use a
possibly-expired token and recover via the reactive 401 path than
refresh aggressively on every request when ``exp`` is unparseable.
"""
exp = self._decode_jwt_exp_unixtime(self.access_token)
if exp is None:
return False
return exp <= int(time.time()) + skew_seconds
# ------------------------------------------------------------------
# Persistence
# ------------------------------------------------------------------
def _persist_state_atomic(self, updated: dict[str, Any]) -> None:
"""Patch ``providers.nous`` in ``_auth_file`` and write atomically.
Re-reads the on-disk store first so fields written by Hermes (other
providers, the credential pool, rotated tokens) are never clobbered,
then patches only the Nous OAuth fields and ``os.replace``s into place
(atomic on POSIX within the same filesystem). Must be called while
holding :func:`_hermes_auth_lock`.
"""
try:
with open(self._auth_file) as f:
loaded = json.load(f)
current: dict[str, Any] = loaded if isinstance(loaded, dict) else {}
except (OSError, json.JSONDecodeError):
current = {}
providers = current.get("providers")
if not isinstance(providers, dict):
providers = {}
current["providers"] = providers
state = providers.get("nous")
if not isinstance(state, dict):
state = {}
providers["nous"] = state
state.update(updated)
# The inference bearer is the access token itself; keep agent_key in
# sync so Hermes' own resolver/status sees the rotation too.
state["agent_key"] = updated.get("access_token", state.get("access_token"))
current["updated_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
parent = self._auth_file.parent
parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=".auth.", suffix=".json.tmp", dir=str(parent))
try:
with os.fdopen(fd, "w") as f:
json.dump(current, f, indent=2)
f.flush()
os.fsync(f.fileno())
with contextlib.suppress(OSError):
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, self._auth_file)
except Exception:
with contextlib.suppress(OSError):
os.unlink(tmp_path)
raise
# ------------------------------------------------------------------
# Refresh
# ------------------------------------------------------------------
@staticmethod
def _extract_oauth_error_code(response: httpx.Response) -> str | None:
"""Pull the OAuth error code out of a 4xx refresh response, if present."""
try:
body = response.json()
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(body, dict):
return None
err = body.get("error")
if isinstance(err, str):
return err
if isinstance(err, dict) and isinstance(err.get("code"), str):
return err["code"]
code = body.get("error_code")
return code if isinstance(code, str) else None
def refresh_tokens(self, reason: str = "", *, force: bool = False) -> None:
"""Single-flight Nous OAuth token refresh.
Serialised through ``self._lock`` (in-process single-flight) and
:func:`_hermes_auth_lock` (cross-process, vs a running Hermes agent).
The latest ``refresh_token`` is re-read from disk under the lock before
the exchange single-use tokens make using a stale in-memory RT a
session-revoking mistake.
Raises
------
NousRefreshExpiredError:
On a terminal refresh error (expired/reused/invalid grant).
RuntimeError:
For other refresh failures (network, 5xx, missing refresh_token).
"""
token_before_lock = self.access_token
with self._lock:
if force:
if self.access_token != token_before_lock:
return # another caller already refreshed while we waited
elif not self._token_is_stale():
return
with _hermes_auth_lock(self._auth_file):
# Re-read the freshest refresh_token persisted by whoever rotated
# last (this process or Hermes). Using a stale RT is exactly what
# trips the Portal's single-use reuse-detection.
disk_rt = self.load_refresh_token_from_file(self._auth_file)
if disk_rt:
self.refresh_token = disk_rt
if not self.refresh_token:
raise RuntimeError(
"Nous access_token is expired but no refresh_token is available. "
"Run 'hermes portal' to re-authenticate."
)
log_reason = f" ({reason})" if reason else ""
logger.info(f"Refreshing Nous Portal access_token{log_reason}")
try:
response = self._http_client.post(
f"{self._portal_base_url}/api/oauth/token",
headers={"x-nous-refresh-token": self.refresh_token},
data={"grant_type": "refresh_token", "client_id": self._client_id},
timeout=30.0,
)
except httpx.RequestError as e:
raise RuntimeError(f"Nous OAuth refresh network error: {type(e).__name__}") from e
if response.status_code != 200:
code = self._extract_oauth_error_code(response)
if code in _NOUS_TERMINAL_REFRESH_ERROR_CODES or response.status_code in (400, 401):
raise NousRefreshExpiredError(
f"Nous refresh_token is no longer valid (status={response.status_code}, "
f"error={code or 'none'}). Run 'hermes portal' to re-authenticate."
)
raise RuntimeError(f"Nous OAuth refresh failed with HTTP {response.status_code}")
try:
body = response.json()
except (json.JSONDecodeError, ValueError) as e:
raise RuntimeError(f"Nous OAuth refresh returned non-JSON body: {e}") from e
new_access = body.get("access_token")
if not new_access:
raise RuntimeError("Nous OAuth refresh returned no access_token")
new_refresh = body.get("refresh_token") or self.refresh_token
# Update in-memory state first so waiters see fresh credentials
# even if the disk write fails.
self.access_token = new_access
self.refresh_token = new_refresh
persisted: dict[str, Any] = {"access_token": new_access, "refresh_token": new_refresh}
expires_in = body.get("expires_in")
if isinstance(expires_in, (int, float)):
persisted["expires_at"] = datetime.fromtimestamp(
time.time() + float(expires_in), tz=timezone.utc
).isoformat()
try:
self._persist_state_atomic(persisted)
except OSError as e:
logger.warning(
f"Nous refresh succeeded but persisting auth.json failed: {type(e).__name__}. "
"In-memory credentials are current; the on-disk rotated token was not saved."
)
logger.info("Nous Portal access_token refreshed successfully")
def ensure_fresh_token(self) -> str:
"""Refresh proactively if near/at expiry, then return the bearer token.
Cheap when fresh (a JWT exp decode + comparison).
"""
if self._token_is_stale():
self.refresh_tokens(reason="proactive (token near expiry)")
return self.access_token
def close(self) -> None:
"""Close the underlying HTTP client."""
self._http_client.close()
@@ -0,0 +1,167 @@
"""
Nous Portal LLM provider for Hindsight.
Thin wrapper over :class:`OpenAICompatibleLLM`. The Nous Portal speaks the
OpenAI chat-completions wire format, so all request/response handling is
inherited unchanged. The only thing Nous needs on top is a rotating,
inference-scoped JWT (there is no static API key in the Hermes login flow),
which :class:`NousAuthManager` reads from ``~/.hermes/auth.json`` and refreshes
natively the same pattern as the Codex provider, with no dependency on the
``hermes_cli`` package. See ``nous_auth.py`` for the auth mechanics.
Configure with::
llm_provider = "nous"
llm_base_url = "https://inference-api.nousresearch.com/v1" # or omit
llm_model = "deepseek/deepseek-v4-flash" # any Nous slug
No API key is set in config; the token comes from the shared Hermes auth store
after a one-time ``hermes portal`` login.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from openai import APIStatusError, AsyncOpenAI
from hindsight_api.engine.providers.nous_auth import (
NousAuthManager,
NousNotLoggedInError,
NousRefreshExpiredError,
)
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
logger = logging.getLogger(__name__)
__all__ = ["NousLLM", "NousAuthManager", "NousNotLoggedInError", "NousRefreshExpiredError"]
class NousLLM(OpenAICompatibleLLM):
"""OpenAI-compatible provider for the Nous Portal with rotating-JWT auth."""
def __init__(
self,
provider: str,
api_key: str, # Ignored — the token is read from ~/.hermes/auth.json
base_url: str,
model: str,
reasoning_effort: str = "low",
**kwargs: Any,
):
try:
self._auth = NousAuthManager.from_file()
except NousNotLoggedInError as e:
raise RuntimeError(
f"Failed to load Nous Portal credentials: {e}\n\n"
"To set up Nous authentication:\n"
"1. Install Hermes: https://hermes-agent.nousresearch.com\n"
"2. Log in to Nous Portal: hermes portal\n"
"3. Verify: hermes portal status\n\n"
"Or use a different provider (openai, anthropic, gemini) with an API key."
) from e
# Single-flight async refresh lock — concurrent coroutines racing toward
# an expired token produce one network refresh.
self._auth_lock = asyncio.Lock()
token = self._auth.access_token
resolved_base = base_url or self._auth.base_url
# Parent validates provider against a fixed list; present as "openai"
# (identical wire format) while retaining the true identity for logs.
super().__init__(
provider="openai",
api_key=token,
base_url=resolved_base,
model=model,
reasoning_effort=reasoning_effort,
**kwargs,
)
self._nous_provider_name = provider
logger.info(
"Nous LLM initialized: model=%s base_url=%s (rotating inference:invoke JWT)",
self.model,
self.base_url,
)
# ------------------------------------------------------------------
# Token lifecycle
# ------------------------------------------------------------------
def _rebuild_client(self) -> None:
"""Rebuild the OpenAI SDK client against the current token."""
self.api_key = self._auth.access_token
self._client = AsyncOpenAI(
api_key=self.api_key,
base_url=self.base_url,
max_retries=0,
timeout=self.timeout,
)
async def _ensure_fresh_token(self) -> None:
"""Proactively refresh if the JWT is near expiry; rebuild on change.
Cheap when fresh (a JWT exp decode). The blocking refresh (network +
cross-process file lock) is offloaded to a thread so the event loop is
never stalled.
"""
if not self._auth._token_is_stale():
return
await self._refresh(reason="proactive (token near expiry)", force=False)
async def _refresh(self, *, reason: str, force: bool) -> None:
token_before = self.api_key
async with self._auth_lock:
if force:
if self.api_key != token_before:
return # another coroutine already refreshed
elif not self._auth._token_is_stale():
return
await asyncio.to_thread(lambda: self._auth.refresh_tokens(reason, force=force))
if self._auth.access_token != self.api_key:
self._rebuild_client()
async def _with_auth_retry(self, fn: Any, label: str, *args: Any, **kwargs: Any) -> Any:
"""Run an OpenAI-compatible call, refreshing once on a 401.
The proactive refresh covers most expiries; a token can still be
rejected mid-flight if Hermes rotated it out from under us or the exp
claim was unparseable. One reactive refresh + retry is the safety net.
"""
await self._ensure_fresh_token()
try:
return await fn(*args, **kwargs)
except APIStatusError as e:
if getattr(e, "status_code", None) != 401:
raise
logger.warning("Nous 401 (%s) — forcing token refresh and retrying once.", label)
try:
await self._refresh(reason=f"reactive (HTTP 401 on {label})", force=True)
except NousRefreshExpiredError as refresh_err:
raise RuntimeError(
"Nous authentication failed and the refresh_token is no longer valid.\n"
"Run 'hermes portal' to re-authenticate."
) from refresh_err
return await fn(*args, **kwargs)
# ------------------------------------------------------------------
# Overrides
# ------------------------------------------------------------------
async def verify_connection(self) -> None:
await self._ensure_fresh_token()
return await super().verify_connection()
async def call(self, *args: Any, **kwargs: Any) -> Any:
return await self._with_auth_retry(super().call, "call", *args, **kwargs)
async def call_with_tools(self, *args: Any, **kwargs: Any) -> Any:
return await self._with_auth_retry(super().call_with_tools, "call_with_tools", *args, **kwargs)
async def cleanup(self) -> None:
self._auth.close()
parent_cleanup = getattr(super(), "cleanup", None)
if parent_cleanup is not None:
await parent_cleanup()
@@ -26,6 +26,8 @@ import logging
import os
import re
import time
from datetime import UTC, datetime, timedelta
from email.utils import parsedate_to_datetime
from typing import Any
from urllib.parse import parse_qs, urlparse, urlunparse
@@ -33,7 +35,9 @@ import httpx
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
from hindsight_api.config import DEFAULT_LLM_TIMEOUT, ENV_LLM_TIMEOUT
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.bank_attribution import apply_bank_attribution
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError, ProviderRateLimitResetError
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
from hindsight_api.worker.stage import set_stage
@@ -44,6 +48,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."""
@@ -72,6 +86,49 @@ def _strip_code_fences(content: str) -> str:
return content
# Reasoning/thinking tags emitted by extended-thinking models. Some providers
# (e.g. MiniMax-M3) leak the chain-of-thought wrapped in these tags into the
# response body instead of a separate reasoning_content field. Each entry is
# (open_tag, close_tag); the open tag also matches when the close tag is missing
# (truncated output) so a dangling block is removed to end-of-string.
_REASONING_TAG_PAIRS: tuple[tuple[str, str], ...] = (
("<think>", "</think>"),
("<thinking>", "</thinking>"),
("<thought>", "</thought>"),
("<reasoning>", "</reasoning>"),
("|startthink|", "|endthink|"),
)
def _strip_reasoning_tags(text: str) -> str:
"""Strip extended-thinking/reasoning blocks from an LLM response.
Removes the full set of tag styles emitted by reasoning models:
``<think>``, ``<thinking>``, ``<thought>``, ``<reasoning>`` and the
``|startthink|...|endthink|`` markers. Both the structured (JSON) path and
the free-form path must call this otherwise a non-structured response
(e.g. a mental-model markdown blob from MiniMax-M3) leaks the raw
``<think>...</think>`` verbatim into stored memories.
Handles two cases:
1. Closed blocks: ``<think>...</think>`` removed wherever they appear.
2. Unclosed blocks: a dangling ``<think>`` with no closing tag (model output
truncated mid-thought) is removed from the open tag to end-of-string.
Returns the input unchanged (modulo surrounding whitespace) when no tags are
present.
"""
if not text:
return text
for open_tag, close_tag in _REASONING_TAG_PAIRS:
open_re = re.escape(open_tag)
close_re = re.escape(close_tag)
# Closed blocks first, then any remaining unclosed (truncated) block.
text = re.sub(rf"{open_re}.*?{close_re}", "", text, flags=re.DOTALL)
text = re.sub(rf"{open_re}.*", "", text, flags=re.DOTALL)
return text.strip()
def _response_get(response: Any, key: str, default: Any = None) -> Any:
if isinstance(response, dict):
return response.get(key, default)
@@ -176,6 +233,21 @@ def _content_or_error(response: Any, *, provider: str, model: str, scope: str) -
return content, choice
def _usage_from_openai_response(response: Any) -> LLMResponseUsage:
"""Extract prompt/completion/cached token counts from an OpenAI-shaped usage block."""
usage = getattr(response, "usage", None)
input_tokens = (usage.prompt_tokens or 0) if usage else 0
output_tokens = (usage.completion_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
return LLMResponseUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
cached_tokens=cached_tokens,
)
def _ensure_json_word_in_user_message(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Some OpenAI-compatible gateways require 'json' in a user message for json_object mode."""
@@ -223,6 +295,122 @@ def _summarize_status_error(e: APIStatusError, body_max: int = 400) -> str:
return f"HTTP {e.status_code}: {body_str or '<no body>'}"
_RATE_LIMIT_RESET_AT_RE = re.compile(
r"\breset at\s+"
r"(?P<reset_at>\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\s*(?:Z|[+-]\d{2}:?\d{2}))?)",
re.IGNORECASE,
)
_RATE_LIMIT_WINDOW_RE = re.compile(
r"\b(?:for|in)\s+(?P<amount>\d+)\s*(?P<unit>second|minute|hour|day)s?\b",
re.IGNORECASE,
)
def _status_error_body_text(e: APIStatusError) -> str:
body: Any = getattr(e, "body", None)
if body is None:
try:
body = e.response.text
except Exception:
body = None
if isinstance(body, (dict, list)):
try:
return json.dumps(body, default=str, ensure_ascii=False)
except Exception:
return str(body)
return str(body or "").strip()
def _parse_retry_after_header(value: str | None, now: datetime) -> datetime | None:
if not value:
return None
raw = value.strip()
try:
seconds = float(raw)
except ValueError:
seconds = -1.0
if seconds >= 0:
return now + timedelta(seconds=seconds)
try:
parsed = parsedate_to_datetime(raw)
except (TypeError, ValueError, IndexError, OverflowError):
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
def _parse_reset_at_datetime(value: str) -> datetime | None:
raw = value.strip().replace(" ", "T")
if raw.endswith("Z"):
raw = f"{raw[:-1]}+00:00"
elif re.search(r"[+-]\d{4}$", raw):
raw = f"{raw[:-2]}:{raw[-2:]}"
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
if parsed.tzinfo is None:
# Some providers (z.ai included) return a wall-clock reset timestamp
# without a zone. Interpret it in the host's local zone so logs, status
# pages, and the queued next_retry_at describe the same operator-facing
# clock instead of silently shifting by UTC offset.
parsed = parsed.astimezone()
return parsed.astimezone(UTC)
def _rate_limit_retry_at(e: APIStatusError) -> datetime | None:
now = datetime.now(UTC)
response = getattr(e, "response", None)
headers = getattr(response, "headers", None)
if headers is not None:
retry_at = _parse_retry_after_header(headers.get("retry-after") or headers.get("Retry-After"), now)
if retry_at is not None and retry_at > now:
return retry_at
body_text = _status_error_body_text(e)
reset_match = _RATE_LIMIT_RESET_AT_RE.search(body_text)
if reset_match:
retry_at = _parse_reset_at_datetime(reset_match.group("reset_at"))
if retry_at is not None and retry_at > now:
return retry_at
window_match = _RATE_LIMIT_WINDOW_RE.search(body_text)
if not window_match:
return None
amount = int(window_match.group("amount"))
unit = window_match.group("unit").lower()
if unit == "second":
seconds = amount
elif unit == "minute":
seconds = amount * 60
elif unit == "hour":
seconds = amount * 3600
else:
seconds = amount * 86400
return now + timedelta(seconds=seconds)
def _raise_provider_quota_defer(
e: APIStatusError, *, provider: str, model: str, scope: str, max_backoff: float
) -> None:
if e.status_code != 429:
return
retry_at = _rate_limit_retry_at(e)
if retry_at is None:
return
if (retry_at - datetime.now(UTC)).total_seconds() <= max_backoff:
return
summary = _summarize_status_error(e)
raise ProviderRateLimitResetError(
retry_at=retry_at,
message=(
f"Provider quota exhausted ({provider}/{model}, scope={scope}); retry at {retry_at.isoformat()}: {summary}"
),
) from e
class OpenAICompatibleLLM(LLMInterface):
"""
LLM provider for OpenAI-compatible APIs.
@@ -258,7 +446,7 @@ class OpenAICompatibleLLM(LLMInterface):
base_url: Base URL for the API (uses defaults for groq/ollama/lmstudio if empty).
model: Model name.
reasoning_effort: Reasoning effort level for supported models ("low", "medium", "high").
timeout: Request timeout in seconds (uses env var or 300s default).
timeout: Request timeout in seconds (uses env var or 120s default).
groq_service_tier: Groq service tier ("on_demand", "flex", "auto").
extra_body: Extra body params merged into every API call.
**kwargs: Additional provider-specific parameters.
@@ -277,8 +465,10 @@ class OpenAICompatibleLLM(LLMInterface):
"deepseek",
"volcano",
"openrouter",
"requesty",
"zai",
"opencode-go",
"atlas",
"fireworks",
]
if self.provider not in valid_providers:
@@ -300,10 +490,14 @@ class OpenAICompatibleLLM(LLMInterface):
self.base_url = "https://api.deepseek.com"
elif self.provider == "openrouter":
self.base_url = "https://openrouter.ai/api/v1"
elif self.provider == "requesty":
self.base_url = "https://router.requesty.ai/v1"
elif self.provider == "zai":
self.base_url = "https://api.z.ai/api/coding/paas/v4"
elif self.provider == "opencode-go":
self.base_url = "https://opencode.ai/zen/go/v1"
elif self.provider == "atlas":
self.base_url = "https://api.atlascloud.ai/v1"
elif self.provider == "fireworks":
# OpenAI-compatible inference host (online path). The batch API
# lives on a separate control-plane host — see FireworksLLM.
@@ -322,8 +516,10 @@ class OpenAICompatibleLLM(LLMInterface):
"minimax",
"deepseek",
"openrouter",
"requesty",
"zai",
"opencode-go",
"atlas",
"ollama-cloud",
)
and not self.api_key
@@ -360,6 +556,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 +671,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:
@@ -568,6 +781,8 @@ class OpenAICompatibleLLM(LLMInterface):
call_params["messages"] = _ensure_json_word_in_user_message(call_params["messages"])
call_params["response_format"] = {"type": "json_object"}
apply_bank_attribution(call_params)
last_exception = None
for attempt in range(max_retries + 1):
@@ -579,6 +794,9 @@ class OpenAICompatibleLLM(LLMInterface):
try:
if response_format is not None:
response = await self._client.chat.completions.create(**call_params)
# Stash usage before parse/validate, which may raise locally
# even though the provider charged for these tokens (#2387).
stash_response_usage(_usage_from_openai_response(response))
content, first_choice = _content_or_error(
response,
@@ -587,15 +805,10 @@ class OpenAICompatibleLLM(LLMInterface):
scope=scope,
)
# Strip reasoning model thinking tags
# Strip reasoning model thinking tags (closed and unclosed).
# Supports: <think>, <thinking>, <thought>, <reasoning>, |startthink|/|endthink|
original_len = len(content)
content = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL)
content = re.sub(r"<thinking>.*?</thinking>", "", content, flags=re.DOTALL)
content = re.sub(r"<thought>.*?</thought>", "", content, flags=re.DOTALL)
content = re.sub(r"<reasoning>.*?</reasoning>", "", content, flags=re.DOTALL)
content = re.sub(r"\|startthink\|.*?\|endthink\|", "", content, flags=re.DOTALL)
content = content.strip()
content = _strip_reasoning_tags(content)
if len(content) < original_len:
logger.debug(f"Stripped {original_len - len(content)} chars of reasoning tokens")
@@ -637,6 +850,7 @@ class OpenAICompatibleLLM(LLMInterface):
result = response_format.model_validate(json_data)
else:
response = await self._client.chat.completions.create(**call_params)
stash_response_usage(_usage_from_openai_response(response))
result, first_choice = _content_or_error(
response,
provider=self.provider,
@@ -644,15 +858,33 @@ class OpenAICompatibleLLM(LLMInterface):
scope=scope,
)
# Free-form (non-structured) output also leaks reasoning tags:
# reasoning models like MiniMax-M3 wrap their chain-of-thought
# in <think>...</think> in the response body. Without this strip
# a mental-model markdown blob is stored verbatim with the raw
# thinking tags. Mirrors the structured-output path above.
result = _strip_reasoning_tags(result)
# Record token usage metrics
duration = time.time() - start_time
usage = response.usage
input_tokens = usage.prompt_tokens or 0 if usage else 0
output_tokens = usage.completion_tokens or 0 if usage else 0
response_usage = _usage_from_openai_response(response)
input_tokens = response_usage.input_tokens
output_tokens = response_usage.output_tokens
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
cached_tokens = response_usage.cached_tokens
thoughts_tokens = 0
if usage and getattr(usage, "completion_tokens_details", None):
thoughts_tokens = getattr(usage.completion_tokens_details, "reasoning_tokens", 0) or 0
# OpenAI-compatible providers fold reasoning tokens into
# ``completion_tokens`` (and thus ``total_tokens``), but the
# TokenUsage contract — and the Gemini provider — treat
# ``output_tokens``/``total_tokens`` as visible-only, surfacing
# reasoning separately in ``thoughts_tokens``. Subtract so the
# two fields don't double-count reasoning (cost over-attribution).
if thoughts_tokens:
output_tokens = max(0, output_tokens - thoughts_tokens)
total_tokens = max(0, total_tokens - thoughts_tokens)
# Record LLM metrics
metrics = get_metrics_collector()
@@ -701,6 +933,7 @@ class OpenAICompatibleLLM(LLMInterface):
output_tokens=output_tokens,
total_tokens=total_tokens,
cached_tokens=cached_tokens,
thoughts_tokens=thoughts_tokens,
)
return result, token_usage
return result
@@ -731,6 +964,10 @@ class OpenAICompatibleLLM(LLMInterface):
logger.error(f"Auth error (HTTP {e.status_code}), not retrying: {str(e)}")
raise
_raise_provider_quota_defer(
e, provider=self.provider, model=self.model, scope=scope, max_backoff=max_backoff
)
# Handle tool_use_failed error - model outputted in tool call format
if e.status_code == 400 and response_format is not None:
try:
@@ -784,7 +1021,6 @@ class OpenAICompatibleLLM(LLMInterface):
f"scope={scope}): {_summarize_status_error(e)}"
)
raise
except ProviderResponseError as e:
last_exception = e
if e.retryable and attempt < max_retries:
@@ -869,6 +1105,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
@@ -908,6 +1154,8 @@ class OpenAICompatibleLLM(LLMInterface):
if extra_body:
call_params["extra_body"] = extra_body
apply_bank_attribution(call_params)
last_exception = None
for attempt in range(max_retries + 1):
@@ -936,6 +1184,17 @@ class OpenAICompatibleLLM(LLMInterface):
usage = response.usage
input_tokens = usage.prompt_tokens or 0 if usage else 0
output_tokens = usage.completion_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
thoughts_tokens = 0
if usage and getattr(usage, "completion_tokens_details", None):
thoughts_tokens = getattr(usage.completion_tokens_details, "reasoning_tokens", 0) or 0
# See ``call()``: OpenAI-compatible ``completion_tokens`` includes
# reasoning, so make ``output_tokens`` visible-only to avoid
# double-counting it against ``thoughts_tokens``.
if thoughts_tokens:
output_tokens = max(0, output_tokens - thoughts_tokens)
metrics = get_metrics_collector()
metrics.record_llm_call(
@@ -978,6 +1237,8 @@ class OpenAICompatibleLLM(LLMInterface):
finish_reason=finish_reason,
input_tokens=input_tokens,
output_tokens=output_tokens,
cached_tokens=cached_tokens,
thoughts_tokens=thoughts_tokens,
)
except APIConnectionError as e:
@@ -1005,6 +1266,10 @@ class OpenAICompatibleLLM(LLMInterface):
f"not retrying: {_summarize_status_error(e)}"
)
raise
_raise_provider_quota_defer(
e, provider=self.provider, model=self.model, scope=scope, max_backoff=max_backoff
)
last_exception = e
if attempt < max_retries:
logger.warning(
@@ -1018,7 +1283,6 @@ class OpenAICompatibleLLM(LLMInterface):
f"({self.provider}/{self.model}, scope={scope}): {_summarize_status_error(e)}"
)
raise
except Exception:
raise
@@ -6,12 +6,17 @@ structured information like temporal constraints.
"""
import logging
import re
from abc import ABC, abstractmethod
from datetime import datetime, timedelta
from pydantic import BaseModel, Field
from hindsight_api.engine.temporal_periods import (
NO_TEMPORAL_CONSTRAINT,
extract_period,
is_embedded_cjk_dateparser_match,
)
logger = logging.getLogger(__name__)
@@ -123,9 +128,12 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
# Check for period expressions first (these need special handling)
query_lower = query.lower()
period_result = self._extract_period(query_lower, reference_date)
if period_result is not None:
return QueryAnalysis(temporal_constraint=period_result)
period_result = extract_period(query_lower, reference_date)
if period_result is NO_TEMPORAL_CONSTRAINT:
return QueryAnalysis(temporal_constraint=None)
if isinstance(period_result, tuple):
start_date, end_date = period_result
return QueryAnalysis(temporal_constraint=TemporalConstraint(start_date=start_date, end_date=end_date))
# Lazy load dateparser (only imports on first call, then cached)
self.load()
@@ -158,7 +166,12 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
# Filter out false positives (common words parsed as dates)
false_positives = {"do", "may", "march", "will", "can", "sat", "sun", "mon", "tue", "wed", "thu", "fri"}
valid_results = [(text, date) for text, date in results if text.lower() not in false_positives or len(text) > 3]
valid_results = [
(text, date)
for text, date in results
if (text.lower() not in false_positives or len(text) > 3)
and not is_embedded_cjk_dateparser_match(query, text)
]
if not valid_results:
return QueryAnalysis(temporal_constraint=None)
@@ -172,127 +185,6 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
return QueryAnalysis(temporal_constraint=TemporalConstraint(start_date=start_date, end_date=end_date))
def _extract_period(self, query: str, reference_date: datetime) -> TemporalConstraint | None:
"""
Extract period-based temporal expressions (week, month, year, weekend).
These need special handling as they represent date ranges, not single dates.
Supports multiple languages.
"""
def constraint(start: datetime, end: datetime) -> TemporalConstraint:
return TemporalConstraint(
start_date=start.replace(hour=0, minute=0, second=0, microsecond=0),
end_date=end.replace(hour=23, minute=59, second=59, microsecond=999999),
)
# Yesterday patterns (English, Spanish, Italian, French, German)
if re.search(r"\b(yesterday|ayer|ieri|hier|gestern)\b", query, re.IGNORECASE):
d = reference_date - timedelta(days=1)
return constraint(d, d)
# Today patterns
if re.search(r"\b(today|hoy|oggi|aujourd\'?hui|heute)\b", query, re.IGNORECASE):
return constraint(reference_date, reference_date)
# "a couple of days ago" / "a few days ago" patterns
# These are imprecise so we create a range
if re.search(r"\b(a\s+)?couple\s+(of\s+)?days?\s+ago\b", query, re.IGNORECASE):
# "a couple of days" = approximately 2 days, give range of 1-3 days
return constraint(reference_date - timedelta(days=3), reference_date - timedelta(days=1))
if re.search(r"\b(a\s+)?few\s+days?\s+ago\b", query, re.IGNORECASE):
# "a few days" = approximately 3-4 days, give range of 2-5 days
return constraint(reference_date - timedelta(days=5), reference_date - timedelta(days=2))
# "a couple of weeks ago" / "a few weeks ago" patterns
if re.search(r"\b(a\s+)?couple\s+(of\s+)?weeks?\s+ago\b", query, re.IGNORECASE):
# "a couple of weeks" = approximately 2 weeks, give range of 1-3 weeks
return constraint(reference_date - timedelta(weeks=3), reference_date - timedelta(weeks=1))
if re.search(r"\b(a\s+)?few\s+weeks?\s+ago\b", query, re.IGNORECASE):
# "a few weeks" = approximately 3-4 weeks, give range of 2-5 weeks
return constraint(reference_date - timedelta(weeks=5), reference_date - timedelta(weeks=2))
# "a couple of months ago" / "a few months ago" patterns
if re.search(r"\b(a\s+)?couple\s+(of\s+)?months?\s+ago\b", query, re.IGNORECASE):
# "a couple of months" = approximately 2 months, give range of 1-3 months
return constraint(reference_date - timedelta(days=90), reference_date - timedelta(days=30))
if re.search(r"\b(a\s+)?few\s+months?\s+ago\b", query, re.IGNORECASE):
# "a few months" = approximately 3-4 months, give range of 2-5 months
return constraint(reference_date - timedelta(days=150), reference_date - timedelta(days=60))
# Last week patterns (English, Spanish, Italian, French, German)
if re.search(
r"\b(last\s+week|la\s+semana\s+pasada|la\s+settimana\s+scorsa|la\s+semaine\s+derni[eè]re|letzte\s+woche)\b",
query,
re.IGNORECASE,
):
start = reference_date - timedelta(days=reference_date.weekday() + 7)
return constraint(start, start + timedelta(days=6))
# Last month patterns
if re.search(
r"\b(last\s+month|el\s+mes\s+pasado|il\s+mese\s+scorso|le\s+mois\s+dernier|letzten?\s+monat)\b",
query,
re.IGNORECASE,
):
first = reference_date.replace(day=1)
end = first - timedelta(days=1)
start = end.replace(day=1)
return constraint(start, end)
# Last year patterns
if re.search(
r"\b(last\s+year|el\s+a[ñn]o\s+pasado|l\'anno\s+scorso|l\'ann[ée]e\s+derni[eè]re|letztes?\s+jahr)\b",
query,
re.IGNORECASE,
):
year = reference_date.year - 1
return constraint(datetime(year, 1, 1), datetime(year, 12, 31))
# Last weekend patterns
if re.search(
r"\b(last\s+weekend|el\s+fin\s+de\s+semana\s+pasado|lo\s+scorso\s+fine\s+settimana|le\s+week-?end\s+dernier|letztes?\s+wochenende)\b",
query,
re.IGNORECASE,
):
days_since_sat = (reference_date.weekday() + 2) % 7
if days_since_sat == 0:
days_since_sat = 7
sat = reference_date - timedelta(days=days_since_sat)
return constraint(sat, sat + timedelta(days=1))
# Month + Year patterns (e.g., "June 2024", "junio 2024", "giugno 2024")
month_patterns = {
"january|enero|gennaio|janvier|januar": 1,
"february|febrero|febbraio|f[ée]vrier|februar": 2,
"march|marzo|mars|m[äa]rz": 3,
"april|abril|aprile|avril": 4,
"may|mayo|maggio|mai": 5,
"june|junio|giugno|juin|juni": 6,
"july|julio|luglio|juillet|juli": 7,
"august|agosto|ao[uû]t": 8,
"september|septiembre|settembre|septembre": 9,
"october|octubre|ottobre|octobre|oktober": 10,
"november|noviembre|novembre": 11,
"december|diciembre|dicembre|d[ée]cembre|dezember": 12,
}
for pattern, month_num in month_patterns.items():
match = re.search(rf"\b({pattern})\s+(\d{{4}})\b", query, re.IGNORECASE)
if match:
year = int(match.group(2))
start = datetime(year, month_num, 1)
if month_num == 12:
end = datetime(year, 12, 31)
else:
end = datetime(year, month_num + 1, 1) - timedelta(days=1)
return constraint(start, end)
return None
class TransformerQueryAnalyzer(QueryAnalyzer):
"""
@@ -14,7 +14,8 @@ import re
import time
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from .models import DirectiveInfo, LLMCall, ReflectAgentResult, TokenUsageSummary, ToolCall
from ...config import get_config
from .models import DirectiveInfo, LLMCall, ReflectAgentResult, StructuredOutputResult, TokenUsageSummary, ToolCall
from .prompts import (
_extract_directive_rules,
build_final_prompt,
@@ -89,12 +90,87 @@ _LEAKED_JSON_SUFFIX = re.compile(
r'\s*```(?:json)?\s*\{[^}]*(?:"(?:observation_ids|memory_ids|mental_model_ids)"|\})\s*```\s*$',
re.DOTALL | re.IGNORECASE,
)
_LEAKED_JSON_OBJECT = re.compile(
r'\s*\{[^{]*"(?:observation_ids|memory_ids|mental_model_ids|answer)"[^}]*\}\s*$', re.DOTALL
)
_TRAILING_IDS_PATTERN = re.compile(
r"\s*(?:observation_ids|memory_ids|mental_model_ids)\s*[=:]\s*\[.*?\]\s*$", re.DOTALL | re.IGNORECASE
)
_JSON_CODE_FENCE_PATTERN = re.compile(r"^\s*```(?:json)?\s*(\{.*\})\s*```\s*$", re.DOTALL | re.IGNORECASE)
_DONE_ARGUMENT_KEYS = frozenset(
{
"answer",
"directive_compliance",
"memory_ids",
"mental_model_ids",
"observation_ids",
"model_ids",
}
)
_DONE_ARGUMENT_MARKER_KEYS = _DONE_ARGUMENT_KEYS - {"answer"}
_LEAKED_JSON_ID_KEYS = frozenset({"memory_ids", "mental_model_ids", "observation_ids", "model_ids"})
def _unwrap_leaked_done_arguments(text: str) -> str | None:
"""Return the answer when a done tool call was rendered as JSON text.
Some providers leak the done tool's argument object instead of surfacing it
as a native tool call, e.g. {"answer": "...", "memory_ids": [...]}. Only
unwrap objects that match the done argument shape so normal JSON answers
stay intact.
"""
candidate = text.strip()
if not candidate:
return None
fenced = _JSON_CODE_FENCE_PATTERN.match(candidate)
if fenced:
candidate = fenced.group(1).strip()
try:
payload = json.loads(candidate)
except json.JSONDecodeError:
return None
if not isinstance(payload, dict):
return None
answer = payload.get("answer")
if not isinstance(answer, str) or not answer.strip():
return None
keys = set(payload)
if not keys.intersection(_DONE_ARGUMENT_MARKER_KEYS):
return None
if not keys.issubset(_DONE_ARGUMENT_KEYS):
return None
for key in ("memory_ids", "mental_model_ids", "observation_ids", "model_ids"):
value = payload.get(key)
if value is not None and not isinstance(value, list):
return None
return answer.strip()
def _strip_trailing_id_json_object(text: str) -> str:
stripped = text.rstrip()
if not stripped.endswith("}"):
return text.strip()
start = stripped.rfind("{")
if start < 0:
return text.strip()
try:
payload = json.loads(stripped[start:])
except json.JSONDecodeError:
return text.strip()
if not isinstance(payload, dict) or not payload:
return text.strip()
keys = set(payload)
if not keys.issubset(_LEAKED_JSON_ID_KEYS):
return text.strip()
return stripped[:start].strip()
def _clean_answer_text(text: str) -> str:
@@ -103,6 +179,10 @@ def _clean_answer_text(text: str) -> str:
Some LLMs output the done() call as text instead of a proper tool call.
This strips out patterns like: done({"answer": "...", ...})
"""
unwrapped = _unwrap_leaked_done_arguments(text)
if unwrapped is not None:
return unwrapped
# Remove done() call pattern from the end of the text
cleaned = _DONE_CALL_PATTERN.sub("", text).strip()
return cleaned if cleaned else text
@@ -121,13 +201,17 @@ def _clean_done_answer(text: str) -> str:
if not text:
return text
unwrapped = _unwrap_leaked_done_arguments(text)
if unwrapped is not None:
return unwrapped
cleaned = text
# Remove leaked JSON in code blocks at the end
cleaned = _LEAKED_JSON_SUFFIX.sub("", cleaned).strip()
# Remove leaked raw JSON objects at the end
cleaned = _LEAKED_JSON_OBJECT.sub("", cleaned).strip()
cleaned = _strip_trailing_id_json_object(cleaned)
# Remove trailing ID patterns
cleaned = _TRAILING_IDS_PATTERN.sub("", cleaned).strip()
@@ -140,7 +224,7 @@ async def _generate_structured_output(
response_schema: dict,
llm_config: "LLMProvider",
reflect_id: str,
) -> tuple[dict[str, Any] | None, int, int]:
) -> StructuredOutputResult:
"""Generate structured output from an answer using the provided JSON schema.
Args:
@@ -150,8 +234,8 @@ async def _generate_structured_output(
reflect_id: Reflect ID for logging
Returns:
Tuple of (structured_output, input_tokens, output_tokens).
structured_output is None if generation fails.
A StructuredOutputResult carrying the structured output (None if
generation fails) and the call's token usage.
"""
try:
from typing import Any as TypingAny
@@ -185,7 +269,7 @@ async def _generate_structured_output(
if not fields:
logger.warning(f"[REFLECT {reflect_id}] No fields found in response_schema, skipping structured output")
return None, 0, 0
return StructuredOutputResult()
DynamicModel = create_model("StructuredResponse", **fields)
@@ -238,6 +322,9 @@ OUTPUT:"""
],
response_format=DynamicModel,
scope="reflect_structured",
max_retries=1,
initial_backoff=0.25,
max_backoff=1.0,
skip_validation=True, # We'll handle the dict ourselves
return_usage=True,
)
@@ -258,11 +345,17 @@ OUTPUT:"""
logger.warning(f"[REFLECT {reflect_id}] Required field '{field_name}' is empty in structured output")
logger.info(f"[REFLECT {reflect_id}] Generated structured output with {len(structured_output)} fields")
return structured_output, usage.input_tokens, usage.output_tokens
return StructuredOutputResult(
structured_output=structured_output,
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
cached_tokens=usage.cached_tokens,
thoughts_tokens=usage.thoughts_tokens,
)
except Exception as e:
logger.warning(f"[REFLECT {reflect_id}] Failed to generate structured output: {e}")
return None, 0, 0
return StructuredOutputResult()
def _count_messages_tokens(messages: list[dict[str, Any]]) -> int:
@@ -302,6 +395,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,
@@ -322,6 +433,7 @@ async def run_reflect_agent(
budget: str | None = None,
max_context_tokens: int = 100_000,
llm_output_language: str | None = None,
cancel_check: Callable[[], None] | None = None,
) -> ReflectAgentResult:
"""
Execute the reflect agent loop using native tool calling.
@@ -358,12 +470,16 @@ async def run_reflect_agent(
# Extract directive rules for tool schema (if any)
directive_rules = _extract_directive_rules(directives) if directives else None
# Get tools for this agent (with directive compliance field if directives exist)
# Get tools for this agent (with directive compliance field if directives exist).
# The expand tool only reads back raw source text (chunks/documents), so it is
# useless and excluded when document text storage is disabled.
include_expand = get_config().store_document_text
tools = get_reflect_tools(
directive_rules=directive_rules,
include_mental_models=has_mental_models,
include_observations=include_observations,
include_recall=include_recall,
include_expand=include_expand,
)
# Build set of enabled tool names to guard against LLM hallucinating disabled tool calls
enabled_tools: frozenset[str] = frozenset(t["function"]["name"] for t in tools if t.get("type") == "function")
@@ -382,6 +498,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] = []
@@ -389,9 +527,14 @@ async def run_reflect_agent(
llm_trace: list[dict[str, Any]] = []
context_history: list[dict[str, Any]] = [] # For final prompt fallback
# Token usage tracking - accumulate across all LLM calls
# Token usage tracking - accumulate across all LLM calls.
# cached_tokens and thoughts_tokens are surfaced for cost attribution
# and prompt-cache tuning. Both are subsets of (or parallel to) the
# input/output counts and are NOT double-counted in total_tokens.
total_input_tokens = 0
total_output_tokens = 0
total_cached_tokens = 0
total_thoughts_tokens = 0
# Track available IDs for validation (prevents hallucinated citations)
available_memory_ids: set[str] = set()
@@ -414,6 +557,8 @@ async def run_reflect_agent(
input_tokens=total_input_tokens,
output_tokens=total_output_tokens,
total_tokens=total_input_tokens + total_output_tokens,
cached_tokens=total_cached_tokens,
thoughts_tokens=total_thoughts_tokens,
)
def _log_completion(answer: str, iterations: int, forced: bool = False):
@@ -442,7 +587,19 @@ 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):
# Cooperative cancellation checkpoint: abort the agent loop between
# iterations if the caller (e.g. an HTTP client) has gone away, rather
# than spending another LLM round-trip on a result nobody will read
# (issue #2122). Raises OperationCancelledError when fired.
if cancel_check is not None:
cancel_check()
is_last = iteration == max_iterations - 1
if is_last:
@@ -455,7 +612,9 @@ async def run_reflect_agent(
messages=[
{
"role": "system",
"content": build_final_system_prompt(bank_profile.get("mission"), llm_output_language),
"content": build_final_system_prompt(
bank_profile.get("mission"), llm_output_language, directives
),
},
{"role": "user", "content": prompt},
],
@@ -466,6 +625,8 @@ async def run_reflect_agent(
llm_duration = int((time.time() - llm_start) * 1000)
total_input_tokens += usage.input_tokens
total_output_tokens += usage.output_tokens
total_cached_tokens += getattr(usage, "cached_tokens", 0) or 0
total_thoughts_tokens += getattr(usage, "thoughts_tokens", 0) or 0
llm_trace.append(
{
"scope": "final",
@@ -479,11 +640,12 @@ async def run_reflect_agent(
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
total_input_tokens += struct_in
total_output_tokens += struct_out
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
structured_output = struct.structured_output
total_input_tokens += struct.input_tokens
total_output_tokens += struct.output_tokens
total_cached_tokens += struct.cached_tokens
total_thoughts_tokens += struct.thoughts_tokens
_log_completion(answer, iteration + 1, forced=True)
return ReflectAgentResult(
@@ -515,7 +677,9 @@ async def run_reflect_agent(
messages=[
{
"role": "system",
"content": build_final_system_prompt(bank_profile.get("mission"), llm_output_language),
"content": build_final_system_prompt(
bank_profile.get("mission"), llm_output_language, directives
),
},
{"role": "user", "content": prompt},
],
@@ -526,6 +690,8 @@ async def run_reflect_agent(
llm_duration = int((time.time() - llm_start) * 1000)
total_input_tokens += usage.input_tokens
total_output_tokens += usage.output_tokens
total_cached_tokens += getattr(usage, "cached_tokens", 0) or 0
total_thoughts_tokens += getattr(usage, "thoughts_tokens", 0) or 0
llm_trace.append(
{
"scope": "final",
@@ -538,11 +704,12 @@ async def run_reflect_agent(
structured_output = None
if response_schema and answer:
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
total_input_tokens += struct_in
total_output_tokens += struct_out
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
structured_output = struct.structured_output
total_input_tokens += struct.input_tokens
total_output_tokens += struct.output_tokens
total_cached_tokens += struct.cached_tokens
total_thoughts_tokens += struct.thoughts_tokens
_log_completion(answer, iteration + 1, forced=True)
return ReflectAgentResult(
@@ -570,22 +737,37 @@ 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
total_output_tokens += result.output_tokens
total_cached_tokens += getattr(result, "cached_tokens", 0) or 0
total_thoughts_tokens += getattr(result, "thoughts_tokens", 0) or 0
llm_trace.append(
{
"scope": f"agent_{iteration + 1}",
@@ -621,7 +803,9 @@ async def run_reflect_agent(
messages=[
{
"role": "system",
"content": build_final_system_prompt(bank_profile.get("mission"), llm_output_language),
"content": build_final_system_prompt(
bank_profile.get("mission"), llm_output_language, directives
),
},
{"role": "user", "content": prompt},
],
@@ -632,6 +816,8 @@ async def run_reflect_agent(
llm_duration = int((time.time() - llm_start) * 1000)
total_input_tokens += usage.input_tokens
total_output_tokens += usage.output_tokens
total_cached_tokens += getattr(usage, "cached_tokens", 0) or 0
total_thoughts_tokens += getattr(usage, "thoughts_tokens", 0) or 0
llm_trace.append(
{
"scope": "final",
@@ -645,11 +831,12 @@ async def run_reflect_agent(
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
total_input_tokens += struct_in
total_output_tokens += struct_out
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
structured_output = struct.structured_output
total_input_tokens += struct.input_tokens
total_output_tokens += struct.output_tokens
total_cached_tokens += struct.cached_tokens
total_thoughts_tokens += struct.thoughts_tokens
_log_completion(answer, iteration + 1, forced=True)
return ReflectAgentResult(
@@ -706,6 +893,8 @@ async def run_reflect_agent(
)
total_input_tokens += rewrite_usage.input_tokens
total_output_tokens += rewrite_usage.output_tokens
total_cached_tokens += getattr(rewrite_usage, "cached_tokens", 0) or 0
total_thoughts_tokens += getattr(rewrite_usage, "thoughts_tokens", 0) or 0
llm_trace.append(
{
"scope": "final_rewrite",
@@ -719,11 +908,12 @@ async def run_reflect_agent(
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
total_input_tokens += struct_in
total_output_tokens += struct_out
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
structured_output = struct.structured_output
total_input_tokens += struct.input_tokens
total_output_tokens += struct.output_tokens
total_cached_tokens += struct.cached_tokens
total_thoughts_tokens += struct.thoughts_tokens
_log_completion(answer, iteration + 1)
return ReflectAgentResult(
@@ -745,7 +935,9 @@ async def run_reflect_agent(
messages=[
{
"role": "system",
"content": build_final_system_prompt(bank_profile.get("mission"), llm_output_language),
"content": build_final_system_prompt(
bank_profile.get("mission"), llm_output_language, directives
),
},
{"role": "user", "content": prompt},
],
@@ -756,6 +948,8 @@ async def run_reflect_agent(
llm_duration = int((time.time() - llm_start) * 1000)
total_input_tokens += usage.input_tokens
total_output_tokens += usage.output_tokens
total_cached_tokens += getattr(usage, "cached_tokens", 0) or 0
total_thoughts_tokens += getattr(usage, "thoughts_tokens", 0) or 0
llm_trace.append(
{
"scope": "final",
@@ -769,11 +963,12 @@ async def run_reflect_agent(
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
total_input_tokens += struct_in
total_output_tokens += struct_out
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
structured_output = struct.structured_output
total_input_tokens += struct.input_tokens
total_output_tokens += struct.output_tokens
total_cached_tokens += struct.cached_tokens
total_thoughts_tokens += struct.thoughts_tokens
_log_completion(answer, iteration + 1, forced=True)
return ReflectAgentResult(
@@ -850,7 +1045,9 @@ async def run_reflect_agent(
hallucinated_tools = []
for tc in other_tools:
norm = _normalize_tool_name(tc.name)
if enabled_tools is not None and norm not in enabled_tools and norm not in ("done", "expand"):
# "done" is always available. "expand" is governed by enabled_tools
# (it is excluded when text storage is disabled), so it is not hardcoded here.
if enabled_tools is not None and norm not in enabled_tools and norm != "done":
hallucinated_tools.append(tc)
else:
allowed_tools.append(tc)
@@ -924,6 +1121,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"
@@ -1047,14 +1263,15 @@ async def _process_done_tool(
structured_output = None
final_usage = usage
if response_schema and llm_config and answer:
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
structured_output = struct.structured_output
# Add structured output tokens to usage
final_usage = TokenUsageSummary(
input_tokens=usage.input_tokens + struct_in,
output_tokens=usage.output_tokens + struct_out,
total_tokens=usage.total_tokens + struct_in + struct_out,
input_tokens=usage.input_tokens + struct.input_tokens,
output_tokens=usage.output_tokens + struct.output_tokens,
total_tokens=usage.total_tokens + struct.input_tokens + struct.output_tokens,
cached_tokens=usage.cached_tokens + struct.cached_tokens,
thoughts_tokens=usage.thoughts_tokens + struct.thoughts_tokens,
)
log_completion(answer, iterations)
@@ -1159,8 +1376,10 @@ async def _execute_tool(
# Normalize tool name for various LLM output formats
tool_name = _normalize_tool_name(tool_name)
# Guard against LLMs hallucinating calls to tools that were not provided
if enabled_tools is not None and tool_name not in enabled_tools and tool_name not in ("done", "expand"):
# Guard against LLMs hallucinating calls to tools that were not provided.
# "done" is always available; "expand" is governed by enabled_tools (excluded
# when text storage is disabled), so it is not hardcoded as always-allowed here.
if enabled_tools is not None and tool_name not in enabled_tools and tool_name != "done":
return {"error": f"Tool '{tool_name}' is not available. Use only the tools provided to you."}
if tool_name == "search_mental_models":
@@ -26,10 +26,13 @@ or stay the same per refresh, never get worse.
from __future__ import annotations
import json
import logging
from typing import Annotated, Any, Literal, Union
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
from hindsight_api.engine.llm_wrapper import parse_llm_json
from .structured_doc import (
Block,
@@ -144,6 +147,27 @@ Operation = Annotated[
Field(discriminator="op"),
]
_OPERATION_ADAPTER: TypeAdapter[Operation] = TypeAdapter(Operation)
def _validate_operations_list(raw_ops: Any) -> tuple[list[Operation], list[dict[str, Any]]]:
"""Validate each operation independently; drop invalid ops instead of failing the batch."""
if not isinstance(raw_ops, list):
raise TypeError(f"operations must be a list, got {type(raw_ops)!r}")
valid: list[Operation] = []
skipped: list[dict[str, Any]] = []
for i, item in enumerate(raw_ops):
try:
valid.append(_OPERATION_ADAPTER.validate_python(item))
except ValidationError as exc:
skipped.append({"index": i, "op": item, "error": exc.errors(include_url=False)})
logger.warning(
"[STRUCTURED_DELTA] skipping invalid operation at index %s: %s",
i,
exc.errors(include_url=False),
)
return valid, skipped
class DeltaOperationList(BaseModel):
"""Container for the operations produced by an LLM delta call."""
@@ -152,6 +176,104 @@ class DeltaOperationList(BaseModel):
operations: list[Operation] = Field(default_factory=list)
class DeltaAllOpsInvalidError(ValueError):
"""Raised when the model emitted operations but none survived validation.
Distinct from an empty ``operations`` array (a legitimate no-op): here every
op was malformed, so returning zero valid ops would make the caller apply
nothing and silently drop this refresh's new facts. Raising instead lets the
caller fall back to a full rewrite, which still integrates the new facts.
"""
def _finalize_operations(valid: list[Operation], skipped: list[dict[str, Any]]) -> DeltaOperationList:
"""Build the result, but refuse a wholesale validation failure as a silent no-op."""
if skipped and not valid:
raise DeltaAllOpsInvalidError(f"all {len(skipped)} delta operation(s) failed validation")
return DeltaOperationList(operations=valid)
def _extract_balanced_json_object(text: str) -> str | None:
"""Return the first top-level ``{...}`` slice, ignoring trailing junk."""
start = text.find("{")
if start < 0:
return None
depth = 0
in_string = False
escape = False
for i in range(start, len(text)):
ch = text[i]
if in_string:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return text[start : i + 1]
return None
def parse_delta_operation_list(raw: Any) -> DeltaOperationList:
"""Parse structured-delta LLM output into a validated operation list."""
if isinstance(raw, DeltaOperationList):
return raw
if isinstance(raw, dict):
ops_raw = raw.get("operations", [])
valid, skipped = _validate_operations_list(ops_raw)
if skipped:
logger.info(
"[STRUCTURED_DELTA] parsed %s op(s), skipped %s invalid op(s) from dict payload",
len(valid),
len(skipped),
)
return _finalize_operations(valid, skipped)
text = (raw or "").strip()
if not text:
return DeltaOperationList()
candidates: list[str] = [text]
extracted = _extract_balanced_json_object(text)
if extracted and extracted != text:
candidates.append(extracted)
last_error: Exception | None = None
for candidate in candidates:
try:
payload = parse_llm_json(candidate)
except json.JSONDecodeError as exc:
last_error = exc
continue
if not isinstance(payload, dict) or "operations" not in payload:
last_error = ValueError("delta payload must be an object with an operations array")
continue
try:
valid, skipped = _validate_operations_list(payload["operations"])
except TypeError as exc:
last_error = exc
continue
if skipped:
logger.info(
"[STRUCTURED_DELTA] parsed %s op(s), skipped %s invalid op(s)",
len(valid),
len(skipped),
)
return _finalize_operations(valid, skipped)
if last_error is not None:
raise last_error
return DeltaOperationList()
# Application ---------------------------------------------------------------
@@ -78,9 +78,32 @@ class DirectiveInfo(BaseModel):
class TokenUsageSummary(BaseModel):
"""Total token usage across all LLM calls."""
input_tokens: int = Field(default=0, description="Total input tokens used")
output_tokens: int = Field(default=0, description="Total output tokens used")
total_tokens: int = Field(default=0, description="Total tokens (input + output)")
input_tokens: int = Field(default=0, description="Total input tokens used (includes any cached prefix tokens)")
output_tokens: int = Field(default=0, description="Total visible output tokens used (excludes reasoning/thoughts)")
total_tokens: int = Field(default=0, description="Total tokens (input + output, excludes thoughts)")
cached_tokens: int = Field(
default=0,
description="Cached/cache-read prompt tokens summed across calls. Subset of input_tokens.",
)
thoughts_tokens: int = Field(
default=0,
description=(
"Reasoning/thinking tokens summed across calls. Billed at the output rate by some providers "
"but not part of visible output."
),
)
class StructuredOutputResult(BaseModel):
"""Result of structured-output generation, including token usage for the call."""
structured_output: dict[str, Any] | None = Field(
default=None, description="Generated structured output, or None if generation failed"
)
input_tokens: int = Field(default=0, description="Input tokens used")
output_tokens: int = Field(default=0, description="Visible output tokens used")
cached_tokens: int = Field(default=0, description="Cached prefix tokens. Subset of input_tokens.")
thoughts_tokens: int = Field(default=0, description="Reasoning/thinking tokens, when reported by the provider")
class ReflectAgentResult(BaseModel):
@@ -604,16 +604,44 @@ Just provide the direct answer with proper markdown formatting.
CRITICAL: This is a NON-CONVERSATIONAL system. NEVER ask follow-up questions, offer to search again, suggest alternatives, or end with anything like "Would you like me to..." or "Let me know if...". The user cannot reply. Your answer must be complete and self-contained."""
def build_final_system_prompt(mission: str | None = None, llm_output_language: str | None = None) -> str:
# The final synthesis is a SEPARATE LLM call with its own system prompt — the
# agent/reasoning system prompt (which carries directives and the language rule)
# is NOT in scope here. So this default language rule, and the directives, must
# be repeated for the answer-writing model. Without it, weaker models drift to
# English even when the question/facts are in another language or a directive
# demands a specific one (the cause of flaky multilingual reflect tests).
_FINAL_LANGUAGE_RULE = (
"## LANGUAGE\n"
"- Respond in the SAME language as the user's question "
"(e.g. a question in Chinese gets a Chinese answer; Japanese → Japanese).\n"
"- If a directive above specifies a response language, follow the directive — "
"it takes precedence over this default."
)
def build_final_system_prompt(
mission: str | None = None,
llm_output_language: str | None = None,
directives: list[dict[str, Any]] | None = None,
) -> str:
"""Build the final synthesis system prompt, using mission as role when set.
When ``llm_output_language`` is set, the response is forced into that
language regardless of the query/source language.
``directives`` are re-injected here (they live in the agent/reasoning prompt,
but the final answer is a separate call) so output-constraining rules most
visibly response language are honoured by the model that actually writes
the answer. When ``llm_output_language`` is set it forces that language
regardless of the query/source/directive language (config override wins).
"""
from hindsight_api.engine.prompt_utils import escape_for_prompt, output_language_directive
role_section = escape_for_prompt(mission.strip()) if mission else _DEFAULT_FINAL_ROLE
return _FINAL_SYSTEM_PROMPT_BASE.format(role_section=role_section) + output_language_directive(llm_output_language)
parts = [build_directives_section(directives) if directives else ""]
parts.append(_FINAL_SYSTEM_PROMPT_BASE.format(role_section=role_section))
parts.append(_FINAL_LANGUAGE_RULE)
parts.append(build_directives_reminder(directives) if directives else "")
return "\n\n".join(p.strip() for p in parts if p.strip()) + output_language_directive(llm_output_language)
# Backward-compatible constant for non-identity missions
@@ -706,7 +734,65 @@ Examples
``{"operations": [{"op": "replace_block", "section_id": "overview",
"index": 0, "block": {"type": "paragraph", "text": "Updated summary."}}]}``
- Remove an obsolete block
``{"operations": [{"op": "remove_block", "section_id": "status", "index": 2}]}``"""
``{"operations": [{"op": "remove_block", "section_id": "status", "index": 2}]}``
JSON STRING RULES (critical)
- Every ``text`` and ``items`` string must be valid JSON: escape ``"`` as ``\\"``,
backslashes as ``\\\\``, and newlines as ``\\n``. Do not use raw backticks inside
strings unless needed; prefer plain quotes for file paths.
- ``replace_block``, ``insert_block``, and ``remove_block`` MUST include ``index`` (0-based block position in that section). Use ``replace_section_blocks`` only when replacing every block in a section.
- Do not append extra ``]`` or ``}`` after the closing ``}`` of the root object."""
_STRUCTURED_DELTA_DEFAULT_MAX_INPUT_TOKENS = 24_000
def _truncate_cl100k(text: str, max_tokens: int) -> str:
"""Truncate text to at most max_tokens using cl100k_base."""
if max_tokens <= 0:
return ""
from .tokenization import count_cl100k_tokens
if count_cl100k_tokens(text) <= max_tokens:
return text
enc = __import__("tiktoken").get_encoding("cl100k_base")
return enc.decode(enc.encode(text)[:max_tokens])
def _fit_structured_delta_prompt_parts(
*,
source_query: str,
current_document_json: str,
candidate_markdown: str,
facts_block: str,
budget_hint: str,
task_footer: str,
max_input_tokens: int,
) -> tuple[str, str, str, bool]:
"""Shrink large prompt sections to fit within max_input_tokens (cl100k estimate)."""
from .tokenization import count_cl100k_tokens
fixed = (
f"## Topic\n{source_query}\n\n"
f"## CURRENT DOCUMENT (apply ops to this; reference section ids as listed)\n"
f"```json\n\n```\n\n"
f"## NEW INFORMATION SYNTHESIS (context for how new facts relate to the topic)\n"
f"```markdown\n\n```\n\n"
f"## SUPPORTING FACTS (new since last refresh — integrate these)\n"
f"{budget_hint}\n\n"
f"{task_footer}"
)
facts_header = "## SUPPORTING FACTS (new since last refresh — integrate these)\n"
facts_prefix_tokens = count_cl100k_tokens(facts_header)
reserved_facts = min(4096, max(512, max_input_tokens // 8))
doc_budget = max(1024, (max_input_tokens - count_cl100k_tokens(fixed) - reserved_facts) * 55 // 100)
cand_budget = max(512, (max_input_tokens - count_cl100k_tokens(fixed) - reserved_facts) * 30 // 100)
facts_budget = max(256, reserved_facts - facts_prefix_tokens)
doc_json = _truncate_cl100k(current_document_json, doc_budget)
candidate = _truncate_cl100k(candidate_markdown, cand_budget)
facts_body = _truncate_cl100k(facts_block, facts_budget)
truncated = doc_json != current_document_json or candidate != candidate_markdown or facts_body != facts_block
return doc_json, candidate, facts_body, truncated
def build_structured_delta_prompt(
@@ -716,6 +802,7 @@ def build_structured_delta_prompt(
supporting_facts: list[dict[str, Any]],
source_query: str,
max_output_tokens: int | None = None,
max_input_tokens: int | None = None,
) -> str:
"""Build the user prompt for a structured-delta mental model refresh.
@@ -746,19 +833,39 @@ def build_structured_delta_prompt(
"block-level ops) so the response always parses as valid JSON."
)
return (
f"## Topic\n{source_query}\n\n"
f"## CURRENT DOCUMENT (apply ops to this; reference section ids as listed)\n"
f"```json\n{current_document_json}\n```\n\n"
f"## NEW INFORMATION SYNTHESIS (context for how new facts relate to the topic)\n"
f"```markdown\n{candidate_markdown}\n```\n\n"
f"## SUPPORTING FACTS (new since last refresh — integrate these)\n{facts_block}"
f"{budget_hint}\n\n"
task_footer = (
"## Task\n"
"Output a JSON object matching the operations schema. Integrate the new "
"supporting facts into CURRENT DOCUMENT. Add, update, or remove content "
"as needed. Preserve unchanged sections and blocks by not mentioning them."
)
input_cap = max_input_tokens if max_input_tokens is not None else _STRUCTURED_DELTA_DEFAULT_MAX_INPUT_TOKENS
doc_json, candidate, facts_body, input_truncated = _fit_structured_delta_prompt_parts(
source_query=source_query,
current_document_json=current_document_json,
candidate_markdown=candidate_markdown,
facts_block=facts_block,
budget_hint=budget_hint,
task_footer=task_footer,
max_input_tokens=input_cap,
)
truncation_note = ""
if input_truncated:
truncation_note = (
"\n\n*Note: Document, synthesis, or facts were truncated to fit the model "
"context window. Prefer minimal, high-leverage operations.*"
)
return (
f"## Topic\n{source_query}\n\n"
f"## CURRENT DOCUMENT (apply ops to this; reference section ids as listed)\n"
f"```json\n{doc_json}\n```\n\n"
f"## NEW INFORMATION SYNTHESIS (context for how new facts relate to the topic)\n"
f"```markdown\n{candidate}\n```\n\n"
f"## SUPPORTING FACTS (new since last refresh — integrate these)\n{facts_body}"
f"{budget_hint}{truncation_note}\n\n"
f"{task_footer}"
)
DELTA_SYSTEM_PROMPT = """You are performing a surgical delta update to an existing mental model document.
@@ -232,6 +232,7 @@ def get_reflect_tools(
include_mental_models: bool = True,
include_observations: bool = True,
include_recall: bool = True,
include_expand: bool = True,
) -> list[dict]:
"""
Get the list of tools for the reflect agent.
@@ -247,6 +248,9 @@ def get_reflect_tools(
include_mental_models: Whether to include the search_mental_models tool.
include_observations: Whether to include the search_observations tool.
include_recall: Whether to include the recall tool.
include_expand: Whether to include the expand tool. Disabled when raw
document/chunk text is not stored, since expand only reads back
source text and would return empty results.
Returns:
List of tool definitions in OpenAI format
@@ -260,7 +264,8 @@ def get_reflect_tools(
if include_recall:
tools.append(TOOL_RECALL)
tools.append(TOOL_EXPAND)
if include_expand:
tools.append(TOOL_EXPAND)
# Use directive-aware done tool if directives are present
if directive_rules:
@@ -31,8 +31,20 @@ class LLMToolCallResult(BaseModel):
content: str | None = Field(default=None, description="Text content if any")
tool_calls: list[LLMToolCall] = Field(default_factory=list, description="Tool calls requested by the LLM")
finish_reason: str | None = Field(default=None, description="Reason the LLM stopped: 'stop', 'tool_calls', etc.")
input_tokens: int = Field(default=0, description="Input tokens used in this call")
output_tokens: int = Field(default=0, description="Output tokens used in this call")
input_tokens: int = Field(
default=0,
description="Input tokens used in this call (includes any cached prefix tokens reported by the provider)",
)
output_tokens: int = Field(
default=0, description="Visible output tokens used in this call (excludes reasoning/thoughts)"
)
cached_tokens: int = Field(
default=0, description="Cached prefix tokens, when reported by the provider. Subset of input_tokens."
)
thoughts_tokens: int = Field(
default=0,
description="Reasoning/thinking tokens. Billed at the output rate by some providers but not part of visible output.",
)
class ToolCallTrace(BaseModel):
@@ -91,9 +103,18 @@ 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)")
output_tokens: int = Field(
default=0, description="Number of visible output/completion tokens generated (excludes reasoning/thoughts)"
)
total_tokens: int = Field(default=0, description="Total tokens (input + output, excludes thoughts)")
cached_tokens: int = Field(default=0, description="Cached/cache-read prompt tokens, when reported by the provider")
thoughts_tokens: int = Field(
default=0,
description=(
"Reasoning/thinking tokens generated by the model. Billed at the output rate by some providers "
"(e.g. Gemini 2.5+ family) but not surfaced in the visible response."
),
)
def __add__(self, other: "TokenUsage") -> "TokenUsage":
"""Allow aggregating token usage from multiple calls."""
@@ -102,9 +123,38 @@ class TokenUsage(BaseModel):
output_tokens=self.output_tokens + other.output_tokens,
total_tokens=self.total_tokens + other.total_tokens,
cached_tokens=self.cached_tokens + other.cached_tokens,
thoughts_tokens=self.thoughts_tokens + other.thoughts_tokens,
)
class ExtractedFact(BaseModel):
"""A single candidate fact produced by dry-run extraction (no resolution/links/persistence).
A deliberate subset of the persisted memory-unit shape only the fields a fresh extraction
yields. Storage/consolidation/curation fields (id, document_id, chunk_id, proof_count, state, )
are omitted because nothing is stored. Entities are raw, unresolved names.
"""
text: str = Field(description="The extracted fact text.")
fact_type: str = Field(description="Perspective classification: 'world' or 'experience'.")
occurred_start: str | None = Field(default=None, description="ISO timestamp the fact's event started, if dated.")
occurred_end: str | None = Field(default=None, description="ISO timestamp the fact's event ended, if dated.")
entities: list[str] = Field(
default_factory=list, description="Raw (unresolved) entity names mentioned in the fact."
)
class DryRunExtractionResult(BaseModel):
"""Result of dry-run fact extraction: candidate facts plus aggregated LLM token usage."""
facts: list[ExtractedFact] = Field(
default_factory=list, description="Candidate facts the retain step would extract."
)
usage: TokenUsage = Field(
default_factory=TokenUsage, description="Aggregated token usage across the extraction LLM calls."
)
class DispositionTraits(BaseModel):
"""
Disposition traits for a memory bank.
@@ -122,6 +172,47 @@ class DispositionTraits(BaseModel):
model_config = ConfigDict(json_schema_extra={"example": {"skepticism": 3, "literalism": 3, "empathy": 3}})
class RecallScores(BaseModel):
"""Per-result recall scores from different stages of the pipeline.
``final`` is the value results are ranked by. The others are diagnostic and
can be filtered on via the recall ``min_scores`` request parameter. ``semantic``
and ``keyword`` are the raw per-strategy retrieval scores (``None`` when that
strategy did not surface this result); ``reranker`` is the cross-encoder's
normalized relevance.
"""
final: float = Field(description="Final ranking score (combined reranker + recency/temporal/proof boosts)")
reranker: float | None = Field(
default=None,
description="Cross-encoder relevance, normalized 0-1. None when the reranker is a passthrough (rrf/interleave modes).",
)
semantic: float | None = Field(
default=None, description="Vector cosine similarity (0-1). None if this result was not surfaced semantically."
)
keyword: float | None = Field(
default=None,
description="Keyword/full-text (BM25) score (>= 0, unbounded). None if this result was not surfaced by keyword search.",
)
class MinScores(BaseModel):
"""Optional per-stage score floors for recall (all inclusive, AND-ed).
``semantic`` and ``keyword`` are **retrieval-level** cutoffs pushed into the SQL
arms (overriding the global ``semantic_min_similarity`` / ``bm25_min_score``
config for this request), so they prune weak matches before fusion. ``reranker``
and ``final`` are **post-query** filters applied to the scored results after
reranking. Any field left None imposes no floor; all-None (the default) means
no score filtering.
"""
semantic: float | None = Field(default=None, description="Retrieval-level: minimum vector similarity (0-1).")
keyword: float | None = Field(default=None, description="Retrieval-level: minimum keyword/full-text (BM25) score.")
reranker: float | None = Field(default=None, description="Post-query: minimum normalized reranker score (0-1).")
final: float | None = Field(default=None, description="Post-query: minimum final ranking score.")
class MemoryFact(BaseModel):
"""
A single memory fact returned by search or think operations.
@@ -152,7 +243,7 @@ class MemoryFact(BaseModel):
id: str = Field(description="Unique identifier for the memory fact")
text: str = Field(description="The actual text content of the memory")
fact_type: str = Field(description="Type of fact: 'world', 'experience', 'opinion', or 'observation'")
fact_type: str = Field(description="Type of fact: 'world', 'experience', or 'observation'")
entities: list[str] | None = Field(None, description="Entity names mentioned in this fact")
context: str | None = Field(None, description="Additional context for the memory")
occurred_start: str | None = Field(None, description="ISO format date when the event started occurring")
@@ -181,6 +272,10 @@ class MemoryFact(BaseModel):
None,
description="IDs of source facts this observation was derived from (observation type only, when source_facts is enabled)",
)
scores: RecallScores | None = Field(
None,
description="Recall scores from each pipeline stage (final/reranker/semantic/keyword). Not returned for source facts.",
)
class ChunkInfo(BaseModel):
@@ -279,7 +374,8 @@ class ReflectResult(BaseModel):
],
"experience": [],
"opinion": [],
"mental_models": [],
"observation": [],
"mental-models": [],
"directives": [
{
"id": "directive-123",
@@ -296,7 +392,7 @@ class ReflectResult(BaseModel):
text: str = Field(description="The formulated answer text")
based_on: dict[str, Any] = Field(
description="Facts used to formulate the answer, organized by type (world, experience, mental_models, directives)"
description="Facts used to formulate the answer, organized by type (world, experience, observation, mental-models, directives)"
)
structured_output: dict[str, Any] | None = Field(
default=None,
@@ -4,8 +4,8 @@ bank profile utilities for disposition and mission management.
import json
import logging
import re
import uuid
from dataclasses import dataclass
from typing import TypedDict
from pydantic import BaseModel, Field
@@ -105,6 +105,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 +135,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 +174,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:
@@ -8,6 +8,7 @@ import hashlib
import logging
from dataclasses import dataclass
from ...config import get_config
from ..memory_engine import fq_table
from .types import ChunkMetadata
@@ -88,6 +89,11 @@ async def store_chunks_batch(
if not chunks:
return {}
# When document text storage is disabled, persist empty chunk_text (the
# column is NOT NULL) while still computing content_hash from the real text
# so delta-retain dedup is unaffected.
store_text = get_config().store_document_text
# Prepare chunk data for batch insert
chunk_ids = []
chunk_texts = []
@@ -98,7 +104,7 @@ async def store_chunks_batch(
for chunk in chunks:
chunk_id = f"{bank_id}_{document_id}_{chunk.chunk_index}"
chunk_ids.append(chunk_id)
chunk_texts.append(chunk.chunk_text)
chunk_texts.append(chunk.chunk_text if store_text else "")
chunk_indices.append(chunk.chunk_index)
content_hashes.append(compute_chunk_hash(chunk.chunk_text))
chunk_id_map[chunk.chunk_index] = chunk_id
@@ -3,6 +3,7 @@ Embedding generation utilities for memory units.
"""
import asyncio
import contextvars
import logging
from typing import Literal, Protocol
@@ -15,11 +16,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 +49,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
@@ -68,7 +90,14 @@ async def generate_embeddings_batch(
"""
try:
loop = asyncio.get_event_loop()
embeddings = await loop.run_in_executor(None, _encode_with_input_type, embeddings_backend, texts, input_type)
# run_in_executor runs the encode in a worker thread, which does NOT inherit
# the caller's contextvars. Capture the current context and run the encode
# inside it so context-dependent behavior (e.g. per-bank `user` attribution
# read via get_current_bank_id()) survives the thread hop.
ctx = contextvars.copy_context()
embeddings = await loop.run_in_executor(
None, lambda: ctx.run(_encode_with_input_type, embeddings_backend, texts, input_type)
)
except Exception as e:
raise Exception(f"Failed to generate batch embeddings: {str(e)}")
@@ -81,4 +110,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_interface import ProviderRateLimitResetError
from ..llm_wrapper import LLMConfig, OutputTooLongError, sanitize_llm_output
from ..operation_metadata import RetainExtractionErrors
from ..response_models import TokenUsage
from .entity_labels import (
EntityLabelsConfig,
@@ -192,7 +193,7 @@ class ExtractedFact(BaseModel):
occurred_start: str | None = Field(default=None, description="ISO timestamp for events")
occurred_end: str | None = Field(default=None, description="ISO timestamp for event end")
fact_type: Literal["world", "assistant"] = Field(
description="'world' = objective/external facts. 'assistant' = first-person actions, experiences, or observations by the speaker."
description="'world' = objective/external facts, including user preferences, rules, corrections, and constraints even when stated during a conversation. 'assistant' = actions, experiences, or observations the assistant/agent actually performed."
)
entities: list[Entity] | None = Field(default=None, description="People, places, concepts")
causal_relations: list[FactCausalRelation] | None = Field(
@@ -295,7 +296,7 @@ class ExtractedFactVerbose(BaseModel):
)
fact_type: Literal["world", "assistant"] = Field(
description="'world' = objective/external facts about other people, events, general knowledge. 'assistant' = first-person actions, experiences, or observations by the speaker (e.g., 'I changed X', 'I discovered Y')."
description="'world' = objective/external facts about the user, other people, events, general knowledge, preferences, rules, corrections, or constraints. 'assistant' = actions, experiences, or observations the assistant/agent actually performed (e.g., 'I changed X', 'I discovered Y')."
)
entities: list[Entity] | None = Field(
@@ -345,7 +346,7 @@ class ExtractedFactNoCausal(BaseModel):
occurred_start: str | None = Field(default=None, description="WHEN the event happened (ISO timestamp).")
occurred_end: str | None = Field(default=None, description="WHEN the event ended (ISO timestamp).")
fact_type: Literal["world", "assistant"] = Field(
description="'world' = about the user/others. 'assistant' = experience with assistant."
description="'world' = about the user/others, including user preferences, rules, corrections, and constraints. 'assistant' = actions or experiences the assistant/agent actually performed."
)
entities: list[Entity] | None = Field(
default=None,
@@ -405,64 +406,110 @@ class VerbatimFactExtractionResponse(BaseModel):
facts: list[VerbatimExtractedFact] = Field(description="List of metadata entries (one per chunk)")
def chunk_text(text: str, max_chars: int) -> list[str]:
"""
Split text into chunks, preserving conversation structure when possible.
# Separators for sentence-aware recursive text splitting, ordered most- to
# least-preferred. The final "" lets the splitter break mid-word as a last
# resort so a chunk can never exceed the size budget.
_RECURSIVE_TEXT_SEPARATORS = [
"\n\n", # Paragraph breaks
"\n", # Line breaks
". ", # Sentence endings
"! ", # Exclamations
"? ", # Questions
"; ", # Semicolons
", ", # Commas
" ", # Words
"", # Characters (last resort)
]
For JSON conversation arrays (user/assistant turns), splits at turn boundaries
while preserving speaker context. For plain text, uses sentence-aware splitting.
Args:
text: Input text to chunk (plain text or JSON conversation)
max_chars: Maximum characters per chunk (default 120k 30k tokens)
def _split_oversized_unit(text: str, max_chars: int) -> list[str]:
"""Sentence-aware split of a single unit that overflowed the budget.
Returns:
List of text chunks, roughly under max_chars
Used when one JSONL line / conversation turn is so large it can't be kept
whole within the configured structured-chunk limit. The resulting fragments
are no longer valid JSON, but the fact extractor treats every chunk as plain
text.
"""
from langchain_text_splitters import RecursiveCharacterTextSplitter
# If text is small enough, return as-is
if len(text) <= max_chars:
return [text]
# Try to parse as JSON conversation array
try:
parsed = json.loads(text)
if isinstance(parsed, list) and all(isinstance(turn, dict) for turn in parsed):
# This looks like a conversation - chunk at turn boundaries
return _chunk_conversation(parsed, max_chars)
except (json.JSONDecodeError, ValueError):
pass
# Fall back to sentence-aware text splitting
splitter = RecursiveCharacterTextSplitter(
chunk_size=max_chars,
chunk_overlap=0,
length_function=len,
is_separator_regex=False,
separators=[
"\n\n", # Paragraph breaks
"\n", # Line breaks
". ", # Sentence endings
"! ", # Exclamations
"? ", # Questions
"; ", # Semicolons
", ", # Commas
" ", # Words
"", # Characters (last resort)
],
separators=_RECURSIVE_TEXT_SEPARATORS,
)
return splitter.split_text(text)
def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
def chunk_text(text: str, max_chars: int, structured_chunk_size: int | None = None) -> list[str]:
"""
Split text into chunks, preserving conversation structure when possible.
For JSON conversation arrays (user/assistant turns) and JSONL (newline-delimited
JSON objects), splits at turn/line boundaries so no object is split across chunks.
A single turn/line that overflows ``max_chars`` is kept whole only up to
``structured_chunk_size``. When unset, that limit defaults to ``max_chars``.
For plain text, uses sentence-aware splitting.
The result is idempotent: re-chunking any chunk this returns yields that chunk
unchanged. The streaming retain pipeline pre-chunks each document once and then
re-chunks every piece during extraction; if a piece re-split, its sub-chunks
would inherit one chunk_index and collide on ``chunk_id`` (issue #2301).
Args:
text: Input text to chunk (plain text, JSON conversation, or JSONL)
max_chars: Target maximum characters per chunk
structured_chunk_size: Maximum characters for a single JSONL line or
conversation turn to keep whole. Defaults to ``max_chars``.
Returns:
List of text chunks, roughly under max_chars
"""
# If text is small enough, return as-is
if len(text) <= max_chars:
return [text]
structured_limit = structured_chunk_size if structured_chunk_size is not None else max_chars
# Try to parse as JSON conversation array
try:
parsed = json.loads(text)
except (json.JSONDecodeError, ValueError):
parsed = None
if isinstance(parsed, list) and all(isinstance(turn, dict) for turn in parsed):
# This looks like a conversation - chunk at turn boundaries
return _chunk_conversation(parsed, max_chars, structured_limit)
if isinstance(parsed, dict):
# A single JSON object — e.g. one JSONL line handed back to the extractor
# after the producer already pre-chunked it. It is one structured unit:
# keep it whole up to the structured limit, else split it as text within
# the chunk budget. Without this, a lone object (one line, so _chunk_jsonl
# declines) would fall through to plain-text splitting and re-split a chunk
# the producer deliberately kept whole — breaking idempotency (issue #2301).
if len(text) <= structured_limit:
return [text]
return _split_oversized_unit(text, max_chars)
# Try to parse as JSONL (newline-delimited JSON objects, e.g. session logs)
jsonl_chunks = _chunk_jsonl(text, max_chars, structured_limit)
if jsonl_chunks is not None:
return jsonl_chunks
# Fall back to sentence-aware text splitting
return _split_oversized_unit(text, max_chars)
def _chunk_conversation(turns: list[dict], max_chars: int, structured_limit: int) -> list[str]:
"""
Chunk a conversation array at turn boundaries, preserving complete turns.
Args:
turns: List of conversation turn dicts (with 'role' and 'content' keys)
max_chars: Maximum characters per chunk
structured_limit: Maximum characters for a single turn to keep whole
Returns:
List of JSON-serialized chunks, each containing complete turns
@@ -472,28 +519,109 @@ def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
current_chunk = []
current_size = 2 # Account for "[]"
for turn in turns:
# Estimate size of this turn when serialized (with comma separator)
turn_json = json.dumps(turn, ensure_ascii=False)
turn_size = len(turn_json) + 1 # +1 for comma
# If adding this turn would exceed limit and we have turns, save current chunk
if current_size + turn_size > max_chars and current_chunk:
def _flush() -> None:
nonlocal current_chunk, current_size
if current_chunk:
chunks.append(json.dumps(current_chunk, ensure_ascii=False))
current_chunk = []
current_size = 2 # Reset to "[]"
for turn in turns:
# Estimate size of this turn when serialized (with comma separator)
turn_json = json.dumps(turn, ensure_ascii=False)
turn_unit_size = len(turn_json)
turn_size = turn_unit_size + 1 # +1 for comma
# A turn too large to keep whole even alone: flush, then split it as
# text. Fragment within min(structured_limit, max_chars) so no fragment
# exceeds the chunk budget — otherwise a downstream re-chunk would split
# it again and collide on chunk_id (issue #2301).
if turn_unit_size > structured_limit:
_flush()
chunks.extend(_split_oversized_unit(turn_json, min(structured_limit, max_chars)))
continue
# If adding this turn would exceed limit and we have turns, save current chunk
if current_size + turn_size > max_chars and current_chunk:
_flush()
# Add turn to current chunk
current_chunk.append(turn)
current_size += turn_size
# Add final chunk if non-empty
if current_chunk:
chunks.append(json.dumps(current_chunk, ensure_ascii=False))
_flush()
return chunks if chunks else [json.dumps(turns, ensure_ascii=False)]
def _chunk_jsonl(text: str, max_chars: int, structured_limit: int) -> list[str] | None:
"""Chunk newline-delimited JSON (JSONL) at line boundaries.
Detects JSONL two or more non-empty lines, each a complete JSON object
and packs whole lines into chunks so no line is split across chunks (multiple
short lines may share a chunk). A line that overflows ``max_chars`` is kept
whole only up to ``structured_limit``. Returns ``None`` if the input is not
JSONL, so the caller falls back to plain-text splitting.
Args:
text: Input text to inspect/chunk.
max_chars: Maximum characters per chunk.
structured_limit: Maximum characters for a single JSONL line to
keep whole.
Returns:
List of JSONL chunks (lines joined by newline), or ``None`` if not JSONL.
"""
lines = [line for line in text.splitlines() if line.strip()]
if len(lines) < 2:
return None
for line in lines:
try:
obj = json.loads(line)
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(obj, dict):
return None
chunks: list[str] = []
current_chunk: list[str] = []
current_size = 0
def _flush() -> None:
nonlocal current_chunk, current_size
if current_chunk:
chunks.append("\n".join(current_chunk))
current_chunk = []
current_size = 0
for line in lines:
line_unit_size = len(line)
line_size = len(line) + 1 # +1 for the joining newline
# A line too large to keep whole even alone: flush, then split it as
# text. Fragment within min(structured_limit, max_chars) so no fragment
# exceeds the chunk budget — otherwise a downstream re-chunk would split
# it again and collide on chunk_id (issue #2301).
if line_unit_size > structured_limit:
_flush()
chunks.extend(_split_oversized_unit(line, min(structured_limit, max_chars)))
continue
# If adding this line would exceed the limit and we have lines, flush.
# A line up to structured_limit is kept whole (a bounded overflow).
if current_size + line_size > max_chars and current_chunk:
_flush()
current_chunk.append(line)
current_size += line_size
_flush()
return chunks
# =============================================================================
# FACT EXTRACTION PROMPTS
# =============================================================================
@@ -510,11 +638,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.
@@ -535,8 +663,8 @@ fact_kind:
- "conversation": Ongoing state, preference, trait (no dates)
fact_type:
- "world": About other people, external events, general knowledge, objective facts
- "assistant": First-person actions, experiences, or observations by the speaker/author (e.g., "I changed X", "I discovered Y", "I debugged Z"). Also includes interactions with the user (requests, recommendations). If the narrator describes something they did, tried, learned, or decided use "assistant".
- "world": Objective/external facts, including the user's preferences, rules, corrections, constraints, plans, traits, or context. These stay "world" even when the user states them during an assistant interaction (e.g., "User prefers browser_navigate over web_search", "User corrected the project deadline").
- "assistant": Actions, experiences, or observations the assistant/agent actually performed (e.g., "I changed X", "I discovered Y", "I debugged Z"). Use this for the assistant/agent doing, trying, learning, deciding, recommending, or responding not merely for user facts mentioned in conversation.
TEMPORAL HANDLING
@@ -638,7 +766,7 @@ RULES:
- Extract all entities (people, places, organizations, objects, concepts).
- Extract temporal information (occurred_start, occurred_end, fact_kind, when).
- Extract location (where) and people (who).
- fact_type: use "world" unless the content is clearly an interaction with the assistant."""
- fact_type: use "world" for user preferences, rules, corrections, constraints, traits, and other objective facts, even when stated during an assistant interaction. Use "assistant" only for actions or experiences the assistant/agent actually performed."""
VERBATIM_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
retain_mission_section="{retain_mission_section}",
@@ -739,8 +867,8 @@ For CONVERSATIONS (fact_kind="conversation"):
FACT TYPE
- **world**: User's life, other people, events (would exist without this conversation)
- **assistant**: Interactions with assistant (requests, recommendations, help)
- **world**: User's life, preferences, rules, corrections, constraints, other people, and events (facts that would exist without this conversation)
- **assistant**: Actions or experiences the assistant/agent actually performed while helping the user (requests, recommendations, help)
CRITICAL for assistant facts: ALWAYS capture the user's request/question in the fact!
Include: what the user asked, what problem they wanted solved, what context they provided
@@ -887,20 +1015,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 +1120,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 +1148,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)
@@ -1039,7 +1188,7 @@ def _build_user_message(
'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}
@@ -1054,9 +1203,17 @@ def _build_request_body(llm_config, config, prompt: str, user_message: str, resp
request_body = {
"model": llm_config.model,
"messages": [{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
"temperature": 0.1,
}
# Honour the configured retain temperature. ``None`` omits the parameter
# entirely (for models like Azure GPT-5.5 that reject explicit temperatures),
# mirroring LLMProvider.call, which drops temperature when it is None. The
# batch path builds the request body directly instead of going through
# LLMProvider.call (#2469 only de-hardcoded the streaming path), so it must
# apply the same rule here.
if config.llm_temperature_retain is not None:
request_body["temperature"] = config.llm_temperature_retain
# Add max_completion_tokens if configured
if config.retain_max_completion_tokens:
request_body["max_completion_tokens"] = config.retain_max_completion_tokens
@@ -1065,12 +1222,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
@@ -1106,8 +1266,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
@@ -1128,11 +1318,11 @@ 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",
temperature=0.1,
temperature=config.llm_temperature_retain,
max_completion_tokens=config.retain_max_completion_tokens,
max_retries=llm_max_retries,
initial_backoff=initial_backoff,
@@ -1140,6 +1330,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
@@ -1575,7 +1769,11 @@ async def extract_facts_from_text(
- chunks: List of tuples (chunk_text, fact_count) for each chunk
- usage: Aggregated token usage across all LLM calls
"""
chunks = chunk_text(text, max_chars=config.retain_chunk_size)
chunks = chunk_text(
text,
max_chars=config.retain_chunk_size,
structured_chunk_size=config.retain_structured_chunk_size,
)
# Log chunk count before starting LLM requests
total_chars = sum(len(c) for c in chunks)
@@ -1624,10 +1822,28 @@ async def extract_facts_from_text(
total_usage = total_usage + chunk_usage
if failed_chunks:
# Include the exception message — not just the type — so operators
# can tell a structured-JSON parse failure apart from a rate limit
# apart from a network 5xx, all of which can surface as the same
# exception types. The error_message we propagate to the
# async_operations row is the only inspection surface a worker-side
# failure leaves behind, and a bare "chunk 0: RuntimeError" is not
# actionable.
failed_summary = ", ".join(f"chunk {idx}: {type(err).__name__}: {err}" for idx, err in failed_chunks[:5])
quota_errors = [err for _, err in failed_chunks if isinstance(err, ProviderRateLimitResetError)]
if quota_errors and len(quota_errors) == len(failed_chunks):
retry_at = max(err.retry_at for err in quota_errors)
raise ProviderRateLimitResetError(
retry_at=retry_at,
message=(
f"Fact extraction deferred by provider quota: {len(failed_chunks)}/{len(chunks)} chunks failed. "
f"First failures: {failed_summary}. Provider detail: {quota_errors[0]}"
),
) from quota_errors[0]
# Fail the entire retain — partial extraction is not acceptable.
# All successfully extracted facts are discarded because the transaction
# hasn't committed yet. The worker poller will retry the entire task.
failed_summary = ", ".join(f"chunk {idx}: {type(err).__name__}" for idx, err in failed_chunks[:5])
raise RuntimeError(
f"Fact extraction failed: {len(failed_chunks)}/{len(chunks)} chunks failed. "
f"First failures: {failed_summary}"
@@ -1652,6 +1868,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,
@@ -1684,8 +1933,7 @@ async def extract_facts_from_contents_batch_api(
logger.info(f"Using Batch API for fact extraction ({len(contents)} contents)")
# Check config for extraction mode and causal link extraction (used throughout)
extraction_mode = config.retain_extraction_mode
# Check config for causal link extraction (used throughout)
extract_causal_links = config.retain_extract_causal_links
# Check if provider supports batch API
@@ -1726,7 +1974,11 @@ async def extract_facts_from_contents_batch_api(
prompt, response_schema = _build_extraction_prompt_and_schema(config)
for content_index, item in enumerate(contents):
chunks = chunk_text(item.content, max_chars=config.retain_chunk_size)
chunks = chunk_text(
item.content,
max_chars=config.retain_chunk_size,
structured_chunk_size=config.retain_structured_chunk_size,
)
for chunk_index_in_content, chunk in enumerate(chunks):
all_chunks_info.append((chunk, content_index, chunk_index_in_content, item.event_date, item.context))
@@ -1743,6 +1995,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
@@ -1828,6 +2081,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
@@ -1836,7 +2090,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
@@ -1846,7 +2102,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
@@ -1859,7 +2117,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
@@ -1874,7 +2134,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
@@ -2047,7 +2309,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)
@@ -2112,6 +2376,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
@@ -2133,7 +2399,11 @@ def _extract_facts_chunks(
global_chunk_idx = 0
for content_index, content in enumerate(contents):
chunks = chunk_text(content.content, config.retain_chunk_size)
chunks = chunk_text(
content.content,
config.retain_chunk_size,
structured_chunk_size=config.retain_structured_chunk_size,
)
for chunk in chunks:
chunks_metadata.append(
ChunkMetadata(
@@ -399,7 +399,12 @@ async def _upsert_document_row(
INSERT so that re-ingesting a document (which deletes + inserts the row)
keeps the original creation timestamp. ``updated_at`` is always set to
``NOW()`` on both INSERT and the ON CONFLICT UPDATE branch.
When ``store_document_text`` is disabled, the raw source text
is dropped and ``original_text`` is stored as NULL. The ``content_hash`` is
still computed from the real content so delta-retain dedup is unaffected.
"""
original_text = combined_content if get_config().store_document_text else None
await conn.execute(
f"""
INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, retain_params, tags, created_at, updated_at)
@@ -413,7 +418,7 @@ async def _upsert_document_row(
""",
document_id,
bank_id,
combined_content,
original_text,
content_hash,
json.dumps(retain_params) if retain_params else None,
document_tags or [],

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