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
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 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
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
1041 changed files with 90979 additions and 11011 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"
+54 -1
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,6 +116,18 @@ 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), "onnx", "tei", "openai", "cohere", "google", "openrouter", "zeroentropy", "litellm", or "litellm-sdk"
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
@@ -142,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"
+1 -1
View File
@@ -75,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) ────────────────────
+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
+418
View File
@@ -33,26 +33,35 @@ jobs:
integrations-openclaw: ${{ steps.filter.outputs.integrations-openclaw }}
integrations-ai-sdk: ${{ steps.filter.outputs.integrations-ai-sdk }}
integrations-agent-framework: ${{ steps.filter.outputs.integrations-agent-framework }}
integrations-composio: ${{ steps.filter.outputs.integrations-composio }}
integrations-chat: ${{ steps.filter.outputs.integrations-chat }}
integrations-claude-code: ${{ steps.filter.outputs.integrations-claude-code }}
integrations-cline: ${{ steps.filter.outputs.integrations-cline }}
integrations-codex: ${{ steps.filter.outputs.integrations-codex }}
integrations-github-copilot: ${{ steps.filter.outputs.integrations-github-copilot }}
integrations-continue: ${{ steps.filter.outputs.integrations-continue }}
integrations-cursor-cli: ${{ steps.filter.outputs.integrations-cursor-cli }}
integrations-crewai: ${{ steps.filter.outputs.integrations-crewai }}
integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }}
integrations-pydantic-ai: ${{ steps.filter.outputs.integrations-pydantic-ai }}
integrations-ag2: ${{ steps.filter.outputs.integrations-ag2 }}
integrations-autogen: ${{ steps.filter.outputs.integrations-autogen }}
integrations-aider: ${{ steps.filter.outputs.integrations-aider }}
integrations-langgraph: ${{ steps.filter.outputs.integrations-langgraph }}
integrations-llamaindex: ${{ steps.filter.outputs.integrations-llamaindex }}
integrations-paperclip: ${{ steps.filter.outputs.integrations-paperclip }}
integrations-opencode: ${{ steps.filter.outputs.integrations-opencode }}
integrations-eve: ${{ steps.filter.outputs.integrations-eve }}
integrations-cursor: ${{ steps.filter.outputs.integrations-cursor }}
integrations-zed: ${{ steps.filter.outputs.integrations-zed }}
integrations-n8n: ${{ steps.filter.outputs.integrations-n8n }}
integrations-zapier: ${{ steps.filter.outputs.integrations-zapier }}
integrations-cloudflare-oauth-proxy: ${{ steps.filter.outputs.integrations-cloudflare-oauth-proxy }}
integrations-superagent: ${{ steps.filter.outputs.integrations-superagent }}
integrations-lockfiles: ${{ steps.filter.outputs.integrations-lockfiles }}
integrations-openai-agents: ${{ steps.filter.outputs.integrations-openai-agents }}
integrations-openhands: ${{ steps.filter.outputs.integrations-openhands }}
integrations-devin-desktop: ${{ steps.filter.outputs.integrations-devin-desktop }}
integrations-pipecat: ${{ steps.filter.outputs.integrations-pipecat }}
integrations-agentcore: ${{ steps.filter.outputs.integrations-agentcore }}
integrations-smolagents: ${{ steps.filter.outputs.integrations-smolagents }}
@@ -127,6 +136,8 @@ jobs:
- 'hindsight-integrations/ai-sdk/**'
integrations-agent-framework:
- 'hindsight-integrations/agent-framework/**'
integrations-composio:
- 'hindsight-integrations/composio/**'
integrations-chat:
- 'hindsight-integrations/chat/**'
integrations-claude-code:
@@ -135,6 +146,10 @@ jobs:
- 'hindsight-integrations/cline/**'
integrations-codex:
- 'hindsight-integrations/codex/**'
integrations-github-copilot:
- 'hindsight-integrations/github-copilot/**'
integrations-continue:
- 'hindsight-integrations/continue/**'
integrations-cursor-cli:
- 'hindsight-integrations/cursor-cli/**'
integrations-crewai:
@@ -147,6 +162,8 @@ jobs:
- 'hindsight-integrations/ag2/**'
integrations-autogen:
- 'hindsight-integrations/autogen/**'
integrations-aider:
- 'hindsight-integrations/aider/**'
integrations-langgraph:
- 'hindsight-integrations/langgraph/**'
integrations-llamaindex:
@@ -157,10 +174,16 @@ jobs:
- 'hindsight-integrations/paperclip/**'
integrations-opencode:
- 'hindsight-integrations/opencode/**'
integrations-eve:
- 'hindsight-integrations/eve/**'
integrations-cursor:
- 'hindsight-integrations/cursor/**'
integrations-zed:
- 'hindsight-integrations/zed/**'
integrations-n8n:
- 'hindsight-integrations/n8n/**'
integrations-zapier:
- 'hindsight-integrations/zapier/**'
integrations-cloudflare-oauth-proxy:
- 'hindsight-integrations/cloudflare-oauth-proxy/**'
integrations-superagent:
@@ -171,6 +194,10 @@ jobs:
- 'scripts/check-integration-lockfiles.sh'
integrations-openai-agents:
- 'hindsight-integrations/openai-agents/**'
integrations-openhands:
- 'hindsight-integrations/openhands/**'
integrations-devin-desktop:
- 'hindsight-integrations/devin-desktop/**'
integrations-pipecat:
- 'hindsight-integrations/pipecat/**'
integrations-agentcore:
@@ -479,6 +506,37 @@ jobs:
working-directory: ./hindsight-integrations/cursor
run: python -m pytest tests/ -v
test-zed-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-zed == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install package and pytest
working-directory: ./hindsight-integrations/zed
# Installs the package (incl. the zstandard runtime dep) so the threads.db
# reader tests can decompress Zed's zstd blobs.
run: pip install -e . pytest
- name: Run tests
working-directory: ./hindsight-integrations/zed
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: python -m pytest tests/ -v -m "not requires_real_llm"
test-omo-integration:
needs: [detect-changes]
if: >-
@@ -542,6 +600,45 @@ jobs:
working-directory: ./hindsight-integrations/cline
run: uv run pytest tests -v
test-github-copilot-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-github-copilot == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build github-copilot integration
working-directory: ./hindsight-integrations/github-copilot
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/github-copilot
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/github-copilot
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-codex-integration:
needs: [detect-changes]
if: >-
@@ -699,6 +796,37 @@ jobs:
working-directory: ./hindsight-integrations/opencode
run: npm run build
test-eve-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-eve == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
- name: Install dependencies
working-directory: ./hindsight-integrations/eve
run: npm ci
- name: Run tests
working-directory: ./hindsight-integrations/eve
run: npm test
- name: Build
working-directory: ./hindsight-integrations/eve
run: npm run build
test-n8n-integration:
needs: [detect-changes]
if: >-
@@ -730,6 +858,37 @@ jobs:
working-directory: ./hindsight-integrations/n8n
run: npm run build
test-zapier-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-zapier == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install dependencies
working-directory: ./hindsight-integrations/zapier
run: npm install --no-fund --no-audit
- name: Validate app definition
working-directory: ./hindsight-integrations/zapier
run: npm run validate
- name: Run tests
working-directory: ./hindsight-integrations/zapier
run: npm test
test-hindsight-agent-sdk:
needs: [detect-changes]
if: >-
@@ -2981,6 +3140,45 @@ jobs:
working-directory: ./hindsight-integrations/ag2
run: uv run pytest tests -v
test-aider-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-aider == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build aider integration
working-directory: ./hindsight-integrations/aider
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/aider
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/aider
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-autogen-integration:
needs: [detect-changes]
if: >-
@@ -3020,6 +3218,88 @@ jobs:
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-composio-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-composio == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build composio integration
working-directory: ./hindsight-integrations/composio
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/composio
run: uv sync --frozen
- name: Lint
working-directory: ./hindsight-integrations/composio
run: uv run ruff check .
- name: Run tests
working-directory: ./hindsight-integrations/composio
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-continue-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-continue == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build continue integration
working-directory: ./hindsight-integrations/continue
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/continue
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/continue
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-smolagents-integration:
needs: [detect-changes]
if: >-
@@ -3547,6 +3827,84 @@ jobs:
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-openhands-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-openhands == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build openhands integration
working-directory: ./hindsight-integrations/openhands
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/openhands
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/openhands
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-devin-desktop-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-devin-desktop == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build devin-desktop integration
working-directory: ./hindsight-integrations/devin-desktop
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/devin-desktop
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/devin-desktop
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-claude-agent-sdk-integration:
needs: [detect-changes]
if: >-
@@ -4311,6 +4669,60 @@ jobs:
fi
done
# Dead-code detection beyond what ruff's F401/F841 catch (those are already
# BLOCKING via the ruff config + the verify-generated-files job).
#
# - knip (control plane): BLOCKING on unused files / dependencies / unlisted
# dependencies. These are unambiguous — an orphaned file or a dead
# package.json entry — so they fail the build.
# - vulture (Python) + knip unused *exports*: ADVISORY only. vulture's
# function/argument heuristics false-positive on FastAPI/SQLAlchemy/Pydantic
# patterns, and the control plane intentionally keeps an unused shadcn/ui
# component surface, so these are surfaced in the step summary, not gated.
check-unused-code:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.control-plane == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install Control Plane dependencies
run: npm install --workspace=hindsight-control-plane
- name: knip — unused files / dependencies (blocking)
working-directory: hindsight-control-plane
run: npx --yes knip@5 --no-progress --include files,dependencies,unlisted
- name: Advisory scan — vulture + knip exports
continue-on-error: true
run: |
{
echo '## Dead-code scan (advisory)'
echo ''
echo '```'
./scripts/hooks/check-unused.sh 2>&1 | sed 's/\x1b\[[0-9;]*m//g'
echo '```'
} | tee -a "$GITHUB_STEP_SUMMARY"
verify-generated-files:
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -4494,11 +4906,13 @@ jobs:
- test-claude-code-integration
- test-cursor-integration
- test-cline-integration
- test-github-copilot-integration
- test-codex-integration
- test-cursor-cli-integration
- build-ai-sdk-integration
- test-ai-sdk-integration-deno
- test-opencode-integration
- test-eve-integration
- test-omo-integration
- test-cloudflare-oauth-proxy-integration
- build-chat-integration
@@ -4527,7 +4941,9 @@ jobs:
- test-openclaw-integration
- test-integration
- test-ag2-integration
- test-aider-integration
- test-autogen-integration
- test-continue-integration
- test-smolagents-integration
- test-dify-integration
- test-flowise-integration
@@ -4540,6 +4956,8 @@ jobs:
- test-pydantic-ai-integration
- test-llamaindex-integration
- test-openai-agents-integration
- test-openhands-integration
- test-devin-desktop-integration
- test-agentcore-integration
- test-haystack-integration
- test-pip-slim
+1
View File
@@ -6,6 +6,7 @@ dist/
wheels/
*.egg-info
.mcp.json
.playwright-mcp/
.osgrep
# Virtual environments
.venv
+20 -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
@@ -361,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/
@@ -370,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
+4 -4
View File
@@ -70,7 +70,7 @@ docker run -it --pull always --name hindsight --restart unless-stopped -p 8888:8
>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).
@@ -250,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.
@@ -276,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)
---
@@ -310,7 +310,7 @@ client.reflect(bank_id="my-bank", query="What should I know about Alice?")
| **macOS** (Intel / x86_64) | ✅ | ⚠️ | ✅ |
| **Windows** (x86_64) | ✅ | ✅ | ✅ |
⚠️ Intel Macs: use `hindsight-all-slim` — see the [installation guide](https://docs.hindsight.vectorize.io/docs/developer/installation#supported-platforms) for details.
⚠️ Intel Macs: use `hindsight-all-slim` — see the [installation guide](https://hindsight.vectorize.io/developer/installation#supported-platforms) for details.
---
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.8.1
appVersion: "0.8.1"
version: 0.8.4
appVersion: "0.8.4"
keywords:
- ai
- memory
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.8.1",
"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.8.1"
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.8.1",
"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.8.1"
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.8.1",
"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.8.1",
"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.8.1"
__version__ = "0.8.4"
+33 -32
View File
@@ -56,6 +56,7 @@ BACKUP_TABLES = [
"observation_history",
"mental_models",
"mental_model_history",
"knowledge_pages",
"directives",
"async_operations",
"webhooks",
@@ -256,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:
@@ -284,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
@@ -327,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()
@@ -340,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(
@@ -347,6 +347,7 @@ def run_db_migration(
schema=schema,
base_schema=config.database_schema,
embedding_dimension=embedding_dimension,
ensure_extensions=not skip_extension_reconcile,
)
)
@@ -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,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,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,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)
@@ -6,8 +6,11 @@ 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 (so a row round-trips
losslessly on revert) plus:
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
@@ -49,13 +52,18 @@ def _pg_upgrade() -> None:
# 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. the
# embedding vector and 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.
# 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, "
@@ -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,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)
@@ -142,6 +142,9 @@ _TABLES: tuple[str, ...] = (
# 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,
@@ -149,7 +152,6 @@ _TABLES: tuple[str, ...] = (
document_id VARCHAR2(512),
chunk_id VARCHAR2(512),
text CLOB NOT NULL,
embedding VECTOR(384, FLOAT32),
context CLOB,
event_date TIMESTAMP WITH TIME ZONE NOT NULL,
occurred_start TIMESTAMP WITH TIME ZONE,
File diff suppressed because it is too large Load Diff
+20 -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.
@@ -135,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,
@@ -144,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)
+472 -17
View File
@@ -142,11 +142,35 @@ ENV_LLM_REASONING_EFFORT = "HINDSIGHT_API_LLM_REASONING_EFFORT"
ENV_LLM_GROQ_SERVICE_TIER = "HINDSIGHT_API_LLM_GROQ_SERVICE_TIER"
ENV_LLM_OPENAI_SERVICE_TIER = "HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER"
ENV_LLM_BEDROCK_SERVICE_TIER = "HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER"
ENV_LLM_GEMINI_SERVICE_TIER = "HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER"
ENV_LLM_EXTRA_BODY = "HINDSIGHT_API_LLM_EXTRA_BODY"
ENV_LLM_DEFAULT_HEADERS = "HINDSIGHT_API_LLM_DEFAULT_HEADERS"
ENV_LLM_STRICT_SCHEMA = "HINDSIGHT_API_LLM_STRICT_SCHEMA"
ENV_LLM_SEND_BANK_AS_USER = "HINDSIGHT_API_LLM_SEND_BANK_AS_USER"
# Per-operation sampling temperature. Each internal LLM call uses a temperature
# tuned for its task (deterministic extraction vs. creative reflection). These
# expose those as overridable knobs. Resolution per operation:
# per-operation env -> global env (ENV_LLM_TEMPERATURE) -> built-in default.
# A value of "none"/"default"/"" (or "off") omits the temperature parameter
# entirely, for models that reject explicit temperatures (e.g. Azure GPT-5.5,
# which only accepts the default value) -- see issue #2459.
ENV_LLM_TEMPERATURE = "HINDSIGHT_API_LLM_TEMPERATURE"
ENV_LLM_TEMPERATURE_VERIFICATION = "HINDSIGHT_API_LLM_TEMPERATURE_VERIFICATION"
ENV_LLM_TEMPERATURE_RETAIN = "HINDSIGHT_API_LLM_TEMPERATURE_RETAIN"
ENV_LLM_TEMPERATURE_REFLECT = "HINDSIGHT_API_LLM_TEMPERATURE_REFLECT"
ENV_LLM_TEMPERATURE_CONSOLIDATION = "HINDSIGHT_API_LLM_TEMPERATURE_CONSOLIDATION"
# Multi-LLM strategy. Extra LLMs are configured by index alongside the unindexed
# primary (e.g. HINDSIGHT_API_LLM_1_PROVIDER, HINDSIGHT_API_LLM_2_PROVIDER, ...),
# and HINDSIGHT_API_LLM_STRATEGY (JSON) selects how to route across them — see
# _parse_llm_members / _parse_llm_strategy below. Each operation can override the
# global chain with its own HINDSIGHT_API_<OP>_LLM_<n>_* members + _STRATEGY.
ENV_LLM_STRATEGY = "HINDSIGHT_API_LLM_STRATEGY"
ENV_RETAIN_LLM_STRATEGY = "HINDSIGHT_API_RETAIN_LLM_STRATEGY"
ENV_REFLECT_LLM_STRATEGY = "HINDSIGHT_API_REFLECT_LLM_STRATEGY"
ENV_CONSOLIDATION_LLM_STRATEGY = "HINDSIGHT_API_CONSOLIDATION_LLM_STRATEGY"
# LiteLLM Router chain — provider-specific config consumed by the "litellmrouter"
# provider. Each entry is a deployment; the Router tries them in declared order and
# falls back to the next on transient errors (5xx, rate-limit, timeout).
@@ -155,15 +179,75 @@ ENV_LLM_SEND_BANK_AS_USER = "HINDSIGHT_API_LLM_SEND_BANK_AS_USER"
# disambiguates from the embeddings/reranker LITELLM_* settings.
ENV_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG"
# Per-operation temperature defaults (preserve historical hardcoded values).
DEFAULT_LLM_TEMPERATURE_VERIFICATION = 0.0 # connection check
DEFAULT_LLM_TEMPERATURE_RETAIN = 0.1 # fact extraction
DEFAULT_LLM_TEMPERATURE_REFLECT = 0.9 # reflect "thinking"
DEFAULT_LLM_TEMPERATURE_CONSOLIDATION = 0.0 # mental-model delta / dedup
# Defaults for service tiers
DEFAULT_LLM_GROQ_SERVICE_TIER = "auto" # "on_demand", "flex", or "auto"
DEFAULT_LLM_OPENAI_SERVICE_TIER = None # None (default) or "flex" (50% cheaper)
DEFAULT_LLM_BEDROCK_SERVICE_TIER = None # None (default), "flex", "priority", or "reserved"
DEFAULT_LLM_GEMINI_SERVICE_TIER = None # None (default) or "flex" (50% cheaper best-effort tier)
DEFAULT_LLM_EXTRA_BODY = None # None = no extra body params; JSON dict merged into OpenAI extra_body
DEFAULT_LLM_DEFAULT_HEADERS = (
None # None = no extra headers; JSON dict passed as default_headers to provider SDK clients
)
def parse_gemini_service_tier(value: str | None) -> str | None:
"""Normalize and validate the Gemini service tier."""
tier = value or None
valid_tiers = (None, "flex")
if tier not in valid_tiers:
raise ValueError(
f"Invalid HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER: "
f"{tier!r}. Must be one of: {', '.join(t for t in valid_tiers if t is not None)}."
)
return tier
# Sentinel strings that, as a temperature value, mean "omit the temperature
# parameter entirely" rather than a numeric setting.
_TEMPERATURE_OMIT_VALUES = frozenset({"", "none", "default", "off", "unset"})
def _parse_temperature(raw: str) -> float | None:
"""Parse a raw temperature env value into a float, or None to omit it.
Returns None for the omit sentinels (so the temperature parameter is dropped
from the LLM call); otherwise parses a float and validates the 0.0-2.0 range.
"""
if raw.strip().lower() in _TEMPERATURE_OMIT_VALUES:
return None
try:
value = float(raw)
except ValueError as e:
raise ValueError(
f"Invalid LLM temperature {raw!r}: must be a number in [0.0, 2.0] "
f"or one of {sorted(_TEMPERATURE_OMIT_VALUES)} to omit it."
) from e
if not 0.0 <= value <= 2.0:
raise ValueError(f"Invalid LLM temperature {value}: must be in [0.0, 2.0].")
return value
def _resolve_operation_temperature(operation_env: str, default: float) -> float | None:
"""Resolve a per-operation temperature: per-op env -> global env -> default.
The omit sentinels resolve to None at any layer, so a single
``HINDSIGHT_API_LLM_TEMPERATURE=none`` drops temperature from every operation
that has no explicit per-operation override.
"""
raw = os.getenv(operation_env)
if raw is None:
raw = os.getenv(ENV_LLM_TEMPERATURE)
if raw is None:
return default
return _parse_temperature(raw)
# Per-operation LLM configuration (optional, falls back to global LLM config)
ENV_RETAIN_LLM_PROVIDER = "HINDSIGHT_API_RETAIN_LLM_PROVIDER"
ENV_RETAIN_LLM_API_KEY = "HINDSIGHT_API_RETAIN_LLM_API_KEY"
@@ -257,6 +341,11 @@ ENV_RERANKER_OPENROUTER_API_KEY = "HINDSIGHT_API_RERANKER_OPENROUTER_API_KEY"
ENV_RERANKER_OPENROUTER_MODEL = "HINDSIGHT_API_RERANKER_OPENROUTER_MODEL"
ENV_RERANKER_OPENROUTER_BASE_URL = "HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL"
# Requesty configuration (OpenAI-compatible gateway; embeddings)
ENV_REQUESTY_API_KEY = "HINDSIGHT_API_REQUESTY_API_KEY"
ENV_EMBEDDINGS_REQUESTY_API_KEY = "HINDSIGHT_API_EMBEDDINGS_REQUESTY_API_KEY"
ENV_EMBEDDINGS_REQUESTY_MODEL = "HINDSIGHT_API_EMBEDDINGS_REQUESTY_MODEL"
# ZeroEntropy configuration (embeddings)
ENV_EMBEDDINGS_ZEROENTROPY_API_KEY = "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY"
ENV_EMBEDDINGS_ZEROENTROPY_MODEL = "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_MODEL"
@@ -354,8 +443,10 @@ ENV_ACCESS_LOG = "HINDSIGHT_API_ACCESS_LOG"
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
ENV_MCP_ENABLED_TOOLS = "HINDSIGHT_API_MCP_ENABLED_TOOLS"
ENV_MCP_STATELESS = "HINDSIGHT_API_MCP_STATELESS"
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
ENV_ENABLE_BANK_CONFIG_API = "HINDSIGHT_API_ENABLE_BANK_CONFIG_API"
ENV_ENABLE_BANK_LLM_HEALTH = "HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH"
ENV_ENABLE_DRY_RUN_EXTRACT = "HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT"
ENV_DEFAULT_BANK_TEMPLATE = "HINDSIGHT_API_DEFAULT_BANK_TEMPLATE"
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
@@ -374,6 +465,7 @@ ENV_OTEL_EXPORTER_OTLP_HEADERS = "HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS"
ENV_OTEL_SERVICE_NAME = "HINDSIGHT_API_OTEL_SERVICE_NAME"
ENV_OTEL_DEPLOYMENT_ENVIRONMENT = "HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT"
ENV_METRICS_INCLUDE_BANK_ID = "HINDSIGHT_API_METRICS_INCLUDE_BANK_ID"
ENV_METRICS_BACKLOG_ENABLED = "HINDSIGHT_API_METRICS_BACKLOG_ENABLED"
# Vertex AI configuration
ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"
@@ -396,6 +488,7 @@ ENV_LLM_PROMPT_CACHE_ENABLED = "HINDSIGHT_API_LLM_PROMPT_CACHE_ENABLED"
# Retain settings
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
ENV_RETAIN_STRUCTURED_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE"
ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
ENV_RETAIN_MISSION = "HINDSIGHT_API_RETAIN_MISSION"
@@ -422,6 +515,11 @@ ENV_FILE_STORAGE_AZURE_ACCOUNT_NAME = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_
ENV_FILE_STORAGE_AZURE_ACCOUNT_KEY = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_KEY"
ENV_FILE_PARSER = "HINDSIGHT_API_FILE_PARSER"
ENV_FILE_PARSER_ALLOWLIST = "HINDSIGHT_API_FILE_PARSER_ALLOWLIST"
ENV_FILE_PARSER_MARKITDOWN_OCR_ENABLED = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED"
ENV_FILE_PARSER_MARKITDOWN_OCR_API_KEY = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY"
ENV_FILE_PARSER_MARKITDOWN_OCR_BASE_URL = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL"
ENV_FILE_PARSER_MARKITDOWN_OCR_MODEL = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL"
ENV_FILE_PARSER_MARKITDOWN_OCR_PROMPT = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_PROMPT"
ENV_FILE_PARSER_IRIS_TOKEN = "HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN"
ENV_FILE_PARSER_IRIS_ORG_ID = "HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID"
ENV_FILE_PARSER_LLAMA_PARSE_API_KEY = "HINDSIGHT_API_FILE_PARSER_LLAMA_PARSE_API_KEY"
@@ -475,10 +573,10 @@ ENV_LLAMACPP_EXTRA_ARGS = "HINDSIGHT_API_LLAMACPP_EXTRA_ARGS"
# Optimization flags
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
# Database migrations
ENV_RUN_MIGRATIONS_ON_STARTUP = "HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP"
ENV_MIGRATION_CONCURRENCY = "HINDSIGHT_API_MIGRATION_CONCURRENCY"
# Database connection pool
ENV_DB_POOL_MIN_SIZE = "HINDSIGHT_API_DB_POOL_MIN_SIZE"
@@ -550,6 +648,14 @@ ENV_RECALL_MAX_CANDIDATES_PER_SOURCE = "HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_
# Empty disables the feature.
ENV_RECALL_STRATEGY_BOOSTS = "HINDSIGHT_API_RECALL_STRATEGY_BOOSTS"
# Recency decay used by recall reranking (engine/search/reranking.py). The decay
# function maps a memory's age onto a freshness signal that nudges its final
# ranking via a small multiplicative boost. "linear" (default) preserves the
# historical behaviour; "exponential" decays by half-life; "none" disables it.
ENV_RECENCY_DECAY_FUNCTION = "HINDSIGHT_API_RECENCY_DECAY_FUNCTION"
ENV_RECENCY_DECAY_LINEAR_WINDOW_DAYS = "HINDSIGHT_API_RECENCY_DECAY_LINEAR_WINDOW_DAYS"
ENV_RECENCY_DECAY_HALFLIFE_DAYS = "HINDSIGHT_API_RECENCY_DECAY_HALFLIFE_DAYS"
# Audit log settings
ENV_AUDIT_LOG_ENABLED = "HINDSIGHT_API_AUDIT_LOG_ENABLED"
ENV_AUDIT_LOG_ACTIONS = "HINDSIGHT_API_AUDIT_LOG_ACTIONS"
@@ -563,6 +669,7 @@ ENV_LLM_TRACE_MAX_CHARS = "HINDSIGHT_API_LLM_TRACE_MAX_CHARS"
# Background maintenance settings
ENV_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS = "HINDSIGHT_API_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS"
ENV_MENTAL_MODEL_REFRESH_TICK_SECONDS = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_TICK_SECONDS"
# Disposition settings
ENV_DISPOSITION_SKEPTICISM = "HINDSIGHT_API_DISPOSITION_SKEPTICISM"
@@ -585,6 +692,7 @@ PROVIDER_DEFAULT_MODELS = {
"deepseek": "deepseek-v4-flash",
"zai": "glm-4.5-flash",
"opencode-go": "deepseek-v4-flash",
"atlas": "deepseek-ai/deepseek-v4-pro",
"ollama": "gemma3:12b",
"ollama-cloud": "gemma3:12b",
"llamacpp": "gemma-4-e2b-it",
@@ -598,6 +706,7 @@ PROVIDER_DEFAULT_MODELS = {
"bedrock": "us.amazon.nova-2-lite-v1:0",
"volcano": "doubao-pro-32k",
"openrouter": "qwen/qwen3.5-9b",
"requesty": "openai/gpt-4o-mini",
"fireworks": "accounts/fireworks/models/llama-v3p1-8b-instruct",
"nous": "deepseek/deepseek-v4-flash",
}
@@ -688,6 +797,14 @@ DEFAULT_RECALL_MAX_CANDIDATES_PER_SOURCE = 0
# "graph:high,semantic:low"). Empty disables the feature. See
# ENV_RECALL_STRATEGY_BOOSTS for the full rationale.
DEFAULT_RECALL_STRATEGY_BOOSTS = ""
# Recency decay shape used by recall reranking. "linear" reproduces the
# historical straight-line decay; defaults below keep behaviour unchanged.
RECENCY_DECAY_FUNCTIONS = ("linear", "exponential", "none")
DEFAULT_RECENCY_DECAY_FUNCTION = "linear"
# Linear: days over which freshness decays from 1.0 to its 0.1 floor.
DEFAULT_RECENCY_DECAY_LINEAR_WINDOW_DAYS = 365.0
# Exponential: age (days) at which the recency signal is neutral (0.5).
DEFAULT_RECENCY_DECAY_HALFLIFE_DAYS = 90.0
# Retrieval arms that can be boosted; mirrors fusion.py source_names.
RECALL_STRATEGY_NAMES = ("semantic", "bm25", "graph", "temporal")
# User-facing priority levels. Kept in sync with recall_boost.BOOST_LEVELS by a
@@ -746,6 +863,9 @@ DEFAULT_EMBEDDINGS_OPENROUTER_MODEL = "perplexity/pplx-embed-v1-0.6b"
DEFAULT_RERANKER_OPENROUTER_MODEL = "cohere/rerank-v3.5"
DEFAULT_RERANKER_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1/rerank"
# Requesty defaults
DEFAULT_EMBEDDINGS_REQUESTY_MODEL = "openai/text-embedding-3-small"
# ZeroEntropy defaults
DEFAULT_EMBEDDINGS_ZEROENTROPY_MODEL = "zembed-1"
# Shared between embeddings (zembed-1) and reranker (zerank-*) — the host is the same.
@@ -801,7 +921,12 @@ DEFAULT_ACCESS_LOG = False
DEFAULT_MCP_ENABLED = True
DEFAULT_MCP_ENABLED_TOOLS: list[str] | None = None # None = all tools enabled
DEFAULT_MCP_STATELESS = False # False = stateful (supports SSE/GET); True = stateless (POST-only)
DEFAULT_MCP_INSTRUCTIONS = None
DEFAULT_ENABLE_BANK_CONFIG_API = True
# Dry-run extraction is a preview tool that makes a real LLM call but stores nothing. Enabled by
# default; set HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=false to remove the endpoint (e.g. to cap
# provider cost/abuse on untrusted deployments).
DEFAULT_ENABLE_DRY_RUN_EXTRACT = True
# The per-bank LLM connectivity probe makes a real provider call, so it's OFF by
# default (cost/abuse concerns) and must be explicitly enabled to expose the endpoint.
DEFAULT_ENABLE_BANK_LLM_HEALTH = False
@@ -840,6 +965,10 @@ DEFAULT_RETAIN_BATCH_POLL_INTERVAL_SECONDS = 60 # Batch API polling interval in
DEFAULT_FILE_STORAGE_TYPE = "native" # PostgreSQL BYTEA storage
DEFAULT_FILE_PARSER = "markitdown" # Default parser fallback chain (comma-separated, e.g. "iris,markitdown")
DEFAULT_FILE_PARSER_ALLOWLIST = None # Allowlist of parsers clients may request (None = all registered parsers)
DEFAULT_FILE_PARSER_MARKITDOWN_OCR_ENABLED = False
DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT = """You are a precise OCR transcription engine.
Transcribe only the visible text in the image. Do not describe the image, summarize it, translate it, infer missing content, or add commentary. Preserve the original language, wording, numbers, punctuation, capitalization, and reading order. Reconstruct headings, lists, key-value fields, stamps, and tables as clean Markdown when the layout is clear. If text is unreadable or uncertain, write [unclear] for that span. Return only the extracted Markdown."""
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE_MB = 100 # Max total batch size in MB (all files combined)
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE = 10 # Max files per batch upload
DEFAULT_ENABLE_FILE_UPLOAD_API = True # Enable file upload endpoint
@@ -900,6 +1029,10 @@ DEFAULT_OBSERVATION_SCOPE_LIMITS: list | None = None
# Database migrations
DEFAULT_RUN_MIGRATIONS_ON_STARTUP = True
# Number of tenant schemas to migrate concurrently. Each schema runs in its own
# process (Alembic's command.upgrade() is not thread-safe); within a schema the
# work is always sequential. 1 = fully sequential (the safe default).
DEFAULT_MIGRATION_CONCURRENCY = 1
# Database connection pool
DEFAULT_DB_POOL_MIN_SIZE = 5
@@ -954,6 +1087,7 @@ DEFAULT_OTEL_TRACES_ENABLED = False # Disabled by default for backward compatib
DEFAULT_OTEL_SERVICE_NAME = "hindsight-api"
DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT = "development"
DEFAULT_METRICS_INCLUDE_BANK_ID = False # Disabled by default to avoid high-cardinality OTel metric growth
DEFAULT_METRICS_BACKLOG_ENABLED = False # Disabled by default: runs periodic per-schema COUNT queries
# Audit log defaults
DEFAULT_AUDIT_LOG_ENABLED = False # Disabled by default
@@ -972,6 +1106,11 @@ DEFAULT_LLM_TRACE_MAX_CHARS = 50000 # Truncate stored input/output beyond this
# 0 disables the reconcile sweep.
DEFAULT_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS = 300
# How often the maintenance loop checks for cron-scheduled mental models that are
# due for a refresh. This is the *check* cadence; the actual schedule is the
# per-model cron expression in the mental model's trigger. 0 disables the sweep.
DEFAULT_MENTAL_MODEL_REFRESH_TICK_SECONDS = 60
# Default MCP tool descriptions (can be customized via env vars)
DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory.
@@ -1077,6 +1216,63 @@ def _parse_optional_positive_int(name: str, raw: str | None) -> int | None:
return _parse_positive_int(name, raw, 1)
def _validate_retain_chunking_int(name: str, value: Any) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{name} must be an integer, got {value!r}")
if value < 1:
raise ValueError(f"{name} must be >= 1, got {value}")
return value
def validate_retain_chunking_config(
retain_chunk_size: Any,
retain_structured_chunk_size: Any,
*,
retain_chunk_size_name: str = "retain_chunk_size",
retain_structured_chunk_size_name: str = "retain_structured_chunk_size",
) -> None:
"""Validate retain chunking size fields.
Defaults emit field-style names ("retain_chunk_size") so API/PATCH callers
don't have to override them. The startup validator (HindsightConfig.validate)
overrides to env-style names ("HINDSIGHT_API_RETAIN_CHUNK_SIZE") for env
misconfig errors.
"""
_validate_retain_chunking_int(retain_chunk_size_name, retain_chunk_size)
if retain_structured_chunk_size is None:
return
_validate_retain_chunking_int(
retain_structured_chunk_size_name,
retain_structured_chunk_size,
)
def validate_retain_completion_token_budget(
*,
llm_provider: str,
retain_max_completion_tokens: int,
retain_chunk_size: int,
retain_llm_model: str | None = None,
llm_model: str | None = None,
retain_llm_provider: str | None = None,
retain_max_completion_tokens_name: str = "retain_max_completion_tokens",
retain_chunk_size_name: str = "retain_chunk_size",
) -> None:
"""Validate that retain LLM output capacity exceeds the configured chunk size."""
if llm_provider == "none" or retain_max_completion_tokens > retain_chunk_size:
return
raise ValueError(
f"Invalid configuration: {retain_max_completion_tokens_name} "
f"({retain_max_completion_tokens}) must be greater than "
f"{retain_chunk_size_name} ({retain_chunk_size}). "
f"\n\nYou have two options to fix this:"
f"\n 1. Increase {retain_max_completion_tokens_name} to a value > {retain_chunk_size}"
f"\n 2. Use a model that supports at least {retain_max_completion_tokens} output tokens"
f"\n (current model: {retain_llm_model or llm_model}, "
f"provider: {retain_llm_provider or llm_provider})"
)
def _parse_optional_choice(name: str, raw: str | None, allowed: frozenset[str]) -> str | None:
"""Parse an optional string env var constrained to a small allowlist."""
if raw is None or raw == "":
@@ -1112,6 +1308,18 @@ def _validate_recall_budget_function(function: str) -> str:
return function_lower
def _validate_recency_decay_function(function: str) -> str:
"""Validate and normalize the recency decay function."""
function_lower = function.lower()
if function_lower not in RECENCY_DECAY_FUNCTIONS:
logger.warning(
f"Invalid recency decay function '{function}', must be one of {RECENCY_DECAY_FUNCTIONS}. "
f"Defaulting to '{DEFAULT_RECENCY_DECAY_FUNCTION}'."
)
return DEFAULT_RECENCY_DECAY_FUNCTION
return function_lower
def _parse_bank_priority(raw: str) -> dict[str, int]:
"""Parse ``bank-pattern:priority,...`` into ``{pattern: priority}``.
@@ -1167,6 +1375,132 @@ def _parse_llm_router_config(env_var: str) -> dict | None:
raise ValueError(f"Invalid {env_var}: invalid JSON: {e}") from e
@dataclass
class LLMMemberConfig:
"""One extra LLM in a multi-LLM chain, configured via indexed env vars.
Mirrors the subset of LLM settings an indexed member supports
(``HINDSIGHT_API_<OP>LLM_<n>_*``). The unindexed config remains the primary
member (index 0); these describe members 1..N.
"""
provider: str
api_key: str | None
model: str
base_url: str | None
reasoning_effort: str | None
extra_body: dict | None
default_headers: dict | None
bedrock_service_tier: str | None
gemini_service_tier: str | None
vertexai_project_id: str | None = None
vertexai_region: str | None = None
vertexai_service_account_key: str | None = None
litellmrouter_config: dict | None = None
# Valid multi-LLM strategy modes.
LLM_STRATEGY_FAILOVER = "failover"
LLM_STRATEGY_ROUND_ROBIN = "round-robin"
_VALID_LLM_STRATEGY_MODES = (LLM_STRATEGY_FAILOVER, LLM_STRATEGY_ROUND_ROBIN)
@dataclass
class LLMStrategyConfig:
"""How to route a request across the members of a multi-LLM chain.
``mode`` is "failover" (try members in order) or "round-robin" (rotate the
starting member per request, then fall through the rest on error). ``weights``
is round-robin only: positive integers, one per member (primary first), giving
an unbalanced rotation; ``None`` means uniform.
"""
mode: str
weights: list[int] | None = None
def _parse_llm_strategy(raw: str | None) -> LLMStrategyConfig | None:
"""Parse a multi-LLM strategy from a JSON env var.
Returns ``None`` when unset. The value must be a JSON object with a ``mode``
of "failover" or "round-robin"; ``weights`` (round-robin only) must be a list
of positive ints. Raises ``ValueError`` on any malformed input so
misconfiguration fails fast at startup rather than silently degrading.
"""
text = (raw or "").strip()
if not text:
return None
try:
parsed = json.loads(text)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid {ENV_LLM_STRATEGY}: invalid JSON: {e}") from e
if not isinstance(parsed, dict):
raise ValueError(f"Invalid LLM strategy: expected a JSON object, got {type(parsed).__name__}")
mode = parsed.get("mode")
if mode not in _VALID_LLM_STRATEGY_MODES:
raise ValueError(f"Invalid LLM strategy mode {mode!r}. Must be one of: {', '.join(_VALID_LLM_STRATEGY_MODES)}.")
weights = parsed.get("weights")
if weights is not None:
if mode != LLM_STRATEGY_ROUND_ROBIN:
raise ValueError(f"LLM strategy 'weights' is only valid with mode '{LLM_STRATEGY_ROUND_ROBIN}'.")
if not isinstance(weights, list) or not weights or not all(isinstance(w, int) and w > 0 for w in weights):
raise ValueError("LLM strategy 'weights' must be a non-empty list of positive integers.")
return LLMStrategyConfig(mode=mode, weights=weights)
def _parse_llm_members(prefix: str) -> list[LLMMemberConfig]:
"""Parse indexed extra-LLM members for an operation env prefix.
``prefix`` is the operation segment in the env name: ``""`` (global),
``"RETAIN_"``, ``"REFLECT_"`` or ``"CONSOLIDATION_"``. Members are read from
``HINDSIGHT_API_{prefix}LLM_{n}_PROVIDER`` for n = 1, 2, ... and scanning
stops at the first index whose ``_PROVIDER`` is unset (so indices must be
contiguous from 1). ``MODEL`` defaults to the provider's default model.
"""
from .engine.llm_wrapper import requires_api_key
members: list[LLMMemberConfig] = []
index = 1
while True:
base = f"HINDSIGHT_API_{prefix}LLM_{index}_"
provider = os.getenv(base + "PROVIDER")
if not provider:
break
api_key = os.getenv(base + "API_KEY") or None
if not api_key and requires_api_key(provider):
raise ValueError(
f"{base}API_KEY is required for provider '{provider}' (member {index} of the multi-LLM chain)."
)
gemini_service_tier = os.getenv(base + "GEMINI_SERVICE_TIER")
members.append(
LLMMemberConfig(
provider=provider,
api_key=api_key,
model=os.getenv(base + "MODEL") or _get_default_model_for_provider(provider),
base_url=os.getenv(base + "BASE_URL") or None,
reasoning_effort=os.getenv(base + "REASONING_EFFORT") or None,
extra_body=json.loads(os.getenv(base + "EXTRA_BODY", "null")),
default_headers=json.loads(os.getenv(base + "DEFAULT_HEADERS", "null")),
bedrock_service_tier=os.getenv(base + "BEDROCK_SERVICE_TIER") or None,
gemini_service_tier=(
parse_gemini_service_tier(gemini_service_tier) if provider.lower() == "gemini" else None
),
vertexai_project_id=os.getenv(base + "VERTEXAI_PROJECT_ID") or None,
vertexai_region=os.getenv(base + "VERTEXAI_REGION") or None,
vertexai_service_account_key=os.getenv(base + "VERTEXAI_SERVICE_ACCOUNT_KEY") or None,
litellmrouter_config=_parse_llm_router_config(base + "LITELLMROUTER_CONFIG"),
)
)
index += 1
return members
def _parse_default_bank_template(raw: str | None) -> dict | None:
"""
Parse HINDSIGHT_API_DEFAULT_BANK_TEMPLATE as JSON.
@@ -1230,6 +1564,7 @@ class HindsightConfig:
llm_groq_service_tier: str # Groq: "on_demand", "flex", or "auto"
llm_openai_service_tier: str | None # OpenAI: None (default) or "flex" (50% cheaper)
llm_bedrock_service_tier: str | None # Bedrock: None (default), "flex", "priority", or "reserved"
llm_gemini_service_tier: str | None # Gemini: None (default) or "flex" (50% cheaper)
llm_extra_body: (
dict | None
) # Extra body params merged into OpenAI-compatible API calls (e.g. {"chat_template_kwargs": {"enable_thinking": true}})
@@ -1243,6 +1578,14 @@ class HindsightConfig:
# overrides a `user` the caller already set.
llm_send_bank_as_user: bool
# Per-operation sampling temperature. None means the temperature parameter is
# omitted from the call (for models that reject explicit temperatures). See
# ENV_LLM_TEMPERATURE and _resolve_operation_temperature.
llm_temperature_verification: float | None
llm_temperature_retain: float | None
llm_temperature_reflect: float | None
llm_temperature_consolidation: float | None
# LiteLLM Router chain (provider-specific; consumed by the "litellmrouter" provider).
# List of deployment dicts evaluated in order with fallback on transient errors.
# Each entry: {"provider": str, "model": str, "api_key": str | None, "base_url": str | None}.
@@ -1332,6 +1675,8 @@ class HindsightConfig:
embeddings_cohere_output_dimensions: int | None
embeddings_openrouter_api_key: str | None
embeddings_openrouter_model: str
embeddings_requesty_api_key: str | None
embeddings_requesty_model: str
embeddings_litellm_api_base: str
embeddings_litellm_api_key: str | None
embeddings_litellm_model: str
@@ -1367,6 +1712,9 @@ class HindsightConfig:
bm25_min_score: float
recall_max_candidates_per_source: int
recall_strategy_boosts: dict[str, str]
recency_decay_function: str
recency_decay_linear_window_days: float
recency_decay_halflife_days: float
reranker_cohere_api_key: str | None
reranker_cohere_model: str
reranker_cohere_base_url: str | None
@@ -1410,8 +1758,10 @@ class HindsightConfig:
mcp_enabled: bool
mcp_enabled_tools: list[str] | None # None = all tools; explicit list = allowlist
mcp_stateless: bool # True = stateless HTTP (POST-only); False = stateful (supports GET/SSE)
mcp_instructions: str | None # Additional instructions appended to retain/recall MCP tool descriptions
enable_bank_config_api: bool
enable_bank_llm_health: bool
enable_dry_run_extract: bool
# Default bank template (static, server-level only). When set, the manifest is applied
# to every newly-created bank, overriding the env/config defaults for any fields it sets.
default_bank_template: dict | None
@@ -1430,6 +1780,7 @@ class HindsightConfig:
# Retain settings
retain_max_completion_tokens: int
retain_chunk_size: int
retain_structured_chunk_size: int | None
retain_extract_causal_links: bool
retain_extraction_mode: str
retain_mission: str | None
@@ -1534,10 +1885,10 @@ class HindsightConfig:
# Optimization flags
skip_llm_verification: bool
lazy_reranker: bool
# Database migrations
run_migrations_on_startup: bool
migration_concurrency: int
# Database connection pool
db_pool_min_size: int
@@ -1571,6 +1922,7 @@ class HindsightConfig:
otel_service_name: str
otel_deployment_environment: str
metrics_include_bank_id: bool
metrics_backlog_enabled: bool
# Audit log configuration (static - server-level only)
audit_log_enabled: bool # Master switch for audit logging
@@ -1587,6 +1939,9 @@ class HindsightConfig:
# Interval for the periodic sweep that re-schedules consolidation for banks with
# eligible-but-unscheduled facts. 0 = disabled.
consolidation_reconcile_interval_seconds: int
# How often the maintenance loop checks for cron-scheduled mental models due for
# refresh (the per-model schedule lives in the mental model trigger). 0 = disabled.
mental_model_refresh_tick_seconds: int
# Webhook configuration (static - server-level only, not per-bank)
webhook_url: str | None # Global webhook URL (None = disabled)
@@ -1605,6 +1960,25 @@ class HindsightConfig:
embeddings_zeroentropy_encoding_format: str = DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT
embeddings_zeroentropy_batch_size: int = DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE
embeddings_zeroentropy_latency: str | None = DEFAULT_EMBEDDINGS_ZEROENTROPY_LATENCY
file_parser_markitdown_ocr_enabled: bool = DEFAULT_FILE_PARSER_MARKITDOWN_OCR_ENABLED
file_parser_markitdown_ocr_api_key: str | None = None
file_parser_markitdown_ocr_base_url: str | None = None
file_parser_markitdown_ocr_model: str | None = None
file_parser_markitdown_ocr_prompt: str = DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT
# Multi-LLM chains (static, server-level). Index 0 of each chain is the
# corresponding unindexed/base LLM config above; these hold the extra indexed
# members and the routing strategy. Per-op members fall back to the global
# members when unset (see MemoryEngine._build_llm). Credential fields (members
# embed api_keys/base_urls).
llm_members: list[LLMMemberConfig] = field(default_factory=list)
llm_strategy: LLMStrategyConfig | None = None
retain_llm_members: list[LLMMemberConfig] = field(default_factory=list)
retain_llm_strategy: LLMStrategyConfig | None = None
reflect_llm_members: list[LLMMemberConfig] = field(default_factory=list)
reflect_llm_strategy: LLMStrategyConfig | None = None
consolidation_llm_members: list[LLMMemberConfig] = field(default_factory=list)
consolidation_llm_strategy: LLMStrategyConfig | None = None
# Class-level sets for configuration categorization
@@ -1620,6 +1994,11 @@ class HindsightConfig:
"retain_llm_litellmrouter_config",
"reflect_llm_litellmrouter_config",
"consolidation_llm_litellmrouter_config",
# Multi-LLM chains — members embed api_keys and base_urls
"llm_members",
"retain_llm_members",
"reflect_llm_members",
"consolidation_llm_members",
# Base URLs (could expose infrastructure)
"llm_base_url",
"retain_llm_base_url",
@@ -1645,6 +2024,8 @@ class HindsightConfig:
"file_storage_gcs_service_account_key",
"file_storage_azure_account_key",
# File parser credentials
"file_parser_markitdown_ocr_api_key",
"file_parser_markitdown_ocr_base_url",
"file_parser_iris_token",
"file_parser_llama_parse_api_key",
}
@@ -1657,6 +2038,7 @@ class HindsightConfig:
"mcp_enabled_tools",
# Retention settings (behavioral)
"retain_chunk_size",
"retain_structured_chunk_size",
"retain_extraction_mode",
"retain_mission",
"retain_custom_instructions",
@@ -1807,6 +2189,9 @@ class HindsightConfig:
f"Note: 'standard' is not a valid Bedrock service tier -- use unset for default tier."
)
# Validate gemini_service_tier
self.llm_gemini_service_tier = parse_gemini_service_tier(self.llm_gemini_service_tier)
# When LLM provider is "none", force chunks-only mode and disable LLM-dependent features
if self.llm_provider == "none":
self.retain_extraction_mode = "chunks"
@@ -1816,20 +2201,23 @@ class HindsightConfig:
"disabling observations/consolidation. Reflect will return HTTP 400."
)
# RETAIN_MAX_COMPLETION_TOKENS must be greater than RETAIN_CHUNK_SIZE
# to ensure the LLM has enough output capacity to extract facts from chunks
# (not applicable when provider is "none" since no LLM calls are made)
if self.llm_provider != "none" and self.retain_max_completion_tokens <= self.retain_chunk_size:
raise ValueError(
f"Invalid configuration: HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS "
f"({self.retain_max_completion_tokens}) must be greater than "
f"HINDSIGHT_API_RETAIN_CHUNK_SIZE ({self.retain_chunk_size}). "
f"\n\nYou have two options to fix this:"
f"\n 1. Increase HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS to a value > {self.retain_chunk_size}"
f"\n 2. Use a model that supports at least {self.retain_max_completion_tokens} output tokens"
f"\n (current model: {self.retain_llm_model or self.llm_model}, "
f"provider: {self.retain_llm_provider or self.llm_provider})"
)
validate_retain_chunking_config(
self.retain_chunk_size,
self.retain_structured_chunk_size,
retain_chunk_size_name="HINDSIGHT_API_RETAIN_CHUNK_SIZE",
retain_structured_chunk_size_name="HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE",
)
validate_retain_completion_token_budget(
llm_provider=self.llm_provider,
retain_max_completion_tokens=self.retain_max_completion_tokens,
retain_chunk_size=self.retain_chunk_size,
retain_llm_model=self.retain_llm_model,
llm_model=self.llm_model,
retain_llm_provider=self.retain_llm_provider,
retain_max_completion_tokens_name="HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS",
retain_chunk_size_name="HINDSIGHT_API_RETAIN_CHUNK_SIZE",
)
# Warn if local ML dependencies are missing when configured.
# Don't hard-fail here — the actual ImportError fires at model init time
@@ -1921,11 +2309,28 @@ class HindsightConfig:
llm_groq_service_tier=os.getenv(ENV_LLM_GROQ_SERVICE_TIER, DEFAULT_LLM_GROQ_SERVICE_TIER),
llm_openai_service_tier=os.getenv(ENV_LLM_OPENAI_SERVICE_TIER, DEFAULT_LLM_OPENAI_SERVICE_TIER),
llm_bedrock_service_tier=os.getenv(ENV_LLM_BEDROCK_SERVICE_TIER) or None,
llm_gemini_service_tier=(
parse_gemini_service_tier(os.getenv(ENV_LLM_GEMINI_SERVICE_TIER) or DEFAULT_LLM_GEMINI_SERVICE_TIER)
if llm_provider.lower() == "gemini"
else None
),
llm_extra_body=json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null")),
llm_default_headers=json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null")),
llm_strict_schema=os.getenv(ENV_LLM_STRICT_SCHEMA, str(DEFAULT_LLM_STRICT_SCHEMA)).lower() in ("true", "1"),
llm_send_bank_as_user=os.getenv(ENV_LLM_SEND_BANK_AS_USER, str(DEFAULT_LLM_SEND_BANK_AS_USER)).lower()
in ("true", "1"),
llm_temperature_verification=_resolve_operation_temperature(
ENV_LLM_TEMPERATURE_VERIFICATION, DEFAULT_LLM_TEMPERATURE_VERIFICATION
),
llm_temperature_retain=_resolve_operation_temperature(
ENV_LLM_TEMPERATURE_RETAIN, DEFAULT_LLM_TEMPERATURE_RETAIN
),
llm_temperature_reflect=_resolve_operation_temperature(
ENV_LLM_TEMPERATURE_REFLECT, DEFAULT_LLM_TEMPERATURE_REFLECT
),
llm_temperature_consolidation=_resolve_operation_temperature(
ENV_LLM_TEMPERATURE_CONSOLIDATION, DEFAULT_LLM_TEMPERATURE_CONSOLIDATION
),
llm_litellmrouter_config=_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG),
# Vertex AI
llm_vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or DEFAULT_LLM_VERTEXAI_PROJECT_ID,
@@ -2025,6 +2430,15 @@ class HindsightConfig:
if os.getenv(ENV_CONSOLIDATION_LLM_TIMEOUT)
else None,
consolidation_llm_litellmrouter_config=_parse_llm_router_config(ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG),
# Multi-LLM chains (indexed members + routing strategy)
llm_members=_parse_llm_members(""),
llm_strategy=_parse_llm_strategy(os.getenv(ENV_LLM_STRATEGY)),
retain_llm_members=_parse_llm_members("RETAIN_"),
retain_llm_strategy=_parse_llm_strategy(os.getenv(ENV_RETAIN_LLM_STRATEGY)),
reflect_llm_members=_parse_llm_members("REFLECT_"),
reflect_llm_strategy=_parse_llm_strategy(os.getenv(ENV_REFLECT_LLM_STRATEGY)),
consolidation_llm_members=_parse_llm_members("CONSOLIDATION_"),
consolidation_llm_strategy=_parse_llm_strategy(os.getenv(ENV_CONSOLIDATION_LLM_STRATEGY)),
# Embeddings
embeddings_provider=os.getenv(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER),
embeddings_local_model=os.getenv(ENV_EMBEDDINGS_LOCAL_MODEL, DEFAULT_EMBEDDINGS_LOCAL_MODEL),
@@ -2089,6 +2503,11 @@ class HindsightConfig:
or os.getenv(ENV_OPENROUTER_API_KEY)
or os.getenv(ENV_LLM_API_KEY),
embeddings_openrouter_model=os.getenv(ENV_EMBEDDINGS_OPENROUTER_MODEL, DEFAULT_EMBEDDINGS_OPENROUTER_MODEL),
# Requesty embeddings (with fallback to shared Requesty key, then LLM key)
embeddings_requesty_api_key=os.getenv(ENV_EMBEDDINGS_REQUESTY_API_KEY)
or os.getenv(ENV_REQUESTY_API_KEY)
or os.getenv(ENV_LLM_API_KEY),
embeddings_requesty_model=os.getenv(ENV_EMBEDDINGS_REQUESTY_MODEL, DEFAULT_EMBEDDINGS_REQUESTY_MODEL),
# ZeroEntropy embeddings
embeddings_zeroentropy_api_key=os.getenv(ENV_EMBEDDINGS_ZEROENTROPY_API_KEY)
or os.getenv("ZEROENTROPY_API_KEY"),
@@ -2195,6 +2614,15 @@ class HindsightConfig:
recall_strategy_boosts=_parse_strategy_boosts(
os.getenv(ENV_RECALL_STRATEGY_BOOSTS, DEFAULT_RECALL_STRATEGY_BOOSTS)
),
recency_decay_function=_validate_recency_decay_function(
os.getenv(ENV_RECENCY_DECAY_FUNCTION, DEFAULT_RECENCY_DECAY_FUNCTION)
),
recency_decay_linear_window_days=float(
os.getenv(ENV_RECENCY_DECAY_LINEAR_WINDOW_DAYS, str(DEFAULT_RECENCY_DECAY_LINEAR_WINDOW_DAYS))
),
recency_decay_halflife_days=float(
os.getenv(ENV_RECENCY_DECAY_HALFLIFE_DAYS, str(DEFAULT_RECENCY_DECAY_HALFLIFE_DAYS))
),
# Cohere reranker (with backward-compatible fallback to shared API key)
reranker_cohere_api_key=os.getenv(ENV_RERANKER_COHERE_API_KEY) or os.getenv(ENV_COHERE_API_KEY),
reranker_cohere_model=os.getenv(ENV_RERANKER_COHERE_MODEL, DEFAULT_RERANKER_COHERE_MODEL),
@@ -2270,10 +2698,13 @@ class HindsightConfig:
if os.getenv(ENV_MCP_ENABLED_TOOLS)
else DEFAULT_MCP_ENABLED_TOOLS,
mcp_stateless=os.getenv(ENV_MCP_STATELESS, str(DEFAULT_MCP_STATELESS)).lower() == "true",
mcp_instructions=os.getenv(ENV_MCP_INSTRUCTIONS) or DEFAULT_MCP_INSTRUCTIONS,
enable_bank_llm_health=os.getenv(ENV_ENABLE_BANK_LLM_HEALTH, str(DEFAULT_ENABLE_BANK_LLM_HEALTH)).lower()
== "true",
enable_bank_config_api=os.getenv(ENV_ENABLE_BANK_CONFIG_API, str(DEFAULT_ENABLE_BANK_CONFIG_API)).lower()
== "true",
enable_dry_run_extract=os.getenv(ENV_ENABLE_DRY_RUN_EXTRACT, str(DEFAULT_ENABLE_DRY_RUN_EXTRACT)).lower()
== "true",
default_bank_template=_parse_default_bank_template(os.getenv(ENV_DEFAULT_BANK_TEMPLATE)),
# Recall
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
@@ -2297,12 +2728,15 @@ class HindsightConfig:
),
# Optimization flags
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
# Retain settings
retain_max_completion_tokens=int(
os.getenv(ENV_RETAIN_MAX_COMPLETION_TOKENS, str(DEFAULT_RETAIN_MAX_COMPLETION_TOKENS))
),
retain_chunk_size=int(os.getenv(ENV_RETAIN_CHUNK_SIZE, str(DEFAULT_RETAIN_CHUNK_SIZE))),
retain_structured_chunk_size=_parse_optional_positive_int(
ENV_RETAIN_STRUCTURED_CHUNK_SIZE,
os.getenv(ENV_RETAIN_STRUCTURED_CHUNK_SIZE),
),
retain_extract_causal_links=os.getenv(
ENV_RETAIN_EXTRACT_CAUSAL_LINKS, str(DEFAULT_RETAIN_EXTRACT_CAUSAL_LINKS)
).lower()
@@ -2343,6 +2777,18 @@ class HindsightConfig:
file_parser_allowlist=_parse_str_list(os.getenv(ENV_FILE_PARSER_ALLOWLIST))
if os.getenv(ENV_FILE_PARSER_ALLOWLIST)
else None,
file_parser_markitdown_ocr_enabled=os.getenv(
ENV_FILE_PARSER_MARKITDOWN_OCR_ENABLED,
str(DEFAULT_FILE_PARSER_MARKITDOWN_OCR_ENABLED),
).lower()
in ("1", "true", "yes", "on"),
file_parser_markitdown_ocr_api_key=os.getenv(ENV_FILE_PARSER_MARKITDOWN_OCR_API_KEY) or None,
file_parser_markitdown_ocr_base_url=os.getenv(ENV_FILE_PARSER_MARKITDOWN_OCR_BASE_URL) or None,
file_parser_markitdown_ocr_model=os.getenv(ENV_FILE_PARSER_MARKITDOWN_OCR_MODEL) or None,
file_parser_markitdown_ocr_prompt=os.getenv(
ENV_FILE_PARSER_MARKITDOWN_OCR_PROMPT,
DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT,
),
file_parser_iris_token=os.getenv(ENV_FILE_PARSER_IRIS_TOKEN) or None,
file_parser_iris_org_id=os.getenv(ENV_FILE_PARSER_IRIS_ORG_ID) or None,
file_parser_llama_parse_api_key=os.getenv(ENV_FILE_PARSER_LLAMA_PARSE_API_KEY) or None,
@@ -2449,6 +2895,7 @@ class HindsightConfig:
memory_defense=None,
# Database migrations
run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true",
migration_concurrency=int(os.getenv(ENV_MIGRATION_CONCURRENCY, str(DEFAULT_MIGRATION_CONCURRENCY))),
# Database connection pool
db_pool_min_size=int(os.getenv(ENV_DB_POOL_MIN_SIZE, str(DEFAULT_DB_POOL_MIN_SIZE))),
db_pool_max_size=int(os.getenv(ENV_DB_POOL_MAX_SIZE, str(DEFAULT_DB_POOL_MAX_SIZE))),
@@ -2532,6 +2979,8 @@ class HindsightConfig:
otel_deployment_environment=os.getenv(ENV_OTEL_DEPLOYMENT_ENVIRONMENT, DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT),
metrics_include_bank_id=os.getenv(ENV_METRICS_INCLUDE_BANK_ID, str(DEFAULT_METRICS_INCLUDE_BANK_ID)).lower()
in ("true", "1", "yes"),
metrics_backlog_enabled=os.getenv(ENV_METRICS_BACKLOG_ENABLED, str(DEFAULT_METRICS_BACKLOG_ENABLED)).lower()
in ("true", "1", "yes"),
# Audit log configuration (static, server-level only)
audit_log_enabled=os.getenv(ENV_AUDIT_LOG_ENABLED, str(DEFAULT_AUDIT_LOG_ENABLED)).lower() == "true",
audit_log_actions=[
@@ -2556,6 +3005,12 @@ class HindsightConfig:
str(DEFAULT_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS),
)
),
mental_model_refresh_tick_seconds=int(
os.getenv(
ENV_MENTAL_MODEL_REFRESH_TICK_SECONDS,
str(DEFAULT_MENTAL_MODEL_REFRESH_TICK_SECONDS),
)
),
# Webhook configuration (static, server-level only)
webhook_url=os.getenv(ENV_WEBHOOK_URL) or DEFAULT_WEBHOOK_URL,
webhook_secret=os.getenv(ENV_WEBHOOK_SECRET) or DEFAULT_WEBHOOK_SECRET,
@@ -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
@@ -13,9 +13,18 @@ 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 Any, Awaitable, Callable
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:
@@ -66,17 +75,28 @@ class BankStatsCache:
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.
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:
@@ -96,7 +116,10 @@ class BankStatsCache:
value = await loader()
except BaseException as exc:
async with self._lock:
self._in_flight.pop(key, None)
# 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
@@ -106,8 +129,12 @@ class BankStatsCache:
raise
async with self._lock:
self._store_unlocked(key, value)
self._in_flight.pop(key, None)
# 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
@@ -115,8 +142,113 @@ class BankStatsCache:
async def invalidate(self, schema: str, bank_id: str) -> None:
"""Drop any cached stats for `(schema, bank_id)`."""
async with self._lock:
self._entries.pop((schema, bank_id), None)
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
@@ -98,7 +98,7 @@ _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"]
action: Literal["merge", "keep"] = "keep"
text: str = "" # the synthesized merged observation (when action == "merge")
reason: str = ""
@@ -224,13 +224,18 @@ async def _dedup_reconcile_create(
# 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()
updated_at = now(){search_vector_clause}
WHERE id = $3::uuid
""",
outcome.merged_text,
@@ -279,6 +284,11 @@ async def _dedup_reconcile_update(
# 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
@@ -289,7 +299,7 @@ async def _dedup_reconcile_update(
proof_count = (
SELECT count(DISTINCT e) FROM unnest(t.source_memory_ids || u.source_memory_ids) e
),
updated_at = now()
updated_at = now(){search_vector_clause}
FROM {fq_table("memory_units")} u
WHERE t.id = $2::uuid AND u.id = $3::uuid
""",
@@ -335,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 [])
@@ -346,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]]
@@ -362,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
@@ -376,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]]
@@ -436,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
@@ -448,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
@@ -627,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
@@ -636,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."""
@@ -663,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
@@ -1263,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}")
@@ -1489,8 +1537,10 @@ async def _process_memory_batch(
# 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(
@@ -1805,6 +1855,12 @@ async def _execute_update_action(
config = get_config()
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()
await conn.execute(
f"""
@@ -1817,7 +1873,7 @@ async def _execute_update_action(
updated_at = now(),
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))
mentioned_at = GREATEST(mentioned_at, COALESCE($8, mentioned_at)){search_vector_clause}
WHERE id = $5
""",
new_text,
@@ -2128,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}). "
@@ -2293,16 +2349,20 @@ async def _create_observation_directly(
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,
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,
@@ -212,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
@@ -220,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
@@ -256,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(
@@ -348,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)
@@ -355,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(
@@ -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
@@ -190,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
@@ -198,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
@@ -709,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
@@ -1634,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:
@@ -1697,6 +1715,6 @@ def create_embeddings_from_env() -> Embeddings:
else:
raise ValueError(
f"Unknown embeddings provider: {provider}. "
f"Supported: 'local', 'onnx', '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,236 +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
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"]
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
# 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]],
@@ -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,6 +457,8 @@ 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
@@ -6,6 +6,7 @@ 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
@@ -252,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)
@@ -76,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)
@@ -10,7 +10,6 @@ import re
import time
import uuid
from contextlib import AsyncExitStack
from pathlib import Path
from typing import TYPE_CHECKING, Any
# Vertex AI imports (conditional - for LLMProvider to pass credentials to GeminiLLM)
@@ -253,6 +252,8 @@ def create_llm_provider(
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.
@@ -266,17 +267,26 @@ def create_llm_provider(
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).
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 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.
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.
@@ -296,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(
@@ -344,6 +360,7 @@ 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,
)
@@ -367,6 +384,8 @@ def create_llm_provider(
model=model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
default_headers=default_headers,
timeout=timeout,
)
elif provider_lower == "litellmrouter":
@@ -385,6 +404,8 @@ def create_llm_provider(
config=litellmrouter_config,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
default_headers=default_headers,
timeout=timeout,
)
elif provider_lower == "bedrock":
@@ -397,7 +418,9 @@ def create_llm_provider(
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":
@@ -444,6 +467,7 @@ def create_llm_provider(
model=model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
timeout=timeout,
)
elif provider_lower in (
@@ -456,8 +480,10 @@ def create_llm_provider(
"deepseek",
"volcano",
"openrouter",
"requesty",
"zai",
"opencode-go",
"atlas",
):
return OpenAICompatibleLLM(
provider=provider,
@@ -468,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:
@@ -496,6 +523,14 @@ class LLMProvider:
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.
@@ -509,29 +544,60 @@ class LLMProvider:
groq_service_tier: Groq service tier ("on_demand", "flex", "auto") - from config.
openai_service_tier: OpenAI service tier (None or "flex") - from config.
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 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
@@ -542,16 +608,9 @@ class LLMProvider:
# 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 = [
@@ -575,8 +634,10 @@ class LLMProvider:
"bedrock",
"volcano",
"openrouter",
"requesty",
"zai",
"opencode-go",
"atlas",
"fireworks",
"nous",
]
@@ -599,32 +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:
@@ -648,45 +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
# Prompt-prefix caching is a provider-agnostic toggle (default on): resolve
# it from the static server config for every provider when the caller didn't
# pass an explicit override. Providers that don't support caching ignore the
# value; only those that implement get_or_create_cached_prefix act on it.
if not self.prompt_cache_enabled:
from ..config import DEFAULT_LLM_PROMPT_CACHE_ENABLED, _get_raw_config
try:
raw_config = _get_raw_config()
self.prompt_cache_enabled = bool(
getattr(raw_config, "llm_prompt_cache_enabled", DEFAULT_LLM_PROMPT_CACHE_ENABLED)
)
except Exception:
pass # Config may not be initialized in test environments
# For litellmrouter: prefer an explicit chain from the caller (per-op
# construction in MemoryEngine threads the right chain through). If the caller
# didn't supply one, fall back to the global ``llm_litellmrouter_config`` so
# 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(
@@ -698,6 +733,7 @@ class LLMProvider:
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,
@@ -706,6 +742,7 @@ class LLMProvider:
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
@@ -762,9 +799,9 @@ 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,
@@ -779,9 +816,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 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: Per-call override requesting grammar-enforced (json_schema strict)
structured output instead of the soft json_object path. The server-level
@@ -806,6 +846,20 @@ 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,
@@ -822,7 +876,13 @@ class LLMProvider:
# 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(
@@ -833,6 +893,9 @@ 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):
@@ -860,14 +923,19 @@ class LLMProvider:
**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,
)
@@ -883,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
@@ -893,9 +962,9 @@ 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":
@@ -908,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:
@@ -920,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(
@@ -933,6 +1025,9 @@ 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):
@@ -957,14 +1052,19 @@ class LLMProvider:
**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,
)
@@ -980,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
@@ -1023,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).
@@ -1032,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(
@@ -1134,18 +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)
@@ -1162,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,
@@ -1171,7 +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))),
)
@@ -11,6 +11,12 @@ from one place, so we don't spawn a separate ``asyncio`` task per concern:
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
@@ -25,12 +31,14 @@ from __future__ import annotations
import asyncio
import logging
import time
from typing import TYPE_CHECKING
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
from .schema import _is_oracle, fq_table
if TYPE_CHECKING:
from .memory_engine import MemoryEngine
@@ -91,7 +99,8 @@ class MaintenanceLoop:
reconcile_on = cfg.consolidation_reconcile_interval_seconds > 0
audit_on = cfg.audit_log_enabled and cfg.audit_log_retention_days > 0
llm_on = cfg.llm_trace_enabled and cfg.llm_trace_retention_days > 0
return reconcile_on or audit_on or llm_on
mm_refresh_on = cfg.mental_model_refresh_tick_seconds > 0
return reconcile_on or audit_on or llm_on or mm_refresh_on
# ── loop ───────────────────────────────────────────────────────────────
@@ -118,10 +127,25 @@ class MaintenanceLoop:
async def _tick(self) -> None:
cfg = get_config()
if self._is_due("retention", _RETENTION_INTERVAL_SECONDS):
await self._run_retention(cfg)
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_reconcile()
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 ──────────────────────────────────────────────────────────
@@ -212,3 +236,112 @@ class MaintenanceLoop:
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)
@@ -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",
@@ -15,12 +15,25 @@ import time
from typing import Any
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.
@@ -136,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:
@@ -167,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] = {
@@ -186,6 +208,14 @@ 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
@@ -194,40 +224,61 @@ class AnthropicLLM(LLMInterface):
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()
@@ -415,6 +466,7 @@ class AnthropicLLM(LLMInterface):
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 = []
@@ -16,6 +16,7 @@ from typing import Any
from pydantic import ValidationError
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
@@ -25,6 +26,7 @@ from typing import Any
import httpx
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
@@ -411,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
@@ -20,6 +20,7 @@ from google.genai import errors as genai_errors
from google.genai import types as genai_types
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
@@ -50,6 +51,18 @@ def _to_int(value: Any) -> int:
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.
@@ -76,6 +89,7 @@ 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
@@ -106,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
@@ -247,16 +271,13 @@ class GeminiLLM(LLMInterface):
else:
gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)]))
# Add the JSON schema as a textual hint in the system_instruction (matching
# the normal uncached path). Structured output is still enforced via
# response_schema regardless; this is just guidance text.
if response_format is not None and hasattr(response_format, "model_json_schema"):
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
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()
@@ -273,11 +294,18 @@ class GeminiLLM(LLMInterface):
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:
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:
@@ -295,6 +323,7 @@ class GeminiLLM(LLMInterface):
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
@@ -311,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
@@ -412,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)
@@ -604,6 +650,7 @@ class GeminiLLM(LLMInterface):
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:
@@ -662,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
@@ -749,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:
@@ -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,13 +68,16 @@ 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
@@ -61,6 +85,13 @@ class LiteLLMLLM(LLMInterface):
# 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:
@@ -77,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,
)
@@ -121,6 +154,13 @@ class LiteLLMLLM(LLMInterface):
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
@@ -209,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
@@ -240,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
@@ -304,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
@@ -354,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
@@ -424,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}")
@@ -208,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.
@@ -225,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)
@@ -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
@@ -34,7 +36,8 @@ from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinish
from hindsight_api.config import DEFAULT_LLM_TIMEOUT, ENV_LLM_TIMEOUT
from hindsight_api.engine.bank_attribution import apply_bank_attribution
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
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
@@ -83,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)
@@ -187,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."""
@@ -234,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.
@@ -269,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.
@@ -288,8 +465,10 @@ class OpenAICompatibleLLM(LLMInterface):
"deepseek",
"volcano",
"openrouter",
"requesty",
"zai",
"opencode-go",
"atlas",
"fireworks",
]
if self.provider not in valid_providers:
@@ -311,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.
@@ -333,8 +516,10 @@ class OpenAICompatibleLLM(LLMInterface):
"minimax",
"deepseek",
"openrouter",
"requesty",
"zai",
"opencode-go",
"atlas",
"ollama-cloud",
)
and not self.api_key
@@ -609,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,
@@ -617,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")
@@ -667,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,
@@ -674,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()
@@ -731,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
@@ -761,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:
@@ -814,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:
@@ -978,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(
@@ -1020,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:
@@ -1047,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(
@@ -1060,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):
"""
@@ -15,7 +15,7 @@ import time
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from ...config import get_config
from .models import DirectiveInfo, LLMCall, ReflectAgentResult, TokenUsageSummary, ToolCall
from .models import DirectiveInfo, LLMCall, ReflectAgentResult, StructuredOutputResult, TokenUsageSummary, ToolCall
from .prompts import (
_extract_directive_rules,
build_final_prompt,
@@ -90,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:
@@ -104,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
@@ -122,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()
@@ -141,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:
@@ -151,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
@@ -186,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)
@@ -239,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,
)
@@ -259,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:
@@ -435,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()
@@ -460,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):
@@ -526,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",
@@ -539,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(
@@ -588,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",
@@ -600,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(
@@ -661,6 +766,8 @@ async def run_reflect_agent(
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}",
@@ -709,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",
@@ -722,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(
@@ -783,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",
@@ -796,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(
@@ -835,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",
@@ -848,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(
@@ -1147,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)
@@ -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):
@@ -734,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(
@@ -744,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.
@@ -774,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.
@@ -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,
@@ -14,6 +14,7 @@ from typing import Any, Literal, cast
from pydantic import BaseModel, ConfigDict, Field, create_model, field_validator
from ..llm_interface import ProviderRateLimitResetError
from ..llm_wrapper import LLMConfig, OutputTooLongError, sanitize_llm_output
from ..operation_metadata import RetainExtractionErrors
from ..response_models import TokenUsage
@@ -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,
@@ -420,19 +421,14 @@ _RECURSIVE_TEXT_SEPARATORS = [
"", # Characters (last resort)
]
# A single structured unit (a JSONL line or a conversation turn) is kept whole
# even when it overflows the budget — but only up to this multiple. Beyond it,
# the unit is split as text rather than handed to the LLM wildly over budget
# (the extractor has no second re-chunk pass; an oversized chunk just errors).
_CHUNK_OVERFLOW_FACTOR = 1.5
def _split_oversized_unit(text: str, max_chars: int) -> list[str]:
"""Sentence-aware split of a single unit that overflowed the budget.
Used when one JSONL line / conversation turn is so large it can't be kept
whole within ``_CHUNK_OVERFLOW_FACTOR``. The resulting fragments are no
longer valid JSON, but the fact extractor treats every chunk as plain text.
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
@@ -446,18 +442,26 @@ def _split_oversized_unit(text: str, max_chars: int) -> list[str]:
return splitter.split_text(text)
def chunk_text(text: str, 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 is kept whole up to ``_CHUNK_OVERFLOW_FACTOR``×
the budget, then split as text. For plain text, uses sentence-aware splitting.
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: Maximum characters per chunk (default 120k 30k tokens)
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
@@ -466,17 +470,31 @@ def chunk_text(text: str, max_chars: int) -> list[str]:
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)
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
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)
jsonl_chunks = _chunk_jsonl(text, max_chars, structured_limit)
if jsonl_chunks is not None:
return jsonl_chunks
@@ -484,20 +502,19 @@ def chunk_text(text: str, max_chars: int) -> list[str]:
return _split_oversized_unit(text, max_chars)
def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
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
"""
overflow_limit = int(max_chars * _CHUNK_OVERFLOW_FACTOR)
chunks = []
current_chunk = []
current_size = 2 # Account for "[]"
@@ -512,13 +529,16 @@ def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
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
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 so no chunk runs far over budget (the extractor won't re-chunk).
if turn_size > overflow_limit:
# 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, max_chars))
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
@@ -535,18 +555,20 @@ def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
return chunks if chunks else [json.dumps(turns, ensure_ascii=False)]
def _chunk_jsonl(text: str, max_chars: int) -> list[str] | None:
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 is kept whole up to
``_CHUNK_OVERFLOW_FACTOR``× the budget, then split as text. Returns ``None``
if the input is not JSONL, so the caller falls back to plain-text splitting.
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.
@@ -563,8 +585,6 @@ def _chunk_jsonl(text: str, max_chars: int) -> list[str] | None:
if not isinstance(obj, dict):
return None
overflow_limit = int(max_chars * _CHUNK_OVERFLOW_FACTOR)
chunks: list[str] = []
current_chunk: list[str] = []
current_size = 0
@@ -577,17 +597,20 @@ def _chunk_jsonl(text: str, max_chars: int) -> list[str] | None:
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 so no chunk runs far over budget (the extractor won't re-chunk).
if line_size > overflow_limit:
# 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, max_chars))
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 overflow_limit is kept whole (a small, bounded overflow).
# A line up to structured_limit is kept whole (a bounded overflow).
if current_size + line_size > max_chars and current_chunk:
_flush()
@@ -640,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
@@ -743,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}",
@@ -844,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
@@ -1180,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
@@ -1291,7 +1322,7 @@ async def _extract_facts_from_chunk(
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,
@@ -1738,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)
@@ -1787,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}"
@@ -1921,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))
@@ -2342,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(
@@ -89,7 +89,29 @@ async def _fire_memory_defense_webhook(
if webhook_manager is None:
return
try:
from ...webhooks import MemoryDefenseEventData, WebhookEvent, WebhookEventType
from ...webhooks import (
MemoryDefenseEventData,
MemoryDefenseHit,
WebhookEvent,
WebhookEventType,
)
# Translate per-match raw dicts on the decision into MemoryDefenseHit
# entries on the wire. The decision's hits list is already fingerprinted
# by apply_redaction (the raw value never lands in hits, by contract),
# so this is purely a shape conversion. None when no per-hit data is
# available so receivers can distinguish "no preview info" from
# "scanned, nothing matched" (the latter wouldn't be a webhook delivery
# in the first place).
decision_hits = getattr(decision, "hits", None) or []
hits: list[MemoryDefenseHit] | None = [
MemoryDefenseHit(
detector=str(h.get("detector") or ""),
preview=str(h.get("preview") or ""),
)
for h in decision_hits
if h.get("detector") and h.get("preview")
] or None
event = WebhookEvent(
event=WebhookEventType.MEMORY_DEFENSE_TRIGGERED,
@@ -103,6 +125,17 @@ async def _fire_memory_defense_webhook(
document_id=document_id,
matched_types=decision.matched_types or None,
message=decision.message or None,
hits=hits,
# Optional SIEM-enrichment fields populated by downstream
# extensions (e.g. hindsight-cloud's _CloudDefenseDecision
# subclass). Read via getattr so OSS doesn't need to know
# about extension subclasses. Combined with the manager's
# exclude_none serialization, missing values stay absent
# from the wire entirely rather than appearing as null.
severity=getattr(decision, "severity", None),
api_key_name=getattr(decision, "api_key_name", None),
memory_unit_id=getattr(decision, "memory_unit_id", None),
receipt_uri=getattr(decision, "receipt_uri", None),
),
)
await webhook_manager.fire_event_with_conn(event, conn, schema=schema)
@@ -801,6 +834,28 @@ async def retain_batch(
if first.get("tags"):
existing_content["tags"] = first["tags"]
contents_dicts = [existing_content, *contents_dicts]
# Merge JSON arrays to keep original_text valid (#2409).
# Without this, combined_content joins items with "\n", producing
# "[...]\n[...]" which is not valid JSON. On the next append cycle
# chunk_text() fails to parse it and falls through to sentence-
# boundary text splitting, breaking speaker attribution.
try:
_merged = []
for _item in contents_dicts:
_parsed = json.loads(_item.get("content", ""))
if isinstance(_parsed, list) and all(isinstance(_e, dict) for _e in _parsed):
_merged.extend(_parsed)
else:
_merged = None
break
if _merged is not None:
contents_dicts = [{"content": json.dumps(_merged, ensure_ascii=False)}]
if first.get("context"):
contents_dicts[0]["context"] = first["context"]
if first.get("tags"):
contents_dicts[0]["tags"] = first["tags"]
except (json.JSONDecodeError, ValueError, TypeError):
pass
# Rebuild contents list to match
contents = _build_contents(contents_dicts, document_tags)
log_buffer.append(
@@ -865,10 +920,15 @@ async def retain_batch(
# retain code paths.
chunk_batch_size = getattr(config, "retain_chunk_batch_size", 100)
chunk_size = getattr(config, "retain_chunk_size", 3000)
structured_chunk_size = getattr(config, "retain_structured_chunk_size", None)
all_pre_chunks: list[str] = []
chunk_to_content: list[int] = [] # maps chunk index -> index into contents
for content_idx, content in enumerate(contents):
content_chunks = fact_extraction.chunk_text(content.content, chunk_size)
content_chunks = fact_extraction.chunk_text(
content.content,
chunk_size,
structured_chunk_size=structured_chunk_size,
)
all_pre_chunks.extend(content_chunks)
chunk_to_content.extend([content_idx] * len(content_chunks))
@@ -1577,8 +1637,19 @@ async def _streaming_retain_batch(
# Check if facts are already committed (recovery from previous crash).
# If so, skip extraction+writes and jump straight to final ANN pass.
# ---------------------------------------------------------------------------
# Only the call that starts a document at chunk 0 may take the whole-document
# skip. When an oversized single item is split into several sequential
# sub-batches that SHARE one document_id AND one operation_id (see
# _split_contents_into_sub_batches), the first sub-batch commits its chunks
# and stamps effective_doc_id into result_metadata.facts_committed_document_ids.
# Without the offset gate, every later sub-batch (chunk_index_offset > 0) would
# then see its own document already "committed" and skip extraction, dropping
# all chunks past the first slice. A non-zero offset inherently means this call
# continues a document another sub-batch already started, so it must always do
# its work — crash-safety for those chunks still comes from the per-chunk hash
# recovery (existing_chunk_hashes) below.
facts_already_committed = False
if operation_id:
if operation_id and chunk_index_offset == 0:
try:
async with acquire_with_retry(pool) as conn:
row = await conn.fetchrow(
@@ -2248,9 +2319,14 @@ def _chunk_contents_for_delta(contents: list[RetainContent], config) -> dict[int
"""
result = {}
global_chunk_idx = 0
chunk_size = getattr(config, "retain_chunk_size", 3000)
structured_chunk_size = getattr(config, "retain_structured_chunk_size", None)
for content in contents:
chunk_size = getattr(config, "retain_chunk_size", 3000)
chunks = fact_extraction.chunk_text(content.content, chunk_size)
chunks = fact_extraction.chunk_text(
content.content,
chunk_size,
structured_chunk_size=structured_chunk_size,
)
for chunk_text in chunks:
result[global_chunk_idx] = chunk_text
global_chunk_idx += 1
@@ -24,7 +24,9 @@ class RetainContentDict(TypedDict, total=False):
tags: Visibility scope tags for this content item (optional)
observation_scopes: How to scope observations for consolidation (optional).
"per_tag" runs one pass per individual tag; "combined" (default) runs a
single pass with all tags; a list[list[str]] specifies exact passes.
single pass with all tags; "shared" runs a single pass over one global,
untagged scope so memories consolidate together regardless of tags;
a list[list[str]] specifies exact passes.
update_mode: How to handle existing documents with the same document_id (optional).
"replace" (default) deletes old data and reprocesses. "append" concatenates
new content to the existing document and reprocesses.
@@ -38,7 +40,7 @@ class RetainContentDict(TypedDict, total=False):
entities: list[dict[str, str]] # [{"text": "...", "type": "..."}]
tags: list[str] # Visibility scope tags
observation_scopes: (
Literal["per_tag", "combined", "all_combinations"] | list[list[str]]
Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]]
) # Observation scopes for consolidation
update_mode: Literal["replace", "append"]
@@ -57,7 +59,7 @@ class RetainContent:
metadata: dict[str, str] = field(default_factory=dict)
entities: list[dict[str, str]] = field(default_factory=list) # User-provided entities
tags: list[str] = field(default_factory=list) # Visibility scope tags
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = (
observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = (
None # Observation scopes
)
@@ -124,7 +126,7 @@ class ExtractedFact:
mentioned_at: datetime | None = None
metadata: dict[str, str] = field(default_factory=dict)
tags: list[str] = field(default_factory=list) # Visibility scope tags
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = (
observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = (
None # Observation scopes
)
@@ -176,7 +178,7 @@ class ProcessedFact:
tags: list[str] = field(default_factory=list)
# Observation scopes for consolidation
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = None
observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = None
@property
def is_duplicate(self) -> bool:
@@ -2,7 +2,7 @@
Helper functions for hybrid search (semantic + BM25 + graph).
"""
from .types import MergedCandidate, RetrievalResult
from .types import ArmScores, MergedCandidate, RetrievalResult
def cap_per_source(results: list[RetrievalResult], cap: int) -> list[RetrievalResult]:
@@ -51,6 +51,7 @@ def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 6
rrf_scores = {}
source_ranks = {} # Track rank from each source for each doc_id
all_retrievals = {} # Store the actual RetrievalResult (use first occurrence)
arm_scores: dict[str, ArmScores] = {} # doc_id -> raw per-strategy scores across arms
source_names = ["semantic", "bm25", "graph", "temporal"]
@@ -79,17 +80,29 @@ def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 6
if doc_id not in rrf_scores:
rrf_scores[doc_id] = 0.0
source_ranks[doc_id] = {}
arm_scores[doc_id] = ArmScores()
rrf_scores[doc_id] += 1.0 / (k + rank)
source_ranks[doc_id][f"{source_name}_rank"] = rank
# Capture this arm's raw score for the doc (the merged RetrievalResult
# below keeps only the first arm's score, so record each arm here).
if source_name == "semantic" and retrieval.similarity is not None:
arm_scores[doc_id].semantic = retrieval.similarity
elif source_name == "bm25" and retrieval.bm25_score is not None:
arm_scores[doc_id].keyword = retrieval.bm25_score
# Combine into final results with metadata
merged_results = []
for rrf_rank, (doc_id, rrf_score) in enumerate(
sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True), start=1
):
merged_candidate = MergedCandidate(
retrieval=all_retrievals[doc_id], rrf_score=rrf_score, rrf_rank=rrf_rank, source_ranks=source_ranks[doc_id]
retrieval=all_retrievals[doc_id],
rrf_score=rrf_score,
rrf_rank=rrf_rank,
source_ranks=source_ranks[doc_id],
arm_scores=arm_scores[doc_id],
)
merged_results.append(merged_candidate)
@@ -118,6 +131,7 @@ def interleave_fusion(result_lists: list[list[RetrievalResult]]) -> list[MergedC
source_names = ["semantic", "bm25", "graph", "temporal"]
source_ranks: dict[str, dict[str, int]] = {}
all_retrievals: dict[str, RetrievalResult] = {}
arm_scores: dict[str, ArmScores] = {}
for source_idx, results in enumerate(result_lists):
source_name = source_names[source_idx] if source_idx < len(source_names) else f"source_{source_idx}"
@@ -129,6 +143,11 @@ def interleave_fusion(result_lists: list[list[RetrievalResult]]) -> list[MergedC
doc_id = retrieval.id
all_retrievals.setdefault(doc_id, retrieval)
source_ranks.setdefault(doc_id, {})[f"{source_name}_rank"] = rank
arm = arm_scores.setdefault(doc_id, ArmScores())
if source_name == "semantic" and retrieval.similarity is not None:
arm.semantic = retrieval.similarity
elif source_name == "bm25" and retrieval.bm25_score is not None:
arm.keyword = retrieval.bm25_score
# Round-robin pick across arms in priority order: all #1s, then all #2s, ...
ordered_ids: list[str] = []
@@ -151,6 +170,7 @@ def interleave_fusion(result_lists: list[list[RetrievalResult]]) -> list[MergedC
rrf_score=float(n - pos),
rrf_rank=pos + 1,
source_ranks=source_ranks[doc_id],
arm_scores=arm_scores[doc_id],
)
for pos, doc_id in enumerate(ordered_ids)
]
@@ -251,8 +251,10 @@ class LinkExpansionRetriever(GraphRetriever):
result.activation = row["score"]
results.append(result)
if tags:
results = filter_results_by_tags(results, tags, match=tags_match)
# filter_results_by_tags is a no-op when no filter applies (tags falsy and not
# the exact-empty/global scope), so call it unconditionally — gating on `if tags:`
# would skip the untagged-only filter for tags=[] + tags_match="exact".
results = filter_results_by_tags(results, tags, match=tags_match)
if tag_groups:
results = filter_results_by_tag_groups(results, tag_groups)
@@ -16,6 +16,44 @@ _RECENCY_ALPHA: float = 0.2
_TEMPORAL_ALPHA: float = 0.2
_PROOF_COUNT_ALPHA: float = 0.1 # Conservative: max ±5% for evidence strength
# Recency decay: maps a memory's age (days) onto a freshness signal in [0, 1]
# where 0.5 is neutral (no boost). The signal is then folded into the
# multiplicative recency_boost via `1 + recency_alpha * (recency - 0.5)`.
#
# "linear" — straight line from 1.0 (today) to a floor of 0.1, reaching
# the floor at `linear_window_days`. The historical default.
# "exponential" — 0.5 ** (days_ago / halflife_days). The half-life is the age
# at which the signal is exactly neutral (0.5): younger
# memories are boosted, older ones penalised, with a smooth
# asymptote toward 0 (no hard cutoff).
# "none" — always neutral (0.5), disabling the recency boost entirely.
# The validated set of names lives in config.RECENCY_DECAY_FUNCTIONS.
_RECENCY_DECAY_FUNCTION: str = "linear"
_RECENCY_DECAY_LINEAR_WINDOW_DAYS: float = 365.0
_RECENCY_DECAY_HALFLIFE_DAYS: float = 90.0
def compute_recency_decay(
days_ago: float,
function: str = _RECENCY_DECAY_FUNCTION,
linear_window_days: float = _RECENCY_DECAY_LINEAR_WINDOW_DAYS,
halflife_days: float = _RECENCY_DECAY_HALFLIFE_DAYS,
) -> float:
"""Map a memory's age in days to a freshness signal in [0, 1] (neutral 0.5).
Future-dated memories (negative ``days_ago``) clamp to the maximum freshness
so they are never penalised. See ``RECENCY_DECAY_FUNCTIONS`` for the shapes.
"""
if function == "none":
return 0.5
if function == "exponential":
if halflife_days <= 0:
return 0.5
return min(1.0, 0.5 ** (days_ago / halflife_days))
# "linear" (default): straight decay to a 0.1 floor over the window.
window = linear_window_days if linear_window_days > 0 else _RECENCY_DECAY_LINEAR_WINDOW_DAYS
return max(0.1, min(1.0, 1.0 - (days_ago / window)))
def apply_combined_scoring(
scored_results: list[ScoredResult],
@@ -24,6 +62,9 @@ def apply_combined_scoring(
temporal_alpha: float = _TEMPORAL_ALPHA,
proof_count_alpha: float = _PROOF_COUNT_ALPHA,
is_passthrough_reranker: bool = False,
recency_decay_function: str = _RECENCY_DECAY_FUNCTION,
recency_decay_linear_window_days: float = _RECENCY_DECAY_LINEAR_WINDOW_DAYS,
recency_decay_halflife_days: float = _RECENCY_DECAY_HALFLIFE_DAYS,
) -> None:
"""Apply combined scoring to a list of ScoredResults in-place.
@@ -57,6 +98,12 @@ def apply_combined_scoring(
recency_alpha: Max relative recency adjustment (default 0.2 ±10%).
temporal_alpha: Max relative temporal adjustment (default 0.2 ±10%).
proof_count_alpha: Max relative proof count adjustment (default 0.1 ±5%).
recency_decay_function: Agefreshness curve "linear" (default),
"exponential", or "none". See compute_recency_decay.
recency_decay_linear_window_days: Days over which the linear curve
decays to its floor (default 365).
recency_decay_halflife_days: For the exponential curve, the age at which
the recency signal is neutral (0.5) (default 90).
"""
if now.tzinfo is None:
now = now.replace(tzinfo=UTC)
@@ -98,14 +145,26 @@ def apply_combined_scoring(
sr.cross_encoder_score_normalized = 1.0 - (0.9 * new_rank / denom)
for sr in scored_results:
# Recency: linear decay over 365 days → [0.1, 1.0]; neutral 0.5 if no date.
# Recency: configurable decay (linear default; see compute_recency_decay)
# → [0.0, 1.0]; neutral 0.5 if no date.
# Use the unit's effective time (occurred_start, then mentioned_at, then
# occurred_end) — the same COALESCE order as retrieval._coalesce_date — so a
# memory that carries only a mentioned_at / occurred_end (e.g. conversation
# facts or ongoing states that intentionally lack occurred_start) still gets
# correct recency ordering instead of a flat neutral 0.5.
sr.recency = 0.5
if sr.retrieval.occurred_start:
occurred = sr.retrieval.occurred_start
effective = sr.retrieval.occurred_start or sr.retrieval.mentioned_at or sr.retrieval.occurred_end
if effective:
occurred = effective
if occurred.tzinfo is None:
occurred = occurred.replace(tzinfo=UTC)
days_ago = (now - occurred).total_seconds() / 86400
sr.recency = max(0.1, min(1.0, 1.0 - (days_ago / 365)))
sr.recency = compute_recency_decay(
days_ago,
recency_decay_function,
recency_decay_linear_window_days,
recency_decay_halflife_days,
)
# Temporal proximity: meaningful only for temporal queries; neutral otherwise.
sr.temporal = sr.retrieval.temporal_proximity if sr.retrieval.temporal_proximity is not None else 0.5
@@ -118,6 +177,9 @@ def apply_combined_scoring(
else:
# Neutral baseline is precisely 0.5, ensuring neutral multiplier (1.0)
proof_norm = 0.5
# Surface the proof signal so the trace can show the proof_count_boost
# factor (otherwise the reranked breakdown can't reconcile CE × boosts).
sr.proof_norm = proof_norm
# RRF: kept at 0.0 for trace continuity but excluded from scoring.
# RRF is batch-relative (min-max normalised) and redundant after reranking.
@@ -104,6 +104,8 @@ async def retrieve_semantic_bm25_combined(
tag_groups: list[TagGroup] | None = None,
created_after: datetime | None = None,
created_before: datetime | None = None,
min_semantic: float | None = None,
min_keyword: float | None = None,
) -> dict[str, tuple[list[RetrievalResult], list[RetrievalResult]]]:
"""
Combined semantic + BM25 retrieval for multiple fact types in a single query.
@@ -143,6 +145,12 @@ async def retrieve_semantic_bm25_combined(
config = get_config()
tokens = tokenize_query(query_text)
# Per-request retrieval-level score floors (recall min_scores.semantic / .keyword)
# override the global config defaults for this query, pruning weak matches in
# the SQL arms before fusion.
sem_min = min_semantic if min_semantic is not None else config.semantic_min_similarity
bm25_min = min_keyword if min_keyword is not None else config.bm25_min_score
# Over-fetch for HNSW approximation; semantic results trimmed to limit in Python.
hnsw_fetch = max(limit * 5, 100)
@@ -203,7 +211,7 @@ async def retrieve_semantic_bm25_combined(
embedding_param="$1",
bank_id_param="$2",
fetch_limit=hnsw_fetch,
min_similarity=config.semantic_min_similarity,
min_similarity=sem_min,
tags_clause=tags_clause,
groups_clause=groups_clause,
extra_where=created_range_clause,
@@ -229,7 +237,7 @@ async def retrieve_semantic_bm25_combined(
arm_index=i,
text_search_extension=text_ext,
bm25_language=config.text_search_extension_native_language,
bm25_min_score=config.bm25_min_score,
bm25_min_score=bm25_min,
extra_where=created_range_clause,
)
)
@@ -277,7 +285,7 @@ async def retrieve_semantic_bm25_combined(
embedding_param="$1",
bank_id_param="$2",
fetch_limit=hnsw_fetch,
min_similarity=config.semantic_min_similarity,
min_similarity=sem_min,
tags_clause=fb_tags_clause,
groups_clause=fb_groups_clause,
extra_where=fb_created_clause,
@@ -706,6 +714,8 @@ async def retrieve_all_fact_types_parallel(
tag_groups: list[TagGroup] | None = None,
created_after: datetime | None = None,
created_before: datetime | None = None,
min_semantic: float | None = None,
min_keyword: float | None = None,
) -> MultiFactTypeRetrievalResult:
"""
Optimized retrieval for multiple fact types using batched queries.
@@ -766,6 +776,8 @@ async def retrieve_all_fact_types_parallel(
tag_groups=tag_groups,
created_after=created_after,
created_before=created_before,
min_semantic=min_semantic,
min_keyword=min_keyword,
)
semantic_bm25_time = time.time() - semantic_bm25_start
@@ -781,7 +793,7 @@ async def retrieve_all_fact_types_parallel(
tc_start,
tc_end,
budget=thinking_budget,
semantic_threshold=0.1,
semantic_threshold=min_semantic if min_semantic is not None else 0.1,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
@@ -14,6 +14,12 @@ AND matching (all/all_strict): Memory matches if ALL request tags are present in
EXACT matching: Memory matches only if its tag set EQUALS the request tag set (order-
independent). Used for observation "scope" filtering, where each observation lives
under exactly one scope (its full tag set) and "scope [a]" must not match "[a, b]".
An EMPTY request scope (no tags ``[]`` or ``None``) is the global/untagged scope and
matches only untagged memories the scope that ``observation_scopes="shared"``
consolidation writes to. This is the one mode where absent tags filter rather than
meaning "no filter"; all other modes treat empty/absent tags as "no filtering". This
mirrors the ``GET .../graph`` endpoint, where ``tags_match="exact"`` with no tags also
selects the global scope.
"""
from __future__ import annotations
@@ -82,11 +88,16 @@ def build_tags_where_clause(
>>> clause, params, next_offset = build_tags_where_clause(['user_a'], 3, 'mu.', 'any_strict')
>>> print(clause) # "AND mu.tags IS NOT NULL AND mu.tags != '{}' AND mu.tags && $3"
"""
column = f"{table_alias}tags" if table_alias else "tags"
if match == "exact" and not tags:
# Empty/absent scope = global/untagged: match only untagged rows. No bind param
# needed (callers gate the param on truthy `tags`, so none is appended).
return f"AND ({column} IS NULL OR {column} = '{{}}')", [], param_offset
if not tags:
return "", [], param_offset
column = f"{table_alias}tags" if table_alias else "tags"
if match == "exact":
# Set equality (order-independent): superset AND subset. Untagged rows
# (empty array) never satisfy `@>` of a non-empty scope, so they're excluded.
@@ -126,11 +137,16 @@ def build_tags_where_clause_simple(
Returns:
SQL clause string or empty string.
"""
column = f"{table_alias}tags" if table_alias else "tags"
if match == "exact" and not tags:
# Empty/absent scope = global/untagged: match only untagged rows. No bind param
# needed (callers gate the param on truthy `tags`, so none is appended).
return f"AND ({column} IS NULL OR {column} = '{{}}')"
if not tags:
return ""
column = f"{table_alias}tags" if table_alias else "tags"
if match == "exact":
# Set equality (order-independent): superset AND subset. Untagged rows
# (empty array) never satisfy `@>` of a non-empty scope, so they're excluded.
@@ -164,6 +180,10 @@ def filter_results_by_tags(
Returns:
Filtered list of results.
"""
if match == "exact" and not tags:
# Empty/absent scope = global/untagged: keep only untagged results.
return [r for r in results if not getattr(r, "tags", None)]
if not tags:
return results
@@ -267,6 +287,9 @@ def _build_group_clause(
if isinstance(group, TagGroupLeaf):
column = f"{table_alias}tags" if table_alias else "tags"
if group.match == "exact":
if len(group.tags) == 0:
# Empty scope = global/untagged: match only untagged rows (no bind param).
return f"({column} IS NULL OR {column} = '{{}}')", [], param_offset
clause = f"({column} @> ${param_offset} AND {column} <@ ${param_offset})"
return clause, [group.tags], param_offset + 1
operator, include_untagged = _parse_tags_match(group.match)
@@ -369,6 +392,9 @@ def _match_group(result: object, group: TagGroup) -> bool:
if isinstance(group, TagGroupLeaf):
result_tags = getattr(result, "tags", None)
is_untagged = result_tags is None or len(result_tags) == 0
if group.match == "exact" and len(group.tags) == 0:
# Empty scope = global/untagged: match only untagged results.
return is_untagged
_, include_untagged = _parse_tags_match(group.match)
is_any_match = group.match in ("any", "any_strict")
tags_set = set(group.tags)
@@ -5,6 +5,7 @@ Think operation utilities for formulating answers based on agent and world facts
import logging
from datetime import datetime
from ...config import get_config
from ..response_models import DispositionTraits, MemoryFact
logger = logging.getLogger(__name__)
@@ -251,7 +252,7 @@ async def reflect(
answer_text = await llm_config.call(
messages=[{"role": "system", "content": system_message}, {"role": "user", "content": prompt}],
scope="memory_think",
temperature=0.9,
temperature=get_config().llm_temperature_reflect,
max_completion_tokens=1000,
)
@@ -392,7 +392,7 @@ class SearchTracer:
# Extract score components (only include non-None values)
# Keys from ScoredResult.to_dict(): cross_encoder_score, cross_encoder_score_normalized,
# rrf_normalized, temporal, recency, combined_score, weight
# rrf_normalized, temporal, recency, proof_norm, combined_score, weight
score_components = {}
for key in [
"cross_encoder_score",
@@ -401,6 +401,7 @@ class SearchTracer:
"rrf_normalized",
"temporal",
"recency",
"proof_norm",
"combined_score",
]:
if key in result and result[key] is not None:
@@ -82,6 +82,20 @@ class RetrievalResult:
)
@dataclass
class ArmScores:
"""Raw per-strategy retrieval scores for a single doc, aggregated across arms.
Fusion keeps only the first-seen RetrievalResult per doc, so its per-arm score
fields reflect just one arm. This captures each arm's raw score for the same doc
so the recall response can report them (and ``min_scores`` can filter on them).
``None`` means the doc was not surfaced by that arm.
"""
semantic: float | None = None # cosine similarity from the semantic arm
keyword: float | None = None # BM25 / full-text score from the keyword arm
@dataclass
class MergedCandidate:
"""
@@ -97,6 +111,7 @@ class MergedCandidate:
rrf_score: float
rrf_rank: int = 0
source_ranks: dict[str, int] = field(default_factory=dict) # method_name -> rank
arm_scores: "ArmScores" = field(default_factory=lambda: ArmScores()) # raw per-strategy scores
@property
def id(self) -> str:
@@ -123,6 +138,7 @@ class ScoredResult:
rrf_normalized: float = 0.0
recency: float = 0.5
temporal: float = 0.5
proof_norm: float = 0.5 # log-normalized proof count (neutral 0.5); drives proof_count_boost
# Final combined score
combined_score: float = 0.0
@@ -179,6 +195,7 @@ class ScoredResult:
result["rrf_normalized"] = self.rrf_normalized
result["temporal"] = self.temporal
result["recency"] = self.recency
result["proof_norm"] = self.proof_norm
result["combined_score"] = self.combined_score
result["weight"] = self.weight
result["activation"] = self.weight # Legacy field
@@ -0,0 +1,155 @@
"""Explicit period extraction helpers for DateparserQueryAnalyzer.
This module keeps the public period-extraction API and the non-Chinese period
rules. Chinese rules live in chinese_temporal_periods.py because that rule set is
substantially larger and has different boundary behavior from whitespace-based
languages.
"""
import calendar
import re
import unicodedata
from datetime import datetime, timedelta
DateRange = tuple[datetime, datetime]
class NoTemporalConstraintSentinel:
pass
NO_TEMPORAL_CONSTRAINT = NoTemporalConstraintSentinel()
__all__ = [
"NO_TEMPORAL_CONSTRAINT",
"extract_period",
"is_embedded_cjk_dateparser_match",
]
def _is_cjk_character(char: str) -> bool:
return "\u4e00" <= char <= "\u9fff"
def is_embedded_cjk_dateparser_match(query: str, matched_text: str) -> bool:
from hindsight_api.engine.chinese_temporal_periods import (
is_embedded_cjk_dateparser_match as chinese_is_embedded_cjk_dateparser_match,
)
return chinese_is_embedded_cjk_dateparser_match(query, matched_text)
def _constraint(start: datetime, end: datetime) -> DateRange:
return (
start.replace(hour=0, minute=0, second=0, microsecond=0),
end.replace(hour=23, minute=59, second=59, microsecond=999999),
)
def _month_end(year: int, month: int) -> datetime:
return datetime(year, month, calendar.monthrange(year, month)[1])
def _extract_non_chinese_period(query: str, reference_date: datetime) -> DateRange | None:
if re.search(r"\b(yesterday|ayer|ieri|hier|gestern)\b", query, re.IGNORECASE):
d = reference_date - timedelta(days=1)
return _constraint(d, d)
if re.search(r"\b(today|hoy|oggi|aujourd\'?hui|heute)\b", query, re.IGNORECASE):
return _constraint(reference_date, reference_date)
if re.search(r"\b(a\s+)?couple\s+(of\s+)?days?\s+ago\b", query, re.IGNORECASE):
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):
return _constraint(reference_date - timedelta(days=5), reference_date - timedelta(days=2))
if re.search(r"\b(a\s+)?couple\s+(of\s+)?weeks?\s+ago\b", query, re.IGNORECASE):
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):
return _constraint(reference_date - timedelta(weeks=5), reference_date - timedelta(weeks=2))
if re.search(r"\b(a\s+)?couple\s+(of\s+)?months?\s+ago\b", query, re.IGNORECASE):
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):
return _constraint(reference_date - timedelta(days=150), reference_date - timedelta(days=60))
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))
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)
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))
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_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)
return _constraint(start, _month_end(year, month_num))
return None
def extract_period(query: str, reference_date: datetime) -> DateRange | NoTemporalConstraintSentinel | None:
"""Extract explicit period-based temporal expressions.
Non-Chinese rules are kept here. Chinese rules are delegated to
chinese_temporal_periods.py and are skipped entirely for non-CJK queries.
"""
query = unicodedata.normalize("NFKC", query)
if any(_is_cjk_character(char) for char in query):
from hindsight_api.engine.chinese_temporal_periods import extract_chinese_period
chinese_result = extract_chinese_period(query, reference_date)
if chinese_result is not None:
return chinese_result
return _extract_non_chinese_period(query, reference_date)
@@ -22,7 +22,7 @@ from pydantic import BaseModel, Field
# Bump when the archive layout changes in a backward-incompatible way.
SCHEMA_VERSION = 1
ObservationScopes = Literal["per_tag", "combined", "all_combinations"] | list[list[str]]
ObservationScopes = Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]]
class TransferCausalRelation(BaseModel):
@@ -39,7 +39,9 @@ from hindsight_api.extensions.operation_validator import (
BankListContext,
BankListResult,
BankReadContext,
BankReadOperation,
BankWriteContext,
BankWriteOperation,
# Consolidation operation
ConsolidateContext,
ConsolidateResult,
@@ -54,6 +56,7 @@ from hindsight_api.extensions.operation_validator import (
OperationValidationError,
OperationValidatorExtension,
PrecheckContext,
PrecheckOperation,
RecallContext,
RecallResult,
ReflectContext,
@@ -87,6 +90,7 @@ __all__ = [
"OperationValidationError",
"OperationValidatorExtension",
"PrecheckContext",
"PrecheckOperation",
"RecallContext",
"RecallResult",
"ReflectContext",
@@ -98,7 +102,9 @@ __all__ = [
"BankListContext",
"BankListResult",
"BankReadContext",
"BankReadOperation",
"BankWriteContext",
"BankWriteOperation",
# Operation Validator - Consolidation
"ConsolidateContext",
"ConsolidateResult",
@@ -52,4 +52,5 @@ class MemoryDefenseRegexExtension(MemoryDefenseExtension):
message=f"Sensitive data pattern matched: {', '.join(result.matched_types)}",
redacted_content=result.content if rule.action is DefenseAction.REDACT else None,
matched_types=result.matched_types,
hits=result.hits,
)
@@ -29,9 +29,14 @@ class DefenseAction(str, Enum):
_VALID_ACTIONS = {a.value for a in DefenseAction}
# Detector identifiers valid as ``policy.rules[*].on``. The OSS extension only
# screens for sensitive data (secrets/PII), so that's the only accepted value.
_VALID_DETECTORS = {"sensitive_data"}
# ``policy.rules[*].on`` names a detector. The OSS extension only screens for
# ``sensitive_data``; any other name is a silent no-op here and is dispatched
# by whichever extension is loaded (e.g. hindsight-cloud screens cloud-only
# detectors). The parser therefore does NOT validate ``on`` against a fixed
# list — pinning the OSS roster to cloud's would force an OSS bump for every
# new cloud detector just to avoid 422-ing a write it never interprets. We
# only require ``on`` to be a non-empty string; entitlement and dispatch are
# the loaded extension's ``screen()`` job.
@dataclass(frozen=True)
@@ -53,19 +58,58 @@ class DefenseDecision:
message: str = ""
redacted_content: str | None = None
matched_types: list[str] = field(default_factory=list)
# Per-match fingerprinted previews. Each entry is
# ``{"detector": <pattern label>, "preview": <fingerprinted value>}``.
# The preview is *never* the raw value — see :func:`_fingerprint_value`.
# OSS populates this from ``apply_redaction``; downstream extensions
# populate it from their own detectors. Optional: empty when the
# match path didn't capture per-hit values.
hits: list[dict] = field(default_factory=list)
@dataclass
class RedactionResult:
content: str
matched_types: list[str]
# Same shape as ``DefenseDecision.hits`` — one entry per matched value
# (so a single content with two GitHub tokens produces two entries).
hits: list[dict] = field(default_factory=list)
def _fingerprint_value(value: str) -> str:
"""Return a redaction-identifiable preview of a matched value.
The preview keeps the prefix and a short suffix so a SIEM operator can
correlate against their credential inventory (the prefix names the
provider; the suffix disambiguates specific instances) without the raw
secret crossing the wire. Length-aware so short values don't accidentally
leak material:
- Length < 6: redact entirely (return a fixed-length mask). Catches
noise like a single ``-----BEGIN...`` marker line.
- Length 6-15: keep the first 2 + last 2 around an ellipsis.
- Length > 15: keep the first 4 + last 4 around an ellipsis.
Examples::
_fingerprint_value("ghp_AAAA...AAAA" + "A" * 36) -> "ghp_...AAAA"
_fingerprint_value("AKIA" + "B" * 16) -> "AKIA...BBBB"
_fingerprint_value("123-45-6789") -> "12...89"
_fingerprint_value("abc") -> "[redacted]"
"""
n = len(value)
if n < 6:
return "[redacted]"
if n <= 15:
return f"{value[:2]}...{value[-2:]}"
return f"{value[:4]}...{value[-4:]}"
def parse_policy(raw: dict | None) -> DefensePolicy:
"""Parse a raw bank-config dict into a frozen DefensePolicy.
Raises ValueError for unknown detectors or actions; the HTTP layer
converts those into a 422 response.
Raises ValueError for a missing/empty ``on`` or an unknown action; the
HTTP layer converts those into a 422 response.
"""
if raw is None:
return DefensePolicy()
@@ -73,8 +117,8 @@ def parse_policy(raw: dict | None) -> DefensePolicy:
rules: list[PolicyRule] = []
for item in raw.get("rules", []) or []:
on_raw = item.get("on")
if on_raw not in _VALID_DETECTORS:
raise ValueError(f"invalid on {on_raw!r}; must be one of {sorted(_VALID_DETECTORS)}")
if not isinstance(on_raw, str) or not on_raw:
raise ValueError(f"invalid on {on_raw!r}; must be a non-empty string")
action_raw = item.get("action")
if action_raw not in _VALID_ACTIONS:
raise ValueError(f"invalid action {action_raw!r}; must be one of {sorted(_VALID_ACTIONS)}")
@@ -166,16 +210,42 @@ _COMPILED_REDACTIONS: list[tuple[str, re.Pattern]] = [
def apply_redaction(content: str) -> RedactionResult:
"""Scrub known secret/PII patterns from content with [REDACTED:type] markers.
Returns the (possibly unchanged) content alongside the list of pattern
labels that matched (empty when nothing matched).
Returns the (possibly unchanged) content alongside:
- ``matched_types``: pattern labels that matched (deduplicated, in
first-occurrence order). Empty when nothing matched.
- ``hits``: per-match fingerprinted previews one entry per matched
substring (so two GitHub tokens in the same content produce two
entries). Each entry is ``{"detector": label, "preview": fingerprint}``
where ``preview`` is a length-aware redaction of the original value.
The raw secret never appears in ``hits``.
The two-pass shape (find matches first, then substitute) lets us capture
raw values for fingerprinting before they're replaced by ``[REDACTED:type]``
markers. A single-pass approach would lose the originals.
"""
matched: list[str] = []
hits: list[dict] = []
for label, pattern in _COMPILED_REDACTIONS:
new_content = pattern.sub(f"[REDACTED:{label}]", content)
if new_content != content:
raw_hits = pattern.findall(content)
if not raw_hits:
continue
if label not in matched:
matched.append(label)
content = new_content
return RedactionResult(content=content, matched_types=matched)
for raw in raw_hits:
# findall returns either a string or a tuple of capture groups
# depending on the pattern. The redaction-pattern catalog uses a
# mix; coerce to the matched substring as best we can.
if isinstance(raw, tuple):
# Pick the longest non-empty group as the canonical match.
non_empty = [g for g in raw if g]
raw_str = max(non_empty, key=len) if non_empty else ""
else:
raw_str = raw
if not raw_str:
continue
hits.append({"detector": label, "preview": _fingerprint_value(raw_str)})
content = pattern.sub(f"[REDACTED:{label}]", content)
return RedactionResult(content=content, matched_types=matched, hits=hits)
class MemoryDefenseExtension(Extension, ABC):
@@ -3,6 +3,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from enum import StrEnum
from typing import TYPE_CHECKING
from hindsight_api.extensions.base import Extension
@@ -82,6 +83,18 @@ class ValidationResult:
# =============================================================================
class PrecheckOperation(StrEnum):
"""Route operation names passed to the pre-body-parse precheck hook."""
DRY_RUN_EXTRACT = "dry_run_extract"
FILES_RETAIN = "files_retain"
MENTAL_MODEL_CREATE = "mental_model_create"
MENTAL_MODEL_REFRESH = "mental_model_refresh"
RECALL = "recall"
REFLECT = "reflect"
RETAIN = "retain"
@dataclass
class PrecheckContext:
"""Context for a pre-body-parse precheck on an operation.
@@ -91,12 +104,14 @@ class PrecheckContext:
therefore intentionally carries only the cheap, already-resolved
pieces of request state:
- ``operation``: a short string identifying the route, e.g. ``"retain"``,
``"recall"``, ``"reflect"``, ``"files_retain"``, ``"mental_model_create"``,
``"mental_model_refresh"``.
- ``operation``: a short string-compatible enum identifying the route.
- ``bank_id``: parsed from the URL path.
- ``request_context``: the authenticated :class:`RequestContext` (tenant
already resolved by the tenant extension).
- ``content_length``: value of the ``Content-Length`` request header as an
int, or ``None`` when the header is absent or unparseable (e.g. chunked
transfer encoding). Lets a precheck make size-aware decisions such as
an upper-bound cost estimate without reading or deserialising the body.
Implementations should keep precheck cheap and side-effect-free. The
full per-request validators (``validate_retain`` / ``validate_recall``
@@ -104,9 +119,10 @@ class PrecheckContext:
the source of truth for the precise per-call cost / quota arithmetic.
"""
operation: str
operation: PrecheckOperation
bank_id: str
request_context: "RequestContext"
content_length: int | None = None
@dataclass
@@ -203,6 +219,16 @@ class RetainResult:
llm_input_tokens: int | None = None
llm_output_tokens: int | None = None
llm_total_tokens: int | None = None
# Diagnostic token splits surfaced for cost attribution and prompt-cache
# tuning. ``llm_cached_input_tokens`` is the subset of llm_input_tokens
# served from the provider's prompt cache (e.g. Gemini's
# cached_content_token_count). ``llm_thoughts_tokens`` is reasoning tokens
# that are billed at the output rate by some providers (Gemini 2.5+) but
# are not part of the visible response. Both default to None when the
# engine/provider didn't report them; downstream metering extensions
# should treat None as 0.
llm_cached_input_tokens: int | None = None
llm_thoughts_tokens: int | None = None
# Content tokens the retain pipeline actually processed, after
# chunk-level content-hash deduplication. Semantics:
# None — no dedup signal available (e.g. a first-time retain or a
@@ -288,12 +314,77 @@ class ConsolidateResult:
# =============================================================================
class BankReadOperation(StrEnum):
"""Bank-scoped read operation names passed to validate_bank_read."""
GET_BANK_CONFIG = "get_bank_config"
GET_BANK_PROFILE = "get_bank_profile"
GET_BANK_STATS = "get_bank_stats"
GET_CHUNK = "get_chunk"
GET_DIRECTIVE = "get_directive"
GET_DOCUMENT = "get_document"
GET_ENTITY = "get_entity"
GET_ENTITY_GRAPH = "get_entity_graph"
GET_ENTITY_STATE = "get_entity_state"
GET_GRAPH_DATA = "get_graph_data"
GET_MEMORIES_TIMESERIES = "get_memories_timeseries"
GET_MEMORY_UNIT = "get_memory_unit"
GET_OBSERVATION_HISTORY = "get_observation_history"
GET_OPERATION_STATUS = "get_operation_status"
LIST_DIRECTIVES = "list_directives"
LIST_DOCUMENT_CHUNKS = "list_document_chunks"
LIST_DOCUMENTS = "list_documents"
LIST_ENTITIES = "list_entities"
LIST_MEMORY_UNITS = "list_memory_units"
LIST_MENTAL_MODEL_TAGS = "list_mental_model_tags"
LIST_MENTAL_MODELS = "list_mental_models"
LIST_OBSERVATION_SCOPES = "list_observation_scopes"
LIST_OPERATIONS = "list_operations"
LIST_TAGS = "list_tags"
LIST_WEBHOOK_DELIVERIES = "list_webhook_deliveries"
LIST_WEBHOOKS = "list_webhooks"
class BankWriteOperation(StrEnum):
"""Bank-scoped write operation names passed to validate_bank_write."""
CANCEL_OPERATION = "cancel_operation"
CLEAR_MENTAL_MODEL = "clear_mental_model"
CLEAR_OBSERVATIONS = "clear_observations"
CLEAR_OBSERVATIONS_FOR_MEMORY = "clear_observations_for_memory"
CREATE_DIRECTIVE = "create_directive"
CREATE_MENTAL_MODEL = "create_mental_model"
CREATE_WEBHOOK = "create_webhook"
DELETE_BANK = "delete_bank"
DELETE_DIRECTIVE = "delete_directive"
DELETE_DOCUMENT = "delete_document"
DELETE_MENTAL_MODEL = "delete_mental_model"
DELETE_WEBHOOK = "delete_webhook"
MERGE_BANK_MISSION = "merge_bank_mission"
REPROCESS_DOCUMENT = "reprocess_document"
RESET_BANK_CONFIG = "reset_bank_config"
RETRY_FAILED_CONSOLIDATION = "retry_failed_consolidation"
RETRY_OPERATION = "retry_operation"
RUN_CONSOLIDATION = "run_consolidation"
SET_BANK_MISSION = "set_bank_mission"
SUBMIT_ASYNC_CONSOLIDATION = "submit_async_consolidation"
SUBMIT_ASYNC_GRAPH_MAINTENANCE = "submit_async_graph_maintenance"
UPDATE_BANK = "update_bank"
UPDATE_BANK_CONFIG = "update_bank_config"
UPDATE_BANK_DISPOSITION = "update_bank_disposition"
UPDATE_DIRECTIVE = "update_directive"
UPDATE_DOCUMENT = "update_document"
UPDATE_MEMORY_UNIT = "update_memory_unit"
UPDATE_MENTAL_MODEL = "update_mental_model"
UPDATE_WEBHOOK = "update_webhook"
@dataclass
class BankReadContext:
"""Context for a bank read operation validation (pre-operation)."""
bank_id: str
operation: str # "get_bank_profile", "get_bank_stats"
operation: BankReadOperation
request_context: "RequestContext"
@@ -302,7 +393,7 @@ class BankWriteContext:
"""Context for a bank write operation validation (pre-operation)."""
bank_id: str
operation: str # "delete_bank", "update_bank", "update_bank_disposition", "set_bank_mission", "merge_bank_mission", "clear_observations", "clear_observations_for_memory"
operation: BankWriteOperation
request_context: "RequestContext"
+166 -72
View File
@@ -12,6 +12,7 @@ from datetime import datetime, timezone
from typing import Any, Callable
from fastmcp import FastMCP
from mcp.types import ToolAnnotations
from pydantic import TypeAdapter
from hindsight_api import MemoryEngine
@@ -21,7 +22,7 @@ from hindsight_api.config import (
)
from hindsight_api.engine.audit import AuditEntry, AuditLogger
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, MinScores
from hindsight_api.engine.search.tags import TagGroup
from hindsight_api.extensions import OperationValidationError
from hindsight_api.models import RequestContext
@@ -199,6 +200,47 @@ def build_content_dict(
return content_dict, None
# MCP tool annotations. Hindsight is a closed memory store (no open-world / internet
# access), so openWorldHint=False throughout. readOnlyHint lets clients group and
# auto-approve safe reads; destructiveHint flags tools that delete or clear memory.
_READ_ONLY_TOOLS = {
"recall",
"reflect",
"list_banks",
"get_bank",
"get_bank_stats",
"list_mental_models",
"get_mental_model",
"list_directives",
"list_memories",
"get_memory",
"list_documents",
"get_document",
"list_operations",
"get_operation",
"list_tags",
}
_DESTRUCTIVE_TOOLS = {
"delete_bank",
"clear_memories",
"clear_mental_model",
"delete_mental_model",
"delete_directive",
"delete_document",
"invalidate_memory",
}
def _tool_annotations(name: str) -> ToolAnnotations:
if name in _READ_ONLY_TOOLS:
return ToolAnnotations(readOnlyHint=True, openWorldHint=False)
if name in _DESTRUCTIVE_TOOLS:
return ToolAnnotations(readOnlyHint=False, destructiveHint=True, openWorldHint=False)
# Everything else writes but does not destructively delete/clear memory
# (retain, create_*, update_*, refresh_mental_model, cancel_operation).
return ToolAnnotations(readOnlyHint=False, destructiveHint=False, openWorldHint=False)
def register_mcp_tools(
mcp: FastMCP,
memory: MemoryEngine,
@@ -552,7 +594,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
if config.include_bank_id_param:
@mcp.tool(description=description)
@mcp.tool(description=description, annotations=_tool_annotations("retain"))
async def retain(
content: str,
context: str = "general",
@@ -608,7 +650,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
else:
@mcp.tool(description=description)
@mcp.tool(description=description, annotations=_tool_annotations("retain"))
async def retain(
content: str,
context: str = "general",
@@ -666,7 +708,7 @@ def _register_sync_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("sync_retain"))
async def sync_retain(
content: str,
context: str = "general",
@@ -724,7 +766,7 @@ def _register_sync_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("sync_retain"))
async def sync_retain(
content: str,
context: str = "general",
@@ -785,16 +827,18 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
if config.include_bank_id_param:
@mcp.tool(description=description)
@mcp.tool(description=description, annotations=_tool_annotations("recall"))
async def recall(
query: str,
max_tokens: int = 4096,
budget: str = "high",
types: list[str] | None = None,
prefer_observations: bool = False,
tags: list[str] | None = None,
tags_match: str = "any",
tag_groups: list[dict] | None = None,
query_timestamp: str | None = None,
min_scores: dict | None = None,
bank_id: str | None = None,
) -> str | dict:
"""
@@ -803,6 +847,10 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
max_tokens: Maximum tokens to return in results (default: 4096)
budget: Search budget - 'low', 'mid', or 'high' (default: 'high'). Higher budgets search more thoroughly.
types: Fact types to include (e.g., ['world', 'experience']). Default: all types.
prefer_observations: When recalling raw facts together with 'observation', drop any raw fact
that a returned observation was consolidated from, so the observation supersedes it (no
duplicate content). Disabled by default; set true to enable. No effect unless
'observation' and a raw type are both in types. Default: False.
tags: Optional tags to filter results by (e.g., ['project:alpha']). Mutually exclusive with tag_groups.
tags_match: How to match tags - 'any' (match any tag) or 'all' (match all tags). Default: 'any'
tag_groups: Compound tag filter using boolean groups (AND-ed together). Each group is a leaf
@@ -811,6 +859,11 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
Mutually exclusive with tags.
query_timestamp: Temporal context for the query (ISO format, e.g., '2024-01-15T10:30:00Z').
Anchors relative temporal expressions and recency scoring.
min_scores: Optional per-stage score floors as an object with any of: "semantic", "keyword"
(retrieval-level cutoffs), "reranker", "final" (post-ranking). E.g. {"reranker": 0.5}.
All inclusive and AND-ed; omit for no score filtering. The reranker's absolute scores are
not calibrated across queries, so only threshold against scores you've calibrated for your
own data.
bank_id: Optional bank to search in (defaults to session bank). Use for cross-bank operations.
"""
try:
@@ -831,6 +884,7 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
"bank_id": target_bank,
"query": query,
"fact_type": fact_types,
"prefer_observations": prefer_observations,
"budget": budget_enum,
"max_tokens": max_tokens,
"request_context": _get_request_context(config),
@@ -842,6 +896,8 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
recall_kwargs["tag_groups"] = _TAG_GROUP_LIST_ADAPTER.validate_python(tag_groups)
if query_timestamp is not None:
recall_kwargs["question_date"] = parse_timestamp(query_timestamp)
if min_scores is not None:
recall_kwargs["min_scores"] = MinScores.model_validate(min_scores)
recall_result = await memory.recall_async(**recall_kwargs)
@@ -857,16 +913,18 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
else:
@mcp.tool(description=description)
@mcp.tool(description=description, annotations=_tool_annotations("recall"))
async def recall(
query: str,
max_tokens: int = 4096,
budget: str = "high",
types: list[str] | None = None,
prefer_observations: bool = False,
tags: list[str] | None = None,
tags_match: str = "any",
tag_groups: list[dict] | None = None,
query_timestamp: str | None = None,
min_scores: dict | None = None,
) -> dict:
"""
Args:
@@ -874,6 +932,10 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
max_tokens: Maximum tokens to return in results (default: 4096)
budget: Search budget - 'low', 'mid', or 'high' (default: 'high'). Higher budgets search more thoroughly.
types: Fact types to include (e.g., ['world', 'experience']). Default: all types.
prefer_observations: When recalling raw facts together with 'observation', drop any raw fact
that a returned observation was consolidated from, so the observation supersedes it (no
duplicate content). Disabled by default; set true to enable. No effect unless
'observation' and a raw type are both in types. Default: False.
tags: Optional tags to filter results by (e.g., ['project:alpha']). Mutually exclusive with tag_groups.
tags_match: How to match tags - 'any' (match any tag) or 'all' (match all tags). Default: 'any'
tag_groups: Compound tag filter using boolean groups (AND-ed together). Each group is a leaf
@@ -882,6 +944,11 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
Mutually exclusive with tags.
query_timestamp: Temporal context for the query (ISO format, e.g., '2024-01-15T10:30:00Z').
Anchors relative temporal expressions and recency scoring.
min_scores: Optional per-stage score floors as an object with any of: "semantic", "keyword"
(retrieval-level cutoffs), "reranker", "final" (post-ranking). E.g. {"reranker": 0.5}.
All inclusive and AND-ed; omit for no score filtering. The reranker's absolute scores are
not calibrated across queries, so only threshold against scores you've calibrated for your
own data.
"""
try:
target_bank = config.bank_id_resolver()
@@ -901,6 +968,7 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
"bank_id": target_bank,
"query": query,
"fact_type": fact_types,
"prefer_observations": prefer_observations,
"budget": budget_enum,
"max_tokens": max_tokens,
"request_context": _get_request_context(config),
@@ -912,6 +980,8 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
recall_kwargs["tag_groups"] = _TAG_GROUP_LIST_ADAPTER.validate_python(tag_groups)
if query_timestamp is not None:
recall_kwargs["question_date"] = parse_timestamp(query_timestamp)
if min_scores is not None:
recall_kwargs["min_scores"] = MinScores.model_validate(min_scores)
recall_result = await memory.recall_async(**recall_kwargs)
@@ -931,7 +1001,7 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("reflect"))
async def reflect(
query: str,
context: str | None = None,
@@ -941,6 +1011,7 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
tags: list[str] | None = None,
tags_match: str = "any",
include_based_on: bool = False,
include_trace: bool = False,
bank_id: str | None = None,
) -> str:
"""
@@ -971,6 +1042,7 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
tags: Optional tags to filter memories by (e.g., ['project:alpha'])
tags_match: How to match tags - 'any' (match any tag) or 'all' (match all tags). Default: 'any'
include_based_on: Include source facts used for synthesis. Defaults to false because broad reflections can exceed MCP client result limits.
include_trace: Include the reflection's internal trace fields (tool_trace/llm_trace and directives_applied). Defaults to false because the trace can be tens of KB and overflow MCP client context; enable only for debugging.
bank_id: Optional bank to reflect in (defaults to session bank). Use for cross-bank operations.
"""
try:
@@ -1000,6 +1072,15 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
result_data = json.loads(reflect_result.model_dump_json(indent=2))
if not include_based_on:
result_data.pop("based_on", None)
if not include_trace:
# The agentic reflect loop's trace fields can be tens of KB (full
# mental-model text) and silently overflow MCP client context; the
# REST API omits them by default too. directives_applied is built by
# the engine "for the trace" and carries full directive content, so it
# belongs with tool_trace/llm_trace here. Opt in via include_trace.
result_data.pop("tool_trace", None)
result_data.pop("llm_trace", None)
result_data.pop("directives_applied", None)
if response_schema is not None and hasattr(reflect_result, "structured_output"):
result_data["structured_output"] = reflect_result.structured_output
return json.dumps(result_data, indent=2)
@@ -1012,7 +1093,7 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("reflect"))
async def reflect(
query: str,
context: str | None = None,
@@ -1022,6 +1103,7 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
tags: list[str] | None = None,
tags_match: str = "any",
include_based_on: bool = False,
include_trace: bool = False,
) -> dict:
"""
Generate thoughtful analysis by synthesizing stored memories with the bank's personality.
@@ -1051,6 +1133,7 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
tags: Optional tags to filter memories by (e.g., ['project:alpha'])
tags_match: How to match tags - 'any' (match any tag) or 'all' (match all tags). Default: 'any'
include_based_on: Include source facts used for synthesis. Defaults to false because broad reflections can exceed MCP client result limits.
include_trace: Include the reflection's internal trace fields (tool_trace/llm_trace and directives_applied). Defaults to false because the trace can be tens of KB and overflow MCP client context; enable only for debugging.
"""
try:
target_bank = config.bank_id_resolver()
@@ -1079,6 +1162,15 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
result_data = reflect_result.model_dump()
if not include_based_on:
result_data.pop("based_on", None)
if not include_trace:
# The agentic reflect loop's trace fields can be tens of KB (full
# mental-model text) and silently overflow MCP client context; the
# REST API omits them by default too. directives_applied is built by
# the engine "for the trace" and carries full directive content, so it
# belongs with tool_trace/llm_trace here. Opt in via include_trace.
result_data.pop("tool_trace", None)
result_data.pop("llm_trace", None)
result_data.pop("directives_applied", None)
if response_schema is not None and hasattr(reflect_result, "structured_output"):
result_data["structured_output"] = reflect_result.structured_output
return result_data
@@ -1093,7 +1185,7 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
def _register_list_banks(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the list_banks tool."""
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_banks"))
async def list_banks() -> str:
"""
List all available memory banks.
@@ -1118,7 +1210,7 @@ def _register_list_banks(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCon
def _register_create_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the create_bank tool."""
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("create_bank"))
async def create_bank(bank_id: str, name: str | None = None, mission: str | None = None) -> str:
"""
Create a new memory bank or get an existing one.
@@ -1182,7 +1274,7 @@ def _register_list_mental_models(mcp: FastMCP, memory: MemoryEngine, config: MCP
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_mental_models"))
async def list_mental_models(
tags: list[str] | None = None,
detail: str = "full",
@@ -1221,7 +1313,7 @@ def _register_list_mental_models(mcp: FastMCP, memory: MemoryEngine, config: MCP
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_mental_models"))
async def list_mental_models(
tags: list[str] | None = None,
detail: str = "full",
@@ -1262,7 +1354,7 @@ def _register_get_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPTo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_mental_model"))
async def get_mental_model(
mental_model_id: str,
detail: str = "full",
@@ -1302,7 +1394,7 @@ def _register_get_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPTo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_mental_model"))
async def get_mental_model(
mental_model_id: str,
detail: str = "full",
@@ -1344,7 +1436,7 @@ def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("create_mental_model"))
async def create_mental_model(
name: str,
source_query: str,
@@ -1428,7 +1520,7 @@ def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("create_mental_model"))
async def create_mental_model(
name: str,
source_query: str,
@@ -1510,7 +1602,7 @@ def _register_update_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("update_mental_model"))
async def update_mental_model(
mental_model_id: str,
name: str | None = None,
@@ -1571,7 +1663,7 @@ def _register_update_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("update_mental_model"))
async def update_mental_model(
mental_model_id: str,
name: str | None = None,
@@ -1634,7 +1726,7 @@ def _register_delete_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("delete_mental_model"))
async def delete_mental_model(
mental_model_id: str,
bank_id: str | None = None,
@@ -1670,7 +1762,7 @@ def _register_delete_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("delete_mental_model"))
async def delete_mental_model(
mental_model_id: str,
) -> dict:
@@ -1708,7 +1800,7 @@ def _register_refresh_mental_model(mcp: FastMCP, memory: MemoryEngine, config: M
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("refresh_mental_model"))
async def refresh_mental_model(
mental_model_id: str,
bank_id: str | None = None,
@@ -1752,7 +1844,7 @@ def _register_refresh_mental_model(mcp: FastMCP, memory: MemoryEngine, config: M
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("refresh_mental_model"))
async def refresh_mental_model(
mental_model_id: str,
) -> dict:
@@ -1796,7 +1888,7 @@ def _register_clear_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCP
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("clear_mental_model"))
async def clear_mental_model(
mental_model_id: str,
bank_id: str | None = None,
@@ -1842,7 +1934,7 @@ def _register_clear_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCP
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("clear_mental_model"))
async def clear_mental_model(
mental_model_id: str,
) -> dict:
@@ -1893,7 +1985,7 @@ def _register_list_directives(mcp: FastMCP, memory: MemoryEngine, config: MCPToo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_directives"))
async def list_directives(
tags: list[str] | None = None,
active_only: bool = True,
@@ -1931,7 +2023,7 @@ def _register_list_directives(mcp: FastMCP, memory: MemoryEngine, config: MCPToo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_directives"))
async def list_directives(
tags: list[str] | None = None,
active_only: bool = True,
@@ -1971,7 +2063,7 @@ def _register_create_directive(mcp: FastMCP, memory: MemoryEngine, config: MCPTo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("create_directive"))
async def create_directive(
name: str,
content: str,
@@ -2017,7 +2109,7 @@ def _register_create_directive(mcp: FastMCP, memory: MemoryEngine, config: MCPTo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("create_directive"))
async def create_directive(
name: str,
content: str,
@@ -2065,7 +2157,7 @@ def _register_delete_directive(mcp: FastMCP, memory: MemoryEngine, config: MCPTo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("delete_directive"))
async def delete_directive(
directive_id: str,
bank_id: str | None = None,
@@ -2101,7 +2193,7 @@ def _register_delete_directive(mcp: FastMCP, memory: MemoryEngine, config: MCPTo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("delete_directive"))
async def delete_directive(
directive_id: str,
) -> dict:
@@ -2144,7 +2236,7 @@ def _register_list_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_memories"))
async def list_memories(
type: str | None = None,
q: str | None = None,
@@ -2159,7 +2251,7 @@ def _register_list_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
browse/search without relevance ranking.
Args:
type: Filter by fact type: 'world', 'experience', or 'opinion'
type: Filter by fact type: 'world', 'experience', or 'observation'
q: Optional text search query to filter memories
limit: Maximum number of results (default: 100)
offset: Pagination offset (default: 0)
@@ -2188,7 +2280,7 @@ def _register_list_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_memories"))
async def list_memories(
type: str | None = None,
q: str | None = None,
@@ -2202,7 +2294,7 @@ def _register_list_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
browse/search without relevance ranking.
Args:
type: Filter by fact type: 'world', 'experience', or 'opinion'
type: Filter by fact type: 'world', 'experience', or 'observation'
q: Optional text search query to filter memories
limit: Maximum number of results (default: 100)
offset: Pagination offset (default: 0)
@@ -2234,7 +2326,7 @@ def _register_get_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCon
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_memory"))
async def get_memory(
memory_id: str,
bank_id: str | None = None,
@@ -2270,7 +2362,7 @@ def _register_get_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCon
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_memory"))
async def get_memory(
memory_id: str,
) -> dict:
@@ -2321,7 +2413,7 @@ def _register_update_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(description=_EDIT_DOC, annotations=_tool_annotations("update_memory"))
async def update_memory(
memory_id: str,
text: str | None = None,
@@ -2332,7 +2424,7 @@ def _register_update_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
entities: list[str] | None = None,
bank_id: str | None = None,
) -> str:
f"""{_EDIT_DOC}
"""
Args:
memory_id: The ID of the memory unit to edit.
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
@@ -2367,7 +2459,7 @@ def _register_update_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
else:
@mcp.tool()
@mcp.tool(description=_EDIT_DOC, annotations=_tool_annotations("update_memory"))
async def update_memory(
memory_id: str,
text: str | None = None,
@@ -2377,7 +2469,7 @@ def _register_update_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
fact_type: str | None = None,
entities: list[str] | None = None,
) -> dict:
f"""{_EDIT_DOC}
"""
Args:
memory_id: The ID of the memory unit to edit.
"""
@@ -2426,14 +2518,14 @@ def _register_invalidate_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPT
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(description=_INVALIDATE_DOC, annotations=_tool_annotations("invalidate_memory"))
async def invalidate_memory(
memory_id: str,
reason: str | None = None,
restore: bool = False,
bank_id: str | None = None,
) -> str:
f"""{_INVALIDATE_DOC}
"""
Args:
memory_id: The ID of the memory unit to retire (or restore).
reason: Optional free-text reason recorded when invalidating.
@@ -2466,13 +2558,13 @@ def _register_invalidate_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPT
else:
@mcp.tool()
@mcp.tool(description=_INVALIDATE_DOC, annotations=_tool_annotations("invalidate_memory"))
async def invalidate_memory(
memory_id: str,
reason: str | None = None,
restore: bool = False,
) -> dict:
f"""{_INVALIDATE_DOC}
"""
Args:
memory_id: The ID of the memory unit to retire (or restore).
reason: Optional free-text reason recorded when invalidating.
@@ -2513,7 +2605,7 @@ def _register_list_documents(mcp: FastMCP, memory: MemoryEngine, config: MCPTool
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_documents"))
async def list_documents(
q: str | None = None,
limit: int = 100,
@@ -2551,7 +2643,7 @@ def _register_list_documents(mcp: FastMCP, memory: MemoryEngine, config: MCPTool
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_documents"))
async def list_documents(
q: str | None = None,
limit: int = 100,
@@ -2591,7 +2683,7 @@ def _register_get_document(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsC
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_document"))
async def get_document(
document_id: str,
bank_id: str | None = None,
@@ -2627,7 +2719,7 @@ def _register_get_document(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsC
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_document"))
async def get_document(
document_id: str,
) -> dict:
@@ -2665,7 +2757,7 @@ def _register_delete_document(mcp: FastMCP, memory: MemoryEngine, config: MCPToo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("delete_document"))
async def delete_document(
document_id: str,
bank_id: str | None = None,
@@ -2699,7 +2791,7 @@ def _register_delete_document(mcp: FastMCP, memory: MemoryEngine, config: MCPToo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("delete_document"))
async def delete_document(
document_id: str,
) -> dict:
@@ -2740,7 +2832,7 @@ def _register_list_operations(mcp: FastMCP, memory: MemoryEngine, config: MCPToo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_operations"))
async def list_operations(
status: str | None = None,
limit: int = 20,
@@ -2777,7 +2869,7 @@ def _register_list_operations(mcp: FastMCP, memory: MemoryEngine, config: MCPToo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_operations"))
async def list_operations(
status: str | None = None,
limit: int = 20,
@@ -2816,7 +2908,7 @@ def _register_get_operation(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_operation"))
async def get_operation(
operation_id: str,
bank_id: str | None = None,
@@ -2850,7 +2942,7 @@ def _register_get_operation(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_operation"))
async def get_operation(
operation_id: str,
) -> dict:
@@ -2886,7 +2978,7 @@ def _register_cancel_operation(mcp: FastMCP, memory: MemoryEngine, config: MCPTo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("cancel_operation"))
async def cancel_operation(
operation_id: str,
bank_id: str | None = None,
@@ -2918,7 +3010,7 @@ def _register_cancel_operation(mcp: FastMCP, memory: MemoryEngine, config: MCPTo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("cancel_operation"))
async def cancel_operation(
operation_id: str,
) -> dict:
@@ -2957,7 +3049,7 @@ def _register_list_tags(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConf
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_tags"))
async def list_tags(
q: str | None = None,
limit: int = 100,
@@ -2994,7 +3086,7 @@ def _register_list_tags(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConf
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_tags"))
async def list_tags(
q: str | None = None,
limit: int = 100,
@@ -3033,7 +3125,7 @@ def _register_get_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfi
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_bank"))
async def get_bank(
bank_id: str | None = None,
) -> str:
@@ -3066,7 +3158,7 @@ def _register_get_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfi
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_bank"))
async def get_bank() -> dict:
"""
Get the profile of this memory bank.
@@ -3096,7 +3188,7 @@ def _register_get_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfi
def _register_get_bank_stats(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the get_bank_stats tool (multi-bank only)."""
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_bank_stats"))
async def get_bank_stats(
bank_id: str | None = None,
) -> str:
@@ -3169,7 +3261,7 @@ def _register_update_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("update_bank"))
async def update_bank(
name: str | None = None,
mission: str | None = None,
@@ -3191,7 +3283,8 @@ def _register_update_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
- retain_mission: Steers what gets extracted during retain().
- retain_extraction_mode: 'concise' (default), 'verbose', or 'custom'.
- retain_custom_instructions: Custom extraction prompt (active when mode is 'custom').
- retain_chunk_size: Maximum token size for each content chunk.
- retain_chunk_size: Target maximum characters for each content chunk.
- retain_structured_chunk_size: Maximum characters for a single JSONL line or conversation turn to keep whole.
- retain_chunk_batch_size: Number of chunks to process in parallel.
- enable_observations: Toggle observation consolidation after retain().
- observations_mission: Controls observation synthesis rules.
@@ -3229,7 +3322,7 @@ def _register_update_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("update_bank"))
async def update_bank(
name: str | None = None,
mission: str | None = None,
@@ -3250,7 +3343,8 @@ def _register_update_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
- retain_mission: Steers what gets extracted during retain().
- retain_extraction_mode: 'concise' (default), 'verbose', or 'custom'.
- retain_custom_instructions: Custom extraction prompt (active when mode is 'custom').
- retain_chunk_size: Maximum token size for each content chunk.
- retain_chunk_size: Target maximum characters for each content chunk.
- retain_structured_chunk_size: Maximum characters for a single JSONL line or conversation turn to keep whole.
- retain_chunk_batch_size: Number of chunks to process in parallel.
- enable_observations: Toggle observation consolidation after retain().
- observations_mission: Controls observation synthesis rules.
@@ -3291,7 +3385,7 @@ def _register_delete_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("delete_bank"))
async def delete_bank(
bank_id: str | None = None,
) -> str:
@@ -3323,7 +3417,7 @@ def _register_delete_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("delete_bank"))
async def delete_bank() -> dict:
"""
Delete this memory bank and all its data.
@@ -3354,7 +3448,7 @@ def _register_clear_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTool
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("clear_memories"))
async def clear_memories(
type: str | None = None,
bank_id: str | None = None,
@@ -3365,7 +3459,7 @@ def _register_clear_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTool
Optionally filter by fact type to only clear specific kinds of memories.
Args:
type: Optional fact type filter: 'world', 'experience', or 'opinion'. If not specified, clears all.
type: Optional fact type filter: 'world', 'experience', or 'observation'. If not specified, clears all.
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
@@ -3389,7 +3483,7 @@ def _register_clear_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTool
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("clear_memories"))
async def clear_memories(
type: str | None = None,
) -> dict:
@@ -3399,7 +3493,7 @@ def _register_clear_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTool
Optionally filter by fact type to only clear specific kinds of memories.
Args:
type: Optional fact type filter: 'world', 'experience', or 'opinion'. If not specified, clears all.
type: Optional fact type filter: 'world', 'experience', or 'observation'. If not specified, clears all.
"""
try:
target_bank = config.bank_id_resolver()
+345 -15
View File
@@ -11,15 +11,17 @@ This module provides metrics for:
- Database connection pool metrics
"""
import asyncio
import importlib
import logging
import os
import re
_resource_mod = importlib.import_module("resource") if importlib.util.find_spec("resource") else None
import threading
import time
from contextlib import contextmanager
from typing import TYPE_CHECKING, Callable
from typing import TYPE_CHECKING, Callable, NamedTuple
from opentelemetry import metrics
from opentelemetry.exporter.prometheus import PrometheusMetricReader
@@ -39,6 +41,32 @@ def _get_tenant() -> str:
return get_current_schema()
def _is_client_cancellation(exc: BaseException) -> bool:
"""Whether *exc* is a client-disconnect cancellation rather than a failure.
An abandoned recall/reflect raises OperationCancelledError (issue #2122);
the HTTP layer re-raises it as ``HTTPException(499) from exc`` (see
api/http.py run_cancellable_on_disconnect). The exception itself, or any
link in its ``__cause__`` chain, being an OperationCancelledError marks it
as a cancellation. Matching on the cause chain rather than a bare status
code avoids misclassifying an unrelated 499 as a cancellation. Per the
engine contract a cancellation is "not a failure to retry or report"
(cancellation.OperationCancelledError), so it must not be counted against
``hindsight.operation.total``.
"""
# Imported lazily to avoid import-time coupling (cf. _get_tenant above).
from hindsight_api.cancellation import OperationCancelledError
cause: BaseException | None = exc
seen: set[int] = set() # guard against a cyclic __cause__ chain
while cause is not None and id(cause) not in seen:
if isinstance(cause, OperationCancelledError):
return True
seen.add(id(cause))
cause = cause.__cause__
return False
# Custom bucket boundaries for operation duration (in seconds)
# Fine granularity in 0-30s range where most operations complete
DURATION_BUCKETS = (0.1, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 5.0, 7.5, 10.0, 15.0, 20.0, 30.0, 60.0, 120.0)
@@ -49,6 +77,28 @@ LLM_DURATION_BUCKETS = (0.1, 0.25, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 30.0, 60
# HTTP request duration buckets (millisecond-level for fast endpoints)
HTTP_DURATION_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0)
# How often the backlog / queue-depth gauge caches are refreshed (seconds).
# The counts are aggregate COUNT queries, so a background task refreshes a
# cache and the observable gauges read from it — keeping the /metrics scrape
# path synchronous (the same reason the db-pool gauges read cached state).
BACKLOG_METRICS_REFRESH_SECONDS = 30
class _AsyncOpKey(NamedTuple):
"""Cache / label key for the async-operation queue gauge."""
tenant: str
operation_type: str
status: str
bank_id: str | None
class _BacklogKey(NamedTuple):
"""Cache / label key for the consolidation backlog and failed gauges."""
tenant: str
bank_id: str | None
def get_token_bucket(token_count: int) -> str:
"""
@@ -87,6 +137,27 @@ def get_token_bucket(token_count: int) -> str:
return "50k+"
# Template unbounded id segments before a path is used as the low-cardinality
# "endpoint" metric label. A raw per-bank path segment (e.g. user-123) would
# otherwise create one never-evicted OTel series per bank.
_METRIC_BANK_SEGMENT_RE = re.compile(r"(/banks/)[^/]+")
_METRIC_UUID_RE = re.compile(r"/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
_METRIC_NUMERIC_ID_RE = re.compile(r"/\d+(?=/|$)")
def normalize_http_endpoint(path: str) -> str:
"""Template high-cardinality id segments in an HTTP path for safe metric labeling.
Collapses the "/banks/<id>" segment (any bank id, including non-numeric ones like
"user-123"), UUIDs, and numeric ids to placeholders so the "endpoint" metric label
has bounded cardinality. Analogous to get_token_bucket for token counts.
"""
path = _METRIC_BANK_SEGMENT_RE.sub(r"\g<1>{bank_id}", path)
path = _METRIC_UUID_RE.sub("/{id}", path)
path = _METRIC_NUMERIC_ID_RE.sub("/{id}", path)
return path
logger = logging.getLogger(__name__)
# Global meter instance
@@ -175,6 +246,19 @@ class MetricsCollectorBase:
"""Context manager to record operation duration and status."""
raise NotImplementedError
def record_operation_result(
self,
operation: str,
bank_id: str,
success: bool,
duration: float,
source: str = "api",
budget: str | None = None,
max_tokens: int | None = None,
):
"""Record a single completed operation with an explicit success label."""
raise NotImplementedError
def record_llm_call(
self,
provider: str,
@@ -228,6 +312,19 @@ class NoOpMetricsCollector(MetricsCollectorBase):
"""No-op context manager."""
yield
def record_operation_result(
self,
operation: str,
bank_id: str,
success: bool,
duration: float,
source: str = "api",
budget: str | None = None,
max_tokens: int | None = None,
):
"""No-op operation result recording."""
pass
def record_llm_call(
self,
provider: str,
@@ -335,6 +432,13 @@ class MetricsCollector(MetricsCollectorBase):
# DB pool metrics holder (set via set_db_pool)
self._db_pool: "asyncpg.Pool | None" = None
# Backlog / queue-depth gauge caches, refreshed by a background task
# (see _setup_backlog_metrics) so the scrape path stays synchronous.
self._async_ops_counts: dict[_AsyncOpKey, int] = {}
self._consolidation_backlog: dict[_BacklogKey, int] = {}
self._consolidation_failed: dict[_BacklogKey, int] = {}
self._backlog_task: "asyncio.Task | None" = None
@contextmanager
def record_operation(
self,
@@ -360,6 +464,51 @@ class MetricsCollector(MetricsCollectorBase):
max_tokens: Optional max tokens for the operation
"""
start_time = time.time()
success = True
cancelled = False
try:
yield
except Exception as exc:
# A client disconnect cancels the operation cooperatively (#2122),
# raised as OperationCancelledError and re-raised by the HTTP layer
# as HTTPException(499) from it. An abandoned request is neither a
# success nor a failure, so it is excluded from the metric entirely
# rather than inflating either the failure or the success rate on
# hindsight.operation.total.
if _is_client_cancellation(exc):
cancelled = True
else:
success = False
raise
finally:
if not cancelled:
self.record_operation_result(
operation,
bank_id,
success=success,
duration=time.time() - start_time,
source=source,
budget=budget,
max_tokens=max_tokens,
)
def record_operation_result(
self,
operation: str,
bank_id: str,
success: bool,
duration: float,
source: str = "api",
budget: str | None = None,
max_tokens: int | None = None,
):
"""Record a single completed operation (duration + count) with a success label.
Direct (non-context-manager) recording for code paths that need explicit
success control rather than the exception-based ``record_operation`` e.g.
the async worker, where deferrals/retries are not terminal outcomes and must
not be counted as completions.
"""
attributes = {
"operation": operation,
"source": source,
@@ -371,22 +520,13 @@ class MetricsCollector(MetricsCollectorBase):
attributes["budget"] = budget
if max_tokens:
attributes["max_tokens"] = str(max_tokens)
attributes["success"] = str(success).lower()
success = True
try:
yield
except Exception:
success = False
raise
finally:
duration = time.time() - start_time
attributes["success"] = str(success).lower()
# Record duration
self.operation_duration.record(duration, attributes)
# Record duration
self.operation_duration.record(duration, attributes)
# Record operation count
self.operation_total.add(1, attributes)
# Record operation count
self.operation_total.add(1, attributes)
def record_llm_call(
self,
@@ -591,6 +731,10 @@ class MetricsCollector(MetricsCollectorBase):
"""
self._db_pool = pool
self._setup_db_pool_metrics()
from .config import get_config
if get_config().metrics_backlog_enabled:
self._setup_backlog_metrics()
def _setup_db_pool_metrics(self):
"""Set up observable gauges for database pool metrics."""
@@ -656,6 +800,192 @@ class MetricsCollector(MetricsCollectorBase):
unit="{connections}",
)
def _setup_backlog_metrics(self):
"""Observable gauges for the async-operation queue and the
consolidation backlog.
These mirror fields the bank-stats endpoint already computes
(``operations_by_status``, ``pending_consolidation``,
``failed_consolidation``) but expose them as scrapable gauges, so
queue depth and backlog can be trended and alerted on instead of only
polled per-bank over HTTP. The two motivating questions both come for
free here: "is the worker keeping up?" (async-op queue) and "is the
knowledge base caught up?" (consolidation backlog) — including the
``processing`` state, which is the only signal that surfaces a hung
operation stuck holding a worker slot.
Counts are aggregate ``COUNT`` queries, so a background task refreshes
a cache every ``BACKLOG_METRICS_REFRESH_SECONDS`` and these callbacks
read it keeping the scrape path synchronous, the same approach as
the db-pool gauges above.
"""
if self._backlog_task is not None:
return # already started for this collector
def get_async_operations(_options):
for key, value in list(self._async_ops_counts.items()):
attrs = {"tenant": key.tenant, "operation_type": key.operation_type, "status": key.status}
if key.bank_id is not None:
attrs["bank_id"] = key.bank_id
yield metrics.Observation(value, attrs)
def get_consolidation_backlog(_options):
for key, value in list(self._consolidation_backlog.items()):
attrs = {"tenant": key.tenant}
if key.bank_id is not None:
attrs["bank_id"] = key.bank_id
yield metrics.Observation(value, attrs)
def get_consolidation_failed(_options):
for key, value in list(self._consolidation_failed.items()):
attrs = {"tenant": key.tenant}
if key.bank_id is not None:
attrs["bank_id"] = key.bank_id
yield metrics.Observation(value, attrs)
self.meter.create_observable_gauge(
name="hindsight.async_operations",
callbacks=[get_async_operations],
description="Async operations in a non-terminal state, by operation_type and status "
"(pending=queued backlog, processing=in-flight, failed=stranded)",
unit="{operations}",
)
self.meter.create_observable_gauge(
name="hindsight.consolidation.backlog",
callbacks=[get_consolidation_backlog],
description="Source memories (experience/world) not yet consolidated into observations",
unit="{memories}",
)
self.meter.create_observable_gauge(
name="hindsight.consolidation.failed",
callbacks=[get_consolidation_failed],
description="Source memories whose consolidation permanently failed "
"(recoverable via the consolidation recovery endpoint)",
unit="{memories}",
)
# Drive the caches from a background task on the running loop.
# set_db_pool runs during async startup, so a loop is normally present;
# if not, the gauges simply stay empty rather than crashing collection.
try:
loop = asyncio.get_running_loop()
except RuntimeError:
logger.warning("No running event loop; backlog metrics disabled")
return
# Process-lifetime task: there is no collector teardown hook to cancel it
# on, so it's torn down with the event loop at process shutdown. If a
# shutdown path is ever added, cancel self._backlog_task there.
self._backlog_task = loop.create_task(self._backlog_refresh_loop())
async def _backlog_refresh_loop(self):
"""Periodically refresh the backlog / queue-depth caches."""
while True:
try:
await self._refresh_backlog()
except Exception:
logger.debug("Backlog metrics refresh failed", exc_info=True)
await asyncio.sleep(BACKLOG_METRICS_REFRESH_SECONDS)
async def _refresh_backlog(self):
"""Recount the async-operation queue and consolidation backlog across
every provisioned Hindsight schema.
Per-bank labels are gated behind ``metrics_include_bank_id`` (off by
default) to keep cardinality bounded; when off, counts are aggregated
per tenant/schema. All SQL here is PostgreSQL-specific (``FILTER``,
``information_schema``), which is consistent with this collector
already being bound to an asyncpg pool.
"""
if self._db_pool is None:
return
async_ops: dict[_AsyncOpKey, int] = {}
backlog: dict[_BacklogKey, int] = {}
failed: dict[_BacklogKey, int] = {}
per_bank = self._include_bank_id
bank_sel = "bank_id, " if per_bank else ""
bank_grp = " GROUP BY bank_id" if per_bank else ""
async with self._db_pool.acquire() as conn:
# memory_units is the central per-tenant table; its presence marks a
# provisioned Hindsight schema.
schema_rows = await conn.fetch(
"SELECT table_schema FROM information_schema.tables WHERE table_name = 'memory_units'"
)
for schema_row in schema_rows:
schema = schema_row["table_schema"]
# Worker queue depth — mirrors operations_by_status, split by
# operation_type. Terminal states (completed/cancelled) are
# excluded on purpose: a gauge of finished work grows without
# bound and says nothing about current load.
# Index: idx_async_operations_status.
ops_grp = "operation_type, status" + (", bank_id" if per_bank else "")
try:
rows = await conn.fetch(
f"SELECT operation_type, status, {bank_sel}COUNT(*) AS count "
f'FROM "{schema}".async_operations '
"WHERE status IN ('pending', 'processing', 'failed') "
f"GROUP BY {ops_grp}"
)
for row in rows:
bank = row["bank_id"] if per_bank else None
key = _AsyncOpKey(schema, row["operation_type"] or "unknown", row["status"], bank)
async_ops[key] = async_ops.get(key, 0) + int(row["count"])
except Exception:
logger.debug("Async-ops queue query failed for schema %s", schema, exc_info=True)
# Consolidation backlog + stranded counts. Two separate COUNT(*)
# queries rather than one with two FILTERs — each WHERE matches a
# partial-index predicate exactly:
# idx_memory_units_unconsolidated WHERE consolidated_at IS NULL ...
# idx_memory_units_consolidation_failed WHERE consolidation_failed_at IS NOT NULL ...
# GROUP BY bank_id still composes — bank_id is each index's lead column.
#
# The backlog count runs with seqscan disabled in a scoped
# transaction. The partial index matches its predicate, but
# `consolidated_at IS NULL` is true for a large fraction of the
# table (every observation has a null consolidated_at), so the
# planner misjudges selectivity and otherwise seq-scans the whole
# (largest) table on every refresh — verified on a 114k-row table
# via EXPLAIN: seq scan ~92 ms vs index scan ~0.1 ms. SET LOCAL
# forces the index path and resets at transaction end. The failed
# count below needs no such nudge: `consolidation_failed_at IS NOT
# NULL` is rare, so its index is chosen on cost.
try:
async with conn.transaction():
await conn.execute("SET LOCAL enable_seqscan = off")
rows = await conn.fetch(
f"SELECT {bank_sel}COUNT(*) AS count "
f'FROM "{schema}".memory_units '
"WHERE consolidated_at IS NULL AND fact_type IN ('experience', 'world')"
f"{bank_grp}"
)
for row in rows:
bank = row["bank_id"] if per_bank else None
key = _BacklogKey(schema, bank)
backlog[key] = backlog.get(key, 0) + int(row["count"])
except Exception:
logger.debug("Consolidation backlog query failed for schema %s", schema, exc_info=True)
try:
rows = await conn.fetch(
f"SELECT {bank_sel}COUNT(*) AS count "
f'FROM "{schema}".memory_units '
"WHERE consolidation_failed_at IS NOT NULL AND fact_type IN ('experience', 'world')"
f"{bank_grp}"
)
for row in rows:
bank = row["bank_id"] if per_bank else None
key = _BacklogKey(schema, bank)
failed[key] = failed.get(key, 0) + int(row["count"])
except Exception:
logger.debug("Consolidation failed query failed for schema %s", schema, exc_info=True)
self._async_ops_counts = async_ops
self._consolidation_backlog = backlog
self._consolidation_failed = failed
# Global metrics collector instance (defaults to no-op)
_metrics_collector: MetricsCollectorBase = NoOpMetricsCollector()
+240 -100
View File
@@ -27,10 +27,12 @@ from alembic.config import Config
from alembic.script.revision import ResolutionError
from alembic.util.exc import CommandError
from sqlalchemy import Connection, create_engine, text
from sqlalchemy.pool import NullPool
from ._pg_search import normalize_pg_search_tokenizer, pg_search_bm25_columns
from ._vector_index import (
bootstrap_extension,
configured_vector_extension,
detect_vector_extension,
index_type_keyword,
index_using_clause,
@@ -59,6 +61,86 @@ def _detect_vector_extension(conn, vector_extension: str = "pgvector") -> str:
return detect_vector_extension(conn, vector_extension)
def _ensure_pgvector_extension_in_public(conn: Connection) -> None:
"""Ensure pgvector is installed before pgvector-backed migrations run."""
logger.debug("Checking pgvector extension availability...")
# First, check if extension already exists
ext_check = conn.execute(
text(
"SELECT extname, nspname FROM pg_extension e "
"JOIN pg_namespace n ON e.extnamespace = n.oid "
"WHERE extname = 'vector'"
)
).fetchone()
if ext_check:
# Extension exists - check if in correct schema
ext_schema = ext_check[1]
if ext_schema == "public":
logger.info("pgvector extension found in public schema - ready to use")
else:
# Extension in wrong schema - try to fix if we have permissions
logger.warning(
f"pgvector extension found in schema '{ext_schema}' instead of 'public'. Attempting to relocate..."
)
try:
conn.execute(text("DROP EXTENSION vector CASCADE"))
conn.execute(text("SET search_path TO public"))
conn.execute(text("CREATE EXTENSION vector"))
conn.commit()
logger.info("pgvector extension relocated to public schema")
except Exception as e:
# Failed to relocate - log but don't fail if extension exists somewhere
logger.warning(
f"Could not relocate pgvector extension to public schema: {e}. "
f"Continuing with extension in '{ext_schema}' schema."
)
conn.rollback()
else:
# Extension doesn't exist - try to install
logger.info("pgvector extension not found, attempting to install...")
try:
conn.execute(text("SET search_path TO public"))
conn.execute(text("CREATE EXTENSION vector"))
conn.commit()
logger.info("pgvector extension installed in public schema")
except Exception as e:
# Installation failed - this is only fatal if extension truly doesn't exist
# Check one more time in case another process installed it
conn.rollback()
ext_recheck = conn.execute(
text(
"SELECT nspname FROM pg_extension e "
"JOIN pg_namespace n ON e.extnamespace = n.oid "
"WHERE extname = 'vector'"
)
).fetchone()
if ext_recheck:
logger.warning(
f"Could not install pgvector extension (permission denied?), "
f"but extension exists in '{ext_recheck[0]}' schema. Continuing..."
)
else:
# Extension truly doesn't exist and we can't install it
logger.error(
f"pgvector extension is not installed and cannot be installed: {e}. "
f"Please ensure pgvector is installed by a database administrator. "
f"See: https://github.com/pgvector/pgvector#installation"
)
raise RuntimeError(
"pgvector extension is required but not installed. Please install it with: CREATE EXTENSION vector;"
) from e
def _bootstrap_vector_extension_for_migrations(conn: Connection, vector_extension: str) -> None:
"""Bootstrap the configured vector backend before schema migrations run."""
if vector_extension == "pgvector":
_ensure_pgvector_extension_in_public(conn)
bootstrap_extension(conn, vector_extension)
def _drop_per_bank_vector_indexes(conn: Connection, schema_name: str) -> None:
"""Drop per-bank partial memory_units vector indexes after global ScaNN is ready."""
rows = conn.execute(
@@ -247,7 +329,14 @@ def run_migrations(
# 2. After acquiring the lock, COMMIT the transaction on the advisory-lock
# connection itself before running migrations. pg_advisory_lock is
# session-level, so the lock survives the COMMIT.
engine = create_engine(migration_url)
# NullPool: do not retain the connection in a pool after the migration.
# Each schema migration opens a few short-lived engines (here plus the
# ensure_* steps); with the default QueuePool those connections linger
# until GC, and running many schemas in parallel (migration_concurrency)
# multiplies that footprint and exhausts max_connections — observed as
# "FATAL: sorry, too many clients already" sweeping 20k schemas at
# concurrency 12. NullPool closes the connection on return.
engine = create_engine(migration_url, poolclass=NullPool)
with engine.connect() as conn:
logger.debug(f"Acquiring migration advisory lock for schema '{schema_name}' (id={lock_id})...")
while True:
@@ -267,83 +356,8 @@ def run_migrations(
logger.debug("Migration advisory lock acquired")
try:
# Ensure pgvector extension is installed globally BEFORE schema migrations
# This is critical: the extension must exist database-wide before any schema
# migrations run, otherwise custom schemas won't have access to vector types
logger.debug("Checking pgvector extension availability...")
# First, check if extension already exists
ext_check = conn.execute(
text(
"SELECT extname, nspname FROM pg_extension e "
"JOIN pg_namespace n ON e.extnamespace = n.oid "
"WHERE extname = 'vector'"
)
).fetchone()
if ext_check:
# Extension exists - check if in correct schema
ext_schema = ext_check[1]
if ext_schema == "public":
logger.info("pgvector extension found in public schema - ready to use")
else:
# Extension in wrong schema - try to fix if we have permissions
logger.warning(
f"pgvector extension found in schema '{ext_schema}' instead of 'public'. "
f"Attempting to relocate..."
)
try:
conn.execute(text("DROP EXTENSION vector CASCADE"))
conn.execute(text("SET search_path TO public"))
conn.execute(text("CREATE EXTENSION vector"))
conn.commit()
logger.info("pgvector extension relocated to public schema")
except Exception as e:
# Failed to relocate - log but don't fail if extension exists somewhere
logger.warning(
f"Could not relocate pgvector extension to public schema: {e}. "
f"Continuing with extension in '{ext_schema}' schema."
)
conn.rollback()
else:
# Extension doesn't exist - try to install
logger.info("pgvector extension not found, attempting to install...")
try:
conn.execute(text("SET search_path TO public"))
conn.execute(text("CREATE EXTENSION vector"))
conn.commit()
logger.info("pgvector extension installed in public schema")
except Exception as e:
# Installation failed - this is only fatal if extension truly doesn't exist
# Check one more time in case another process installed it
conn.rollback()
ext_recheck = conn.execute(
text(
"SELECT nspname FROM pg_extension e "
"JOIN pg_namespace n ON e.extnamespace = n.oid "
"WHERE extname = 'vector'"
)
).fetchone()
if ext_recheck:
logger.warning(
f"Could not install pgvector extension (permission denied?), "
f"but extension exists in '{ext_recheck[0]}' schema. Continuing..."
)
else:
# Extension truly doesn't exist and we can't install it
logger.error(
f"pgvector extension is not installed and cannot be installed: {e}. "
f"Please ensure pgvector is installed by a database administrator. "
f"See: https://github.com/pgvector/pgvector#installation"
)
raise RuntimeError(
"pgvector extension is required but not installed. "
"Please install it with: CREATE EXTENSION vector;"
) from e
vector_extension = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
bootstrap_extension(conn, vector_extension)
vector_extension = configured_vector_extension()
_bootstrap_vector_extension_for_migrations(conn, vector_extension)
# Commit any pending transaction on the advisory-lock connection
# before running migrations. Some code paths above (e.g., the
@@ -400,7 +414,7 @@ def check_migration_status(
return None, None
# Get current revision from database
engine = create_engine(to_libpq_url(database_url))
engine = create_engine(to_libpq_url(database_url), poolclass=NullPool)
with engine.connect() as connection:
context = MigrationContext.configure(connection)
current_rev = context.get_current_revision()
@@ -573,7 +587,7 @@ def ensure_embedding_dimension(
"""
schema_name = schema or "public"
engine = create_engine(to_libpq_url(database_url))
engine = create_engine(to_libpq_url(database_url), poolclass=NullPool)
with engine.connect() as conn:
# Check if memory_units table exists (proxy for schema being initialized)
table_exists = conn.execute(
@@ -596,6 +610,10 @@ def ensure_embedding_dimension(
_migrate_table_embedding_dimension(conn, schema_name, "memory_units", required_dimension, vector_ext)
_migrate_table_embedding_dimension(conn, schema_name, "mental_models", required_dimension, vector_ext)
# NOTE: invalidated_memory_units is deliberately omitted. The curation archive has no
# embedding column at all (dropped in migration d4f6a8c2e1b3) — invalidate stores no
# embedding and revert recomputes one — so there is no archive vector to re-dimension
# and a model switch can't trip a dimension mismatch there (#2209).
def ensure_vector_extension(
@@ -622,7 +640,7 @@ def ensure_vector_extension(
"""
schema_name = schema or "public"
engine = create_engine(to_libpq_url(database_url))
engine = create_engine(to_libpq_url(database_url), poolclass=NullPool)
with engine.connect() as conn:
# Detect which vector extension should be used
target_ext = _detect_vector_extension(conn, vector_extension)
@@ -674,24 +692,20 @@ def ensure_vector_extension(
if not current_index_info:
if table_name == "memory_units" and uses_per_bank_vector_indexes(target_ext):
# Check whether per-bank partial vector indexes already cover this table
# (created by the bank_utils lifecycle — no global index needed in that case)
per_bank_index_count = conn.execute(
text("""
SELECT COUNT(*)
FROM pg_indexes
WHERE schemaname = :schema
AND tablename = :table_name
AND indexname LIKE 'idx_mu_emb_%'
"""),
{"schema": schema_name, "table_name": table_name},
).scalar()
if per_bank_index_count and per_bank_index_count > 0:
logger.debug(
f"No global embedding index on {table_name}, but {per_bank_index_count} "
f"per-bank partial vector indexes exist — skipping global index creation"
)
continue
# Per-bank backends never use a GLOBAL memory_units vector index.
# Every vector search is bank + fact_type scoped and served by the
# per-(bank, fact_type) partial indexes created at bank-creation time
# (bank_utils.create_bank_vector_indexes); the planner never picks a
# global index when bank_id is in the WHERE clause, which is exactly
# why migration d5e6f7a8b9c0 drops it for these backends. So don't
# create one here either — not even on an empty schema with no per-bank
# indexes yet (those are built when the first bank is created). Verified
# via EXPLAIN: the query uses idx_mu_emb_* whether or not the global
# index exists, so creating it is dead weight.
logger.debug(
f"Per-bank vector backend ({target_ext}); skipping global {index_name} creation on {table_name}"
)
continue
logger.warning(f"No embedding index found for {table_name}, will create it if safe")
mismatched_tables.append((table_name, index_name, None, row_count))
continue
@@ -836,7 +850,7 @@ def ensure_text_search_extension(
schema_name = schema or "public"
pg_search_tokenizer = normalize_pg_search_tokenizer(pg_search_tokenizer)
engine = create_engine(to_libpq_url(database_url))
engine = create_engine(to_libpq_url(database_url), poolclass=NullPool)
with engine.connect() as conn:
# Tables with search_vector columns to check
tables_to_check = [
@@ -1129,3 +1143,129 @@ def ensure_text_search_extension(
conn.commit()
logger.info(f"Successfully migrated text search to {text_search_extension}")
def _migrate_one_schema_pg(
database_url: str,
schema: str,
*,
migration_database_url: str | None,
embedding_dimension: int | None,
vector_extension: str,
text_search_extension: str,
pg_search_tokenizer: str | None,
ensure_extensions: bool,
) -> str:
"""Run migrations + post-migration extension setup for a SINGLE PG schema.
Module-level (not a closure) so it is picklable and can run inside a
``ProcessPoolExecutor`` worker. The steps run strictly in order this is
the per-tenant sequential unit; parallelism happens only *across* schemas.
Returns the schema name on success; raises on the first failing step so the
caller can attribute the failure back to this schema.
"""
run_migrations(database_url, schema=schema, migration_database_url=migration_database_url)
if embedding_dimension is not None:
ensure_embedding_dimension(
database_url,
embedding_dimension,
schema=schema,
vector_extension=vector_extension,
)
if ensure_extensions:
ensure_vector_extension(database_url, vector_extension=vector_extension, schema=schema)
ensure_text_search_extension(
database_url,
text_search_extension=text_search_extension,
schema=schema,
pg_search_tokenizer=pg_search_tokenizer,
)
return schema
def _make_migration_executor(max_workers: int):
"""Build the executor that runs per-schema migrations in parallel.
Each schema must run in its OWN process Alembic's ``command.upgrade()``
uses non-thread-safe module globals (serialized in-process by
``_alembic_lock``), so a thread pool would not actually run two upgrades at
once. ``spawn`` gives every worker a clean interpreter on all platforms,
avoiding the fork-of-a-multithreaded-process deadlock hazard (the API server
holds threads/pools when migrations run on startup).
Factored out so tests can substitute an in-process executor.
"""
import multiprocessing
from concurrent.futures import ProcessPoolExecutor
return ProcessPoolExecutor(max_workers=max_workers, mp_context=multiprocessing.get_context("spawn"))
def run_migrations_for_schemas(
database_url: str,
schemas: list[str],
*,
concurrency: int = 1,
migration_database_url: str | None = None,
embedding_dimension: int | None = None,
vector_extension: str = "pgvector",
text_search_extension: str = "native",
pg_search_tokenizer: str | None = None,
ensure_extensions: bool = True,
) -> None:
"""Run PostgreSQL migrations for many schemas, up to ``concurrency`` at once.
Within a schema the work is always sequential (migrate embedding dim
vector ext text-search ext). Across schemas, when ``concurrency > 1`` each
schema is migrated in its OWN process: Alembic's ``command.upgrade()`` relies
on non-thread-safe module-level globals (serialized in-process by
``_alembic_lock``), so threads would gain nothing separate interpreters
each get a clean Alembic context. Per-schema advisory locks
(``_get_schema_lock_id``) keep concurrent processes from colliding on the
same schema across replicas.
``database_url`` must already be resolved (e.g. an embedded ``pg0`` instance
started in the parent) workers receive it verbatim and only connect.
Failures are collected per schema and re-raised together so one bad tenant
does not hide the status of the others.
"""
if not schemas:
return
worker_kwargs = dict(
migration_database_url=migration_database_url,
embedding_dimension=embedding_dimension,
vector_extension=vector_extension,
text_search_extension=text_search_extension,
pg_search_tokenizer=pg_search_tokenizer,
ensure_extensions=ensure_extensions,
)
effective = max(1, min(concurrency, len(schemas)))
if effective == 1:
# Inline, in-process — no subprocess overhead for the common single
# tenant / sequential case (and keeps embedded pg0 dev simple).
for schema in schemas:
_migrate_one_schema_pg(database_url, schema, **worker_kwargs)
return
logger.info("Migrating %d schema(s) with concurrency=%d", len(schemas), effective)
errors: dict[str, BaseException] = {}
with _make_migration_executor(effective) as executor:
futures = {
executor.submit(_migrate_one_schema_pg, database_url, schema, **worker_kwargs): schema for schema in schemas
}
for future in futures:
schema = futures[future]
try:
future.result()
except Exception as exc: # noqa: BLE001 — aggregate per-schema, re-raise below
errors[schema] = exc
logger.error("Migration failed for schema '%s': %s", schema, exc)
if errors:
failed = ", ".join(sorted(errors))
raise RuntimeError(
f"Database migrations failed for {len(errors)} of {len(schemas)} schema(s): {failed}"
) from next(iter(errors.values()))
+52
View File
@@ -1,6 +1,58 @@
import logging
import os
from urllib.parse import urlparse, urlunparse
def detect_container_runtime() -> str | None:
"""Detect whether the process is running inside a container.
Returns "kubernetes", "docker", or None. Used to warn operators that the
default ``socket.gethostname()`` worker id is unstable across container
recreation (the random container id changes on restart, so tasks stuck in
'processing' under the old id are never recovered).
"""
if os.getenv("KUBERNETES_SERVICE_HOST"):
return "kubernetes"
# Docker (and most OCI runtimes) create this marker file in every container.
if os.path.exists("/.dockerenv"):
return "docker"
# cgroup v1 fallback for runtimes that don't write /.dockerenv.
try:
with open("/proc/1/cgroup", encoding="utf-8") as f:
if any(token in f.read() for token in ("docker", "containerd", "kubepods")):
return "docker"
except OSError:
pass
return None
def warn_if_container_default_worker_id(worker_id: str | None) -> None:
"""Warn when worker id will fall back to an unstable container hostname."""
if worker_id:
return
runtime = detect_container_runtime()
if not runtime:
return
logging.warning(
"\n"
"============================================================\n"
" WARNING: HINDSIGHT_API_WORKER_ID is not set and Hindsight\n"
f" appears to be running inside {runtime}.\n"
"\n"
" The worker id is defaulting to the container hostname,\n"
" which CHANGES every time the container is recreated.\n"
" When that happens, tasks left in 'processing' under the\n"
" old hostname are never recovered — consolidation and other\n"
" async operations can get stuck indefinitely.\n"
"\n"
" Set HINDSIGHT_API_WORKER_ID to a STABLE value (e.g. the\n"
" compose service name or StatefulSet pod name) to avoid this.\n"
"============================================================"
)
def mask_network_location(url):
if not url:
return url
@@ -4,6 +4,7 @@ from .manager import WebhookManager
from .models import (
ConsolidationEventData,
MemoryDefenseEventData,
MemoryDefenseHit,
RetainEventData,
WebhookConfig,
WebhookEvent,
@@ -17,5 +18,6 @@ __all__ = [
"WebhookEventType",
"ConsolidationEventData",
"MemoryDefenseEventData",
"MemoryDefenseHit",
"RetainEventData",
]
@@ -70,7 +70,10 @@ class WebhookManager:
webhook_table = _fq_table("webhooks", schema)
ops_table = _fq_table("async_operations", schema)
now = datetime.now(timezone.utc)
payload_str = event.model_dump_json()
# Drop null fields so receivers don't see promised-but-unfilled keys.
# OSS leaves SIEM-enrichment fields (severity, api_key_name, etc.) None
# because it doesn't have the data; cloud populates them when it does.
payload_str = event.model_dump_json(exclude_none=True)
try:
async with self._backend.acquire() as conn:
@@ -150,7 +153,10 @@ class WebhookManager:
webhook_table = _fq_table("webhooks", schema)
ops_table = _fq_table("async_operations", schema)
now = datetime.now(timezone.utc)
payload_str = event.model_dump_json()
# Drop null fields so receivers don't see promised-but-unfilled keys.
# OSS leaves SIEM-enrichment fields (severity, api_key_name, etc.) None
# because it doesn't have the data; cloud populates them when it does.
payload_str = event.model_dump_json(exclude_none=True)
try:
rows = await self._backend.ops.get_webhooks_for_dispatch(
@@ -24,14 +24,43 @@ class RetainEventData(BaseModel):
tags: list[str] | None = None
class MemoryDefenseHit(BaseModel):
"""A single secret match inside a non-allow decision.
``preview`` is a fingerprinted, redaction-identifiable rendering of the
matched value (e.g. ``ghp_AAAA...BBBB``) so SIEM operators can correlate
against their credential inventory WITHOUT the raw secret crossing the
network. Implementations must never put the raw value here.
"""
detector: str # the inner detector that matched (e.g. "GitHub Token")
preview: str # fingerprinted value, never the raw secret
class MemoryDefenseEventData(BaseModel):
"""Payload for a memory_defense.triggered event (one item, one non-allow decision)."""
"""Payload for a memory_defense.triggered event (one item, one non-allow decision).
The four base fields (``action``/``detector``/``document_id``/``message``)
plus ``matched_types`` are populated by every implementation including OSS's
built-in regex defense. The remaining fields are optional SIEM-enrichment
surfaces that downstream extensions (e.g. hindsight-cloud) populate when
they have richer per-decision context severity classification, the API
key that submitted the retain, fingerprinted hit previews for SIEM
correlation, and pointers into the audit trail. OSS leaves them ``None``;
receivers should treat absence as "not provided" rather than "no match".
"""
action: str # "redact" or "block"
detector: str | None = None # e.g. "sensitive_data"
document_id: str | None = None
matched_types: list[str] | None = None # redaction pattern labels that fired
message: str | None = None
# --- Optional SIEM enrichment (populated by extensions, not OSS) ---
severity: str | None = None # "low" / "medium" / "high" / "critical"
api_key_name: str | None = None # human-readable name of the submitting API key
hits: list[MemoryDefenseHit] | None = None # per-match fingerprints for correlation
memory_unit_id: str | None = None # drill-down pointer (when the decision was REDACT)
receipt_uri: str | None = None # storage pointer for the audit trail entry
class WebhookEvent(BaseModel):
@@ -136,7 +136,7 @@ def main():
# Worker options
parser.add_argument(
"--worker-id",
default=config.worker_id or socket.gethostname(),
default=config.worker_id,
help="Worker identifier (default: hostname, env: HINDSIGHT_API_WORKER_ID)",
)
parser.add_argument(
@@ -178,10 +178,17 @@ def main():
# Configure logging
config.configure_logging()
from ..utils import warn_if_container_default_worker_id
warn_if_container_default_worker_id(args.worker_id)
worker_id = args.worker_id or socket.gethostname()
worker_id_source = "HINDSIGHT_API_WORKER_ID/--worker-id" if args.worker_id else "hostname (default)"
logger.info(f"Worker id: {worker_id} (source: {worker_id_source})")
# Import MemoryEngine here to avoid circular imports
from .. import MemoryEngine
print(f"Starting Hindsight Worker: {args.worker_id}")
print(f"Starting Hindsight Worker: {worker_id}")
print(f" Poll interval: {args.poll_interval}ms")
print(f" Max retries: {args.max_retries}")
print(f" Max slots: {config.worker_max_slots}")
@@ -249,7 +256,7 @@ def main():
schema = None if config.database_schema == DEFAULT_DATABASE_SCHEMA else config.database_schema
poller = WorkerPoller(
backend=memory._backend,
worker_id=args.worker_id,
worker_id=worker_id,
executor=memory.execute_task,
poll_interval_ms=args.poll_interval,
schema=schema,
@@ -20,9 +20,23 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from ..engine.schema import fq_table_explicit as fq_table
from ..metrics import get_metrics_collector
from .exceptions import DeferOperation, RetryTaskAt
from .stage import StageHolder, bind_holder
# Map DB operation_type -> metric `operation` label, collapsing the retain
# variants onto "retain" so async worker completions land on the same
# operation="retain" series the synchronous API path emits. Unknown types
# pass through unchanged.
_RETAIN_OP_TYPES = {"retain", "batch_retain", "file_convert_retain"}
def _metric_operation_label(operation_type: str | None) -> str:
if operation_type in _RETAIN_OP_TYPES:
return "retain"
return operation_type or "unknown"
if TYPE_CHECKING:
from hindsight_api.engine.db.base import DatabaseBackend, DatabaseConnection
from hindsight_api.extensions.tenant import TenantExtension
@@ -226,38 +240,23 @@ class WorkerPoller:
"""
async with self._backend.acquire() as conn:
if await self._optional_routines.is_installed(conn, "schemas_with_pending_work"):
# The routine IS the authority on where work exists: every schema
# it returns is claimable, and every schema it does NOT return is
# treated as having nothing to do this cycle. That is the entire
# point of installing it — one round-trip replaces N per-schema
# EXISTS probes. We deliberately do NOT re-verify the omitted
# schemas with a per-schema scan: that re-runs the exact queries
# the routine exists to avoid, on every idle poll, silently
# negating the optimisation.
#
# Because the result is trusted wholesale, the routine is only
# appropriate for multi-tenant deployments. A single-schema
# (default/public only) install should NOT create it and instead
# falls through to the per-schema path below — a single cheap
# EXISTS check that cannot starve. See
# ``hindsight_api.engine.db.optional_routines``.
rows = await conn.fetch("SELECT * FROM public.schemas_with_pending_work()")
routine_active = {self._normalize_poll_schema(r[0]) for r in rows}
known_schemas = set(schemas)
active = routine_active & known_schemas
unknown = routine_active - known_schemas
if unknown:
logger.warning(
"Optional PG routine public.schemas_with_pending_work() returned schema(s) "
"not present in tenant discovery: %s",
sorted(str(s) for s in unknown),
)
# The optional routine returns PostgreSQL schema names, but the poller uses
# None for the default schema. Older operator-supplied implementations also
# commonly scan tenant_% only; when the default schema is in scope but absent
# from the routine result, verify via the fully-correct per-schema fallback so
# public single-tenant deployments cannot silently starve.
should_verify_with_fallback = (None in known_schemas and None not in active) or (
bool(routine_active) and not active
)
if not should_verify_with_fallback:
return active
fallback_active = await self._scan_active_schemas_by_exists(conn, schemas)
missed = fallback_active - active
if missed:
logger.warning(
"Optional PG routine public.schemas_with_pending_work() missed claimable schema(s) %s; "
"using per-schema fallback for this poll",
sorted(str(s) for s in missed),
)
return fallback_active
return {self._normalize_poll_schema(r[0]) for r in rows}
return await self._scan_active_schemas_by_exists(conn, schemas)
@@ -716,6 +715,24 @@ class WorkerPoller:
"""
task_type = task.task_dict.get("type", "unknown")
bank_id = task.task_dict.get("bank_id", "unknown")
# Operation metric (source="worker"): record on terminal outcomes only, so
# async worker throughput and latency (retain, consolidation and the other
# worker task types) are visible in Prometheus. Prefer the DB-authoritative
# operation_type.
#
# success semantics are deliberately narrow: success=false means the task
# raised out to the poller (an unexpected error, or retry-exhausted). It does
# NOT capture deterministic failures that the executor handles itself and
# returns from normally (file_convert_retain, non-retryable errors via
# memory_engine.execute_task) — those record success=true here. Treat this as
# a completion-throughput signal, not a failure-rate one: for authoritative
# failure visibility use the hindsight_async_operations{status="failed"} gauge,
# which reads each operation's final DB status.
op_label = _metric_operation_label(task.task_dict.get("operation_type") or task_type)
op_start = time.time()
metrics = get_metrics_collector()
# None = not a terminal outcome (deferred/retried) → no metric.
terminal_success: bool | None = None
# Bind the stage holder in this task's own contextvar scope so engine
# code running under us can update it via stage.set_stage(). If holder
@@ -732,14 +749,28 @@ class WorkerPoller:
task.task_dict["_schema"] = task.schema
await self._executor(task.task_dict)
logger.debug(f"Task {task.operation_id} execution finished")
terminal_success = True
except DeferOperation as e:
# Deferral is not a terminal outcome — do not record a completion.
await self._defer_operation(task.operation_id, e.exec_date, e.reason, task.schema)
except RetryTaskAt as e:
# Retry is not a terminal outcome — do not record a completion.
await self._schedule_retry(task.operation_id, e.retry_at, str(e), task.schema)
except Exception as e:
logger.error(f"Task {task.operation_id} failed: {e}")
traceback.print_exc()
await self._mark_failed(task.operation_id, str(e), task.schema)
terminal_success = False
# Record the metric outside the executor's exception scope so a metrics
# reporting failure can never be mistaken for a task failure and flip terminal state.
if terminal_success is not None:
try:
metrics.record_operation_result(
op_label, bank_id, success=terminal_success, duration=time.time() - op_start, source="worker"
)
except Exception:
logger.warning(f"Failed to record worker operation metric for {task.operation_id}", exc_info=True)
async def recover_own_tasks(self) -> int:
"""
+5 -5
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api-slim"
version = "0.8.1"
version = "0.8.4"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -60,10 +60,10 @@ dependencies = [
"pyasn1>=0.6.3", # DoS vulnerability fix
"urllib3>=2.7.0", # Decompression-bomb safeguards bypass + sensitive header forwarding fixes
"langchain-core>=1.2.22", # Path traversal in legacy load_prompt functions fix
"langsmith>=0.6.3", # SSRF via tracing header injection fix
"langsmith>=0.8.18", # GHSA-f4xh-w4cj-qxq8: arbitrary server-side file read in TracingMiddleware fix (supersedes >=0.6.3 SSRF tracing-header-injection floor)
"protobuf>=6.33.5", # JSON recursion depth bypass fix
"pillow>=12.1.1", # Out-of-bounds write in PSD image loading fix
"cryptography>=46.0.6,<47", # Incomplete DNS name constraint enforcement fix; cap <47 — 47.0.0 SIGILLs on some ARM64 Linux VMs (Docker/Podman on Apple Silicon), pyca/cryptography#14733
"cryptography>=48.0.1", # GHSA-537c-gmf6-5ccf: bundled-OpenSSL OOB read fix needs >=48.0.1. Prior <47 cap (47.0.0 SIGILL on ARM64 Docker/Podman, pyca/cryptography#14733) lifted — 47/48/49 verified importing + RSA sign/verify cleanly on linux/arm64 (Docker on Apple Silicon) and native arm64 macOS; upstream issue closed unconfirmed.
"filelock>=3.20.1", # TOCTOU race condition fix
"authlib>=1.6.9", # Account takeover/JWS header injection vulnerability fix
"pyjwt>=2.12.0", # Accepts unknown crit header extensions fix
@@ -74,6 +74,7 @@ dependencies = [
"pygments>=2.20.0", # ReDoS via inefficient GUID regex fix
"claude-agent-sdk>=0.2.82",
"boto3>=1.42.74",
"croniter>=2.0.0", # Cron parsing for scheduled mental model refresh
]
[project.optional-dependencies]
@@ -200,12 +201,11 @@ select = [
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"B021", # flake8-bugbear: f-string used as docstring (leaves __doc__ None)
]
ignore = [
"E501", # line too long (handled by formatter)
"E402", # module import not at top of file
"F401", # unused import (too noisy during development)
"F841", # unused variable (too noisy during development)
"F811", # redefined while unused
"F821", # undefined name (forward references in type hints)
]
+70 -23
View File
@@ -11,11 +11,71 @@ import pytest
import pytest_asyncio
from dotenv import load_dotenv
# Force torch to initialize exactly once, in the main thread, at conftest import
# time — before any fixture spins up an event loop or sentence-transformers'
# thread pools. torch's C-level `_add_docstr(_has_torch_function, ...)` in
# torch/overrides.py is not re-entrancy-safe: when the first `import torch`
# happens lazily from inside concurrent/async code (e.g.
# embeddings.initialize() -> sentence_transformers -> transformers -> torch, or
# cross_encoder's ThreadPoolExecutor), torch/overrides.py can execute twice and
# raise "RuntimeError: function '_has_torch_function' already has a docstring",
# failing collection of every test on the pytest-xdist shard. Importing it here
# (single-threaded, before any concurrency) makes that registration happen once
# per worker process. Guarded so slim/no-torch environments still collect.
try:
import torch # noqa: F401 # eager one-time init; see comment above
except ImportError:
pass
from hindsight_api import LLMConfig, LocalSTEmbeddings, MemoryEngine, RequestContext
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer
from hindsight_api.engine.task_backend import SyncTaskBackend
from hindsight_api.pg0 import EmbeddedPostgres
from hindsight_api.tracing import unregister_span_recorder
async def _teardown_memory_engine(mem: MemoryEngine) -> None:
"""Tear down a test MemoryEngine, guaranteeing its span recorder is unregistered.
LLM-trace recorders live in a process-global registry; ``MemoryEngine.close()`` is
the only thing that removes the engine's recorder from it. If close() is skipped
(pool already closing) or raises before that step, the recorder leaks and a later
test's LLM calls get recorded into the shared DB — the flaky
test_llm_trace::test_disabled_writes_no_rows (#2229). Unregister unconditionally;
it's a no-op when close() already did it.
"""
try:
if mem._pool and not mem._pool._closing:
await mem.close()
except Exception:
pass
finally:
unregister_span_recorder(mem._llm_recorder)
@pytest.fixture(autouse=True)
def _cleanup_leaked_span_recorders():
"""Fail-safe for the process-global LLM-trace recorder registry (#2229).
``MemoryEngine.__init__`` registers its recorder in the shared registry, and
only ``close()`` removes it. Tests that construct an engine directly (without
``_teardown_memory_engine``/``close()``) leak an *enabled* recorder; a later
test's LLM calls then get recorded into the shared DB, flaking
``test_llm_trace::test_disabled_writes_no_rows`` (it observes rows for its
bank even though its own recorder is disabled). ``_teardown_memory_engine``
guards the fixtures; this guards everything else by dropping any recorder a
test added to the registry.
"""
from hindsight_api.tracing import get_span_recorder
recorders = get_span_recorder()._recorders
before = {id(r) for r in recorders}
yield
for recorder in list(recorders):
if id(recorder) not in before:
recorders.remove(recorder)
# Default pg0 instance configuration for tests
DEFAULT_PG0_INSTANCE_NAME = "hindsight-test"
@@ -24,11 +84,13 @@ DEFAULT_PG0_PORT = int(os.environ.get("HINDSIGHT_TEST_PG_PORT", "5556"))
# Keep the background MaintenanceLoop from auto-starting during tests. In
# production it sweeps retention and re-schedules consolidation, but its timers
# would race shared-pg0 test data (e.g. delete llm_requests/audit_log rows a test
# just inserted). Disabling the reconcile interval and llm-trace retention — with
# audit retention already off by default — leaves no job enabled, so the loop
# never starts. Tests that exercise it call MaintenanceLoop methods
# (_run_reconcile / _purge_expired) directly.
# just inserted). Disabling the reconcile interval, the mental-model refresh tick
# and llm-trace retention — with audit retention already off by default — leaves
# no job enabled, so the loop never starts. Tests that exercise it call
# MaintenanceLoop methods (_run_reconcile / _run_scheduled_mm_refresh /
# _purge_expired) directly.
os.environ.setdefault("HINDSIGHT_API_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS", "0")
os.environ.setdefault("HINDSIGHT_API_MENTAL_MODEL_REFRESH_TICK_SECONDS", "0")
os.environ.setdefault("HINDSIGHT_API_LLM_TRACE_RETENTION_DAYS", "-1")
@@ -342,10 +404,7 @@ async def oracle_memory(oracle_db_url, embeddings, cross_encoder, query_analyzer
)
await mem.initialize()
yield mem
try:
await mem.close()
except Exception:
pass
await _teardown_memory_engine(mem)
finally:
# Restore original env var and clear config cache
if old_backend is None:
@@ -463,11 +522,7 @@ async def memory(pg0_db_url, embeddings, cross_encoder, query_analyzer):
)
await mem.initialize()
yield mem
try:
if mem._pool and not mem._pool._closing:
await mem.close()
except Exception:
pass
await _teardown_memory_engine(mem)
@pytest_asyncio.fixture(scope="function")
@@ -496,11 +551,7 @@ async def memory_real_llm(pg0_db_url, embeddings, cross_encoder, query_analyzer)
)
await mem.initialize()
yield mem
try:
if mem._pool and not mem._pool._closing:
await mem.close()
except Exception:
pass
await _teardown_memory_engine(mem)
@pytest_asyncio.fixture(scope="function")
@@ -527,11 +578,7 @@ async def memory_no_llm_verify(pg0_db_url, embeddings, cross_encoder, query_anal
)
await mem.initialize()
yield mem
try:
if mem._pool and not mem._pool._closing:
await mem.close()
except Exception:
pass
await _teardown_memory_engine(mem)
@pytest_asyncio.fixture
@@ -88,7 +88,10 @@ async def test_backup_tables_covers_entire_schema(backup_test_schema):
await conn.close()
# alembic_version is migration bookkeeping, not data — never backed up.
schema_tables = {r["table_name"] for r in rows} - {"alembic_version"}
# bank_stats_cache is a derived TTL cache of get_bank_stats results: it has no
# FK to banks (so the restore cascade never touches it) and repopulates itself
# on demand, so it is deliberately not backed up — a restore starts it cold.
schema_tables = {r["table_name"] for r in rows} - {"alembic_version", "bank_stats_cache"}
backup_tables = set(BACKUP_TABLES)
missing = schema_tables - backup_tables
@@ -547,3 +550,36 @@ async def test_run_migration_with_schema_only_runs_requested_schema(monkeypatch)
assert calls["run_migrations"] == [("resolved::postgresql://test", "tenant_demo")]
assert calls["ensure_vector_extension"] == [("resolved::postgresql://test", "pgvector", "tenant_demo")]
assert calls["ensure_text_search_extension"] == [("resolved::postgresql://test", "native", "", "tenant_demo")]
@pytest.mark.parametrize(
("ensure_extensions", "expected"),
[(True, True), (False, False)],
)
@pytest.mark.asyncio
async def test_run_migration_threads_ensure_extensions_flag(monkeypatch, ensure_extensions, expected):
"""The --skip-extension-reconcile flag (ensure_extensions=False) must reach run_migrations_for_schemas.
The post-migration vector/text-search reconcile only does work on a backend change, so operators
can skip it on a no-change re-migration over many tenant schemas. Verify the flag is threaded through
rather than silently dropped.
"""
monkeypatch.setenv("HINDSIGHT_API_DATABASE_URL", "postgresql://test")
captured: dict = {}
async def fake_resolve_database_url(db_url: str) -> str:
return f"resolved::{db_url}"
def fake_run_migrations_for_schemas(database_url, schemas, **kwargs):
captured["ensure_extensions"] = kwargs.get("ensure_extensions")
monkeypatch.setattr(admin_cli, "load_extension", lambda *args, **kwargs: None)
monkeypatch.setattr(admin_cli, "resolve_database_url", fake_resolve_database_url)
from hindsight_api import migrations as migrations_module
monkeypatch.setattr(migrations_module, "run_migrations_for_schemas", fake_run_migrations_for_schemas)
await admin_cli._run_migration("postgresql://test", schema="tenant_demo", ensure_extensions=ensure_extensions)
assert captured["ensure_extensions"] is expected
@@ -0,0 +1,111 @@
"""Regression tests for issue #1002 — Anthropic structured output via forced tool_use.
When strict_schema=True, AnthropicLLM.call() must request the schema through a single
forced tool_use tool (tool_choice={"type":"tool",...}) and read the validated args from
the tool_use block, NOT inject the schema as text and json.loads() the reply (which caused
a ~1:1 invalid-JSON retry storm / OOM in production).
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import BaseModel
class _Decision(BaseModel):
action: str
reason: str
def _make_anthropic_provider():
with patch("anthropic.AsyncAnthropic") as mock_client_cls:
mock_client_cls.return_value = MagicMock()
from hindsight_api.engine.providers.anthropic_llm import AnthropicLLM
provider = AnthropicLLM(
provider="anthropic",
api_key="fake-key",
base_url="",
model="claude-sonnet-4-20250514",
)
provider._client = MagicMock()
return provider
def _tool_use_response(args: dict):
block = MagicMock()
block.type = "tool_use"
block.name = "structured_response"
block.input = args
resp = MagicMock()
resp.content = [block]
resp.usage = MagicMock(input_tokens=5, output_tokens=2, cache_read_input_tokens=0)
resp.stop_reason = "tool_use"
return resp
@pytest.mark.asyncio
async def test_strict_schema_uses_forced_tool_choice():
"""strict_schema=True ⇒ a single tool is defined and tool_choice forces it (no schema text-injection)."""
provider = _make_anthropic_provider()
provider._client.messages.create = AsyncMock(return_value=_tool_use_response({"action": "skip", "reason": "dup"}))
with patch("hindsight_api.engine.providers.anthropic_llm.get_metrics_collector"):
result = await provider.call(
messages=[{"role": "user", "content": "decide"}],
response_format=_Decision,
strict_schema=True,
scope="test",
max_retries=0,
)
kwargs = provider._client.messages.create.call_args.kwargs
# forced tool_use requested
assert "tools" in kwargs and len(kwargs["tools"]) == 1
assert kwargs["tool_choice"] == {"type": "tool", "name": "structured_response"}
# schema NOT injected as text into the system prompt
assert "valid JSON matching this schema" not in (kwargs.get("system") or "")
# validated model returned straight from tool_use.input
assert isinstance(result, _Decision)
assert result.action == "skip"
@pytest.mark.asyncio
async def test_strict_schema_tool_use_never_hits_json_retry_loop():
"""A tool_use response is structurally valid → no second messages.create call (no retry storm)."""
provider = _make_anthropic_provider()
create = AsyncMock(return_value=_tool_use_response({"action": "keep", "reason": "novel"}))
provider._client.messages.create = create
with patch("hindsight_api.engine.providers.anthropic_llm.get_metrics_collector"):
await provider.call(
messages=[{"role": "user", "content": "x"}],
response_format=_Decision,
strict_schema=True,
scope="test",
max_retries=10, # would allow 11 attempts on the old text-parse path
)
assert create.await_count == 1 # exactly one call — the bug was N retries on malformed text
@pytest.mark.asyncio
async def test_non_strict_keeps_text_injection_fallback():
"""strict_schema=False (default) preserves the legacy schema-in-prompt behavior."""
provider = _make_anthropic_provider()
block = MagicMock()
block.type = "text"
block.text = '{"action":"skip","reason":"d"}'
resp = MagicMock()
resp.content = [block]
resp.usage = MagicMock(input_tokens=5, output_tokens=2, cache_read_input_tokens=0)
resp.stop_reason = "end_turn"
provider._client.messages.create = AsyncMock(return_value=resp)
with patch("hindsight_api.engine.providers.anthropic_llm.get_metrics_collector"):
result = await provider.call(
messages=[{"role": "user", "content": "decide"}],
response_format=_Decision,
strict_schema=False,
scope="test",
max_retries=0,
)
kwargs = provider._client.messages.create.call_args.kwargs
assert "tools" not in kwargs # no forced tool when not strict
assert "valid JSON matching this schema" in (kwargs.get("system") or "")
assert isinstance(result, _Decision)
@@ -0,0 +1,90 @@
"""Regression test: submitting an async op for a bank that doesn't exist must
raise a clean validation error, not a raw asyncpg `ForeignKeyViolationError`.
`_submit_async_operation` inserts into `async_operations`, which has an FK to
`banks.bank_id`. If a caller submits for a missing bank (typo, race against a
deletion, integration that derives bank IDs before the bank is created), the
INSERT raises `asyncpg.exceptions.ForeignKeyViolationError`. The FastAPI
endpoint's broad `except Exception` then surfaces it as a 500 — but this is
a client error, not a server error, and should be a 404.
This test exercises the call directly via `MemoryEngine.submit_async_*` so
the failure mode is observable without spinning up the HTTP layer.
"""
import uuid
import pytest
from hindsight_api.extensions.operation_validator import OperationValidationError
pytestmark = pytest.mark.xdist_group("async_submit_bank_not_found_tests")
@pytest.fixture
def no_inline_execution(memory):
"""Prevent SyncTaskBackend from running the submitted op inline so we
only test the submit-path failure, not downstream execution."""
async def _noop(_payload):
return None
original = memory._task_backend.submit_task
memory._task_backend.submit_task = _noop
yield
memory._task_backend.submit_task = original
@pytest.mark.asyncio
async def test_consolidation_submit_on_missing_bank_raises_validation_error(
memory, request_context, no_inline_execution
):
"""A `/consolidate` submit against a bank that doesn't exist must raise
OperationValidationError(404), not a raw asyncpg FK violation that bubbles
out as a 500 from the API."""
missing_bank = f"does-not-exist-{uuid.uuid4().hex[:8]}"
with pytest.raises(OperationValidationError) as exc_info:
await memory.submit_async_consolidation(
bank_id=missing_bank,
request_context=request_context,
)
assert exc_info.value.status_code == 404
assert missing_bank in exc_info.value.reason
@pytest.mark.asyncio
async def test_scoped_consolidation_submit_on_missing_bank_raises_validation_error(
memory, request_context, no_inline_execution
):
"""Scoped consolidates (with `observation_scopes`) take the
`dedupe_by_bank=False` branch, which historically skipped the bank lock
entirely and went straight to the FK-violating INSERT. Same 404 contract."""
missing_bank = f"does-not-exist-{uuid.uuid4().hex[:8]}"
with pytest.raises(OperationValidationError) as exc_info:
await memory.submit_async_consolidation(
bank_id=missing_bank,
request_context=request_context,
observation_scopes=[{"tag": "anything"}],
)
assert exc_info.value.status_code == 404
assert missing_bank in exc_info.value.reason
@pytest.mark.asyncio
async def test_graph_maintenance_on_missing_bank_short_circuits(memory, request_context, no_inline_execution):
"""`submit_async_graph_maintenance` has its own short-circuit that checks
the per-bank queue before calling `_submit_async_operation`. A missing
bank means an empty queue, so it returns `no_work=True` without reaching
the FK-violating INSERT. This test pins that behaviour."""
missing_bank = f"does-not-exist-{uuid.uuid4().hex[:8]}"
result = await memory.submit_async_graph_maintenance(
bank_id=missing_bank,
request_context=request_context,
)
assert result == {"operation_id": None, "no_work": True}
@@ -0,0 +1,244 @@
"""
Tests for the async-operation queue and consolidation backlog gauges
(``_setup_backlog_metrics`` / ``_refresh_backlog`` in metrics.py).
These gauges expose, as scrapable time-series, the same counts the bank-stats
endpoint already returns per bank (``operations_by_status``,
``pending_consolidation``, ``failed_consolidation``):
- ``hindsight_async_operations{operation_type,status}`` worker queue depth
(pending=backlog, processing=in-flight, failed=stranded)
- ``hindsight_consolidation_backlog`` source memories not yet consolidated
- ``hindsight_consolidation_failed`` source memories permanently failed
"""
from unittest.mock import MagicMock, patch
import pytest
from hindsight_api.metrics import MetricsCollector, _AsyncOpKey, _BacklogKey
class _FakeTxn:
async def __aenter__(self):
return None
async def __aexit__(self, *exc):
return False
class _FakeConn:
"""asyncpg-like connection whose fetch() is dispatched by SQL substring."""
def __init__(self, fetch_fn):
self._fetch_fn = fetch_fn
self.executed = []
async def fetch(self, sql, *args):
return self._fetch_fn(sql, *args)
async def execute(self, sql, *args):
self.executed.append(sql)
def transaction(self):
return _FakeTxn()
class _FakeAcquire:
def __init__(self, conn):
self._conn = conn
async def __aenter__(self):
return self._conn
async def __aexit__(self, *exc):
return False
class _FakePool:
def __init__(self, fetch_fn):
self._conn = _FakeConn(fetch_fn)
def acquire(self):
return _FakeAcquire(self._conn)
def _collector(include_bank_id=False):
mock_config = MagicMock()
mock_config.metrics_include_bank_id = include_bank_id
with (
patch("hindsight_api.metrics.get_meter", return_value=MagicMock()),
patch("hindsight_api.config.get_config", return_value=mock_config),
):
return MetricsCollector()
def _set_db_pool_with_backlog_enabled(collector, pool):
"""Call set_db_pool with the backlog flag forced on (it's off by default)."""
mock_config = MagicMock()
mock_config.metrics_backlog_enabled = True
with patch("hindsight_api.config.get_config", return_value=mock_config):
collector.set_db_pool(pool)
def _rows_for(sql):
"""Canned results, keyed off distinctive substrings of each query."""
if "information_schema.tables" in sql:
return [{"table_schema": "public"}]
if "async_operations" in sql:
return [
{"operation_type": "retain", "status": "pending", "count": 5},
{"operation_type": "consolidation", "status": "pending", "count": 12},
{"operation_type": "consolidation", "status": "processing", "count": 1},
{"operation_type": "consolidation", "status": "failed", "count": 2},
]
if "memory_units" in sql and "consolidated_at IS NULL" in sql:
return [{"count": 42}]
if "memory_units" in sql and "consolidation_failed_at IS NOT NULL" in sql:
return [{"count": 3}]
return []
@pytest.mark.asyncio
async def test_refresh_backlog_aggregates_queue_and_consolidation():
collector = _collector(include_bank_id=False)
collector._db_pool = _FakePool(lambda sql, *a: _rows_for(sql))
await collector._refresh_backlog()
# Worker queue depth keyed by (schema, operation_type, status, bank=None)
assert collector._async_ops_counts[("public", "retain", "pending", None)] == 5
assert collector._async_ops_counts[("public", "consolidation", "pending", None)] == 12
assert collector._async_ops_counts[("public", "consolidation", "processing", None)] == 1
assert collector._async_ops_counts[("public", "consolidation", "failed", None)] == 2
# Consolidation backlog (source memories), keyed by (schema, bank=None)
assert collector._consolidation_backlog[("public", None)] == 42
assert collector._consolidation_failed[("public", None)] == 3
@pytest.mark.asyncio
async def test_refresh_backlog_uses_index_matched_predicates_not_filter_scan():
"""Backlog/failed must be two separate COUNT(*) queries whose WHERE matches
a partial-index predicate exactly (no FILTER over a full-table scan), and
the queue query must exclude terminal statuses."""
captured = []
collector = _collector()
collector._db_pool = _FakePool(lambda sql, *a: (captured.append(sql), _rows_for(sql))[1])
await collector._refresh_backlog()
mem_queries = [s for s in captured if "memory_units" in s and "COUNT(*)" in s]
assert len(mem_queries) == 2 # split, not a single two-FILTER aggregate
assert all("FILTER" not in s for s in mem_queries)
assert any("consolidated_at IS NULL AND fact_type IN ('experience', 'world')" in s for s in mem_queries)
assert any("consolidation_failed_at IS NOT NULL AND fact_type IN ('experience', 'world')" in s for s in mem_queries)
ops_sql = next(s for s in captured if "async_operations" in s and "GROUP BY" in s)
assert "status IN ('pending', 'processing', 'failed')" in ops_sql
assert "completed" not in ops_sql and "cancelled" not in ops_sql
@pytest.mark.asyncio
async def test_backlog_count_runs_with_seqscan_disabled():
"""`consolidated_at IS NULL` is true for a large fraction of the table, so
the planner misjudges selectivity and won't use the partial index without a
nudge the backlog count must issue SET LOCAL enable_seqscan=off."""
collector = _collector()
pool = _FakePool(lambda sql, *a: _rows_for(sql))
collector._db_pool = pool
await collector._refresh_backlog()
assert any("enable_seqscan" in s.lower() and "off" in s.lower() for s in pool._conn.executed)
# the result is still correct under the nudge
assert collector._consolidation_backlog[("public", None)] == 42
@pytest.mark.asyncio
async def test_refresh_backlog_per_bank_labels_and_group_by_when_enabled():
"""With metrics_include_bank_id on, bank_id enters the cache key and the
SQL switches to GROUP BY bank_id."""
captured = []
def fetch(sql, *a):
captured.append(sql)
if "information_schema.tables" in sql:
return [{"table_schema": "public"}]
if "async_operations" in sql:
return [{"operation_type": "retain", "status": "pending", "bank_id": "bankA", "count": 4}]
if "memory_units" in sql and "consolidated_at IS NULL" in sql:
return [{"bank_id": "bankA", "count": 11}]
if "memory_units" in sql and "consolidation_failed_at IS NOT NULL" in sql:
return [{"bank_id": "bankA", "count": 2}]
return []
collector = _collector(include_bank_id=True)
collector._db_pool = _FakePool(fetch)
await collector._refresh_backlog()
assert collector._async_ops_counts[("public", "retain", "pending", "bankA")] == 4
assert collector._consolidation_backlog[("public", "bankA")] == 11
assert collector._consolidation_failed[("public", "bankA")] == 2
# bank_id must be grouped in every per-bank count query
assert all("GROUP BY bank_id" in s for s in captured if "memory_units" in s and "COUNT(*)" in s)
def test_gauges_register_and_emit_cached_values_without_bank_id():
collector = _collector(include_bank_id=False)
# Sync call: no running loop, so gauges register but no background task spawns.
_set_db_pool_with_backlog_enabled(collector, MagicMock())
gauges = {
c.kwargs["name"]: c.kwargs["callbacks"][0]
for c in collector.meter.create_observable_gauge.call_args_list
if "callbacks" in c.kwargs
}
assert "hindsight.async_operations" in gauges
assert "hindsight.consolidation.backlog" in gauges
assert "hindsight.consolidation.failed" in gauges
collector._async_ops_counts = {
_AsyncOpKey("public", "retain", "pending", None): 7,
_AsyncOpKey("public", "consolidation", "processing", None): 1,
}
collector._consolidation_backlog = {_BacklogKey("public", None): 9}
obs = list(gauges["hindsight.async_operations"](None))
by_label = {(o.attributes["operation_type"], o.attributes["status"]): o.value for o in obs}
assert by_label[("retain", "pending")] == 7
assert by_label[("consolidation", "processing")] == 1
assert all("bank_id" not in o.attributes for o in obs) # cardinality guard
backlog_obs = list(gauges["hindsight.consolidation.backlog"](None))
assert backlog_obs[0].value == 9
assert backlog_obs[0].attributes["tenant"] == "public"
def test_gauge_emits_bank_id_attribute_when_present():
collector = _collector(include_bank_id=True)
_set_db_pool_with_backlog_enabled(collector, MagicMock())
gauges = {
c.kwargs["name"]: c.kwargs["callbacks"][0]
for c in collector.meter.create_observable_gauge.call_args_list
if "callbacks" in c.kwargs
}
collector._consolidation_backlog = {_BacklogKey("public", "bankA"): 4}
obs = list(gauges["hindsight.consolidation.backlog"](None))
assert obs[0].value == 4
assert obs[0].attributes["bank_id"] == "bankA"
def test_backlog_gauges_not_registered_when_flag_disabled():
"""Backlog metrics are off by default: set_db_pool must not register the
gauges unless metrics_backlog_enabled is set."""
collector = _collector()
mock_config = MagicMock()
mock_config.metrics_backlog_enabled = False
with patch("hindsight_api.config.get_config", return_value=mock_config):
collector.set_db_pool(MagicMock())
names = [
c.kwargs.get("name") for c in collector.meter.create_observable_gauge.call_args_list if "callbacks" in c.kwargs
]
assert "hindsight.async_operations" not in names
assert "hindsight.consolidation.backlog" not in names
assert "hindsight.consolidation.failed" not in names
assert collector._backlog_task is None
+2 -1
View File
@@ -82,7 +82,8 @@ async def test_bank_llm_not_configured(api_client, memory, monkeypatch):
monkeypatch.setattr(cfg, "provider", "none")
body = (await api_client.post("/v1/default/banks/llm-none/health/llm")).json()
assert all(op["status"] == "not_configured" and op["ok"] is False for op in body["operations"])
assert all(op["latency_ms"] is None for op in body["operations"])
# latency_ms is null when not configured; responses omit null fields, so use .get().
assert all(op.get("latency_ms") is None for op in body["operations"])
@pytest.mark.asyncio
@@ -186,6 +186,43 @@ async def test_invalidate_drops_entry() -> None:
assert calls[0] == 2
@pytest.mark.asyncio
async def test_invalidate_detaches_in_flight_loader() -> None:
cache = BankStatsCache(ttl_seconds=60, max_entries=100)
stale_started = asyncio.Event()
release_stale = asyncio.Event()
fresh_started = asyncio.Event()
async def stale_loader() -> dict[str, Any]:
stale_started.set()
await release_stale.wait()
return {"v": "stale"}
async def fresh_loader() -> dict[str, Any]:
fresh_started.set()
return {"v": "fresh"}
stale_task = asyncio.create_task(cache.get_or_load("schema", "bank", stale_loader))
await stale_started.wait()
await cache.invalidate("schema", "bank")
# A request after invalidation must start a new load instead of joining the
# pre-invalidation query, which may contain data from before a bank write.
fresh_result = await asyncio.wait_for(cache.get_or_load("schema", "bank", fresh_loader), timeout=1)
assert fresh_started.is_set()
assert fresh_result == {"v": "fresh"}
release_stale.set()
assert await stale_task == {"v": "stale"}
# The stale loader completed last, but must not overwrite the fresh value.
async def should_not_run() -> dict[str, Any]:
raise AssertionError("fresh value was not cached")
cached = await cache.get_or_load("schema", "bank", should_not_run)
assert cached == {"v": "fresh"}
@pytest.mark.asyncio
async def test_clear_drops_all_entries() -> None:
cache = BankStatsCache(ttl_seconds=60, max_entries=100)
@@ -199,3 +236,27 @@ async def test_clear_drops_all_entries() -> None:
await cache.get_or_load("s", "a", loader)
await cache.get_or_load("s", "b", loader)
assert calls[0] == 4
@pytest.mark.asyncio
async def test_clear_detaches_in_flight_loaders() -> None:
cache = BankStatsCache(ttl_seconds=60, max_entries=100)
stale_started = asyncio.Event()
release_stale = asyncio.Event()
async def stale_loader() -> dict[str, Any]:
stale_started.set()
await release_stale.wait()
return {"v": "stale"}
async def fresh_loader() -> dict[str, Any]:
return {"v": "fresh"}
stale_task = asyncio.create_task(cache.get_or_load("schema", "bank", stale_loader))
await stale_started.wait()
await cache.clear()
assert await cache.get_or_load("schema", "bank", fresh_loader) == {"v": "fresh"}
release_stale.set()
assert await stale_task == {"v": "stale"}
assert await cache.get_or_load("schema", "bank", fresh_loader) == {"v": "fresh"}

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