Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 5bca83432b fix(coding-agents): consolidate one set of observations per bank
Every document this integration writes carries provenance tags (`source:chat`,
`harness:<id>`, `knowledge:<kind>`, anything from `retainTags`). Consolidation's
default `combined` scoping groups observations by a memory's WHOLE tag set, so
those tags become a consolidation boundary: work one repo with two agents and
the `harness:<id>` tag alone yields two parallel sets of beliefs that never
merge, each blind to the other, at double the consolidation cost (#3564).

Retain with `observation_scopes: "shared"` instead — one global scope per bank,
which is what a bank already is: one project's memory. The tags stay on the
facts, so recall filtering, the documents-list filter and each document's agent
logo are unaffected.

New `observationScopes` config field (default `"shared"`) sets it, per bank via
`banks.<id>` like any behavioral field, or `HINDSIGHT_OBSERVATION_SCOPES` for
the scalar modes. `"combined"` restores the previous behaviour.
2026-08-18 10:01:30 +00:00
Nicolò Boschi f8b3988cf8 feat(coding-agents): make the knowledge-page refresh trigger configurable (#3545)
* feat(coding-agents): make the knowledge-page refresh trigger configurable

Every page this plugin creates was stamped with one hardcoded trigger:
refresh after every consolidation, full rebuild from scratch. That is the
most current setting and the most expensive one -- an LLM synthesis per
page per consolidation -- and on a few heavy auto-surveyed repos it adds
up to real, unexpected spend with no way to opt out short of patching
dist/ or fixing pages up after the fact.

Three config knobs, defaulting to exactly today's behaviour:
  pageTriggerType  reactive (default) | cron | manual
  pageTriggerCron  the schedule, when type is cron
  pageTriggerMode  full (server default) | delta -- edit instead of rebuild

`buildPageTrigger(cfg)` replaces the PAGE_TRIGGER constant and also
replaces the second, hand-copied instance of the same literal in
`captureInitiative`, so seeded pages and captured initiatives can no
longer drift apart.

A `cron` type with no expression is a broken config, not a request to stop
refreshing -- the API rejects it and page creation would fail -- so it
warns and falls back to the default. `manual` is how you ask for no
automatic refreshes.

Closes #3506.

* fix(coding-agents): stop clobbering the API's knowledge-page refresh contract

`create_knowledge_page` applies KNOWLEDGE_PAGE_DEFAULT_TRIGGER only when
the client sends NO trigger: a trigger REPLACES that default rather than
merging into it. Every page this plugin created therefore lost the two
settings that make a knowledge page a knowledge page --

  mode: "delta"              -> refreshes rebuilt the page from scratch
  exclude_mental_models: true -> refreshes reflected over sibling pages

-- which is a large part of the cost #3506 is about. Both are now sent
explicitly on every page, under every trigger type, and neither is
configurable: they are the API's contract for a page, not a preference.

Drops the pageTriggerMode knob accordingly. What remains configurable is
WHEN a page refreshes (pageTriggerType/pageTriggerCron), and the README
now names the API field each flag maps to.

Also reformats the README's config table under the repo's pinned prettier
(the new rows changed its column widths -- CI's verify-generated-files
caught it).

* fix(coding-agents): send only what the plugin decides in a page trigger

Follow-up to the API fix that makes a page trigger MERGE over
KNOWLEDGE_PAGE_DEFAULT_TRIGGER instead of replacing it. With that in
place the plugin no longer has to restate the server's own page defaults
(mode: delta, exclude_mental_models) to avoid losing them -- doing so
would just freeze a copy that drifts the next time they change.

What stays is what this plugin actually decides: fact_types (its pages
are tag-scoped syntheses over knowledge:<tier> labels on world and
experience facts, not the observation-only page default) and the refresh
policy the new config knobs select.

Against a server without the merge fix, pages keep the behaviour they
have shipped with all along -- no regression, no improvement until the
server is new enough.

* refactor(coding-agents): name the trigger types after the product's own terms

`pageTriggerType: "reactive"` invented a word for something the docs, the
API and the control plane already call auto-refresh. The three values are
now auto-refresh | cron | manual.

* docs(coding-agents): say plainly that the trigger applies to new pages only

Changing pageTriggerType does not migrate a repo's existing pages -- a page
keeps the trigger it was created with. Worth stating outright, since the
repos that most want "manual" are exactly the ones already seeded.

* docs(coding-agents): list the page-trigger knobs in the companion skill

The skill enumerates the behavioral config fields for the agent to answer
from; a knob missing there is invisible to every user who asks the agent
how to configure memory.
2026-08-18 10:55:18 +02:00
Nicolò Boschi d69c53739c fix(mental-models): keep at most one queued refresh per model (#3487) (#3550)
* fix(mental-models): keep at most one queued refresh per model (#3487)

A bank whose refresh queue drains slower than it fills accumulated one
refresh_mental_model operation per model per consolidation round — 12k
pending operations covering 259 models, ~45 identical copies each, every
copy a full recall + LLM refresh when it eventually ran.

The in-flight guard existed but was opt-in per call site, so any enqueue
path that did not ask for it (and every path before #3411) piled up
copies. Make the floor structural instead: a submit for a model that
already has a *queued* refresh always folds into it and returns that
operation's id. Nothing is lost — a refresh carries no per-request
options and the queued one has not started, so it still reads whatever
the caller just changed. skip_if_in_flight now only widens the guard to
an already-*running* refresh, which an explicit refresh must not fold
into: it may have read the model before the caller's edit.

The check moves out of the INSERT and back in front of it, where the
bank-row FOR NO KEY UPDATE lock (held for the rest of the transaction)
already serialises submits for the bank and makes check-and-insert
atomic. That also makes it work on Oracle: the previous
INSERT ... SELECT ... WHERE NOT EXISTS is a FROM-less SELECT there, and
its bind-parameter JSON key was never rewritten to JSON_VALUE, so since
#3411 every after-consolidation refresh submit raised on Oracle and was
swallowed as a warning.

* test(mental-models): force the submit race in the dedupe concurrency test (#3487)

The eight-way concurrent submit test passed with the bank-row lock removed —
asyncio happened to run each short transaction to completion before the next,
so it never actually raced. Stall every in-flight lookup before it returns, so
all eight submits would sit between their lookup and their INSERT at once. With
the lock it still queues one operation; without it the same test inserts eight.
2026-08-18 10:46:54 +02:00
github-actions[bot] 997f27f1bf chore: update star history 2026-08-18 03:33:23 +00:00
Nicolò Boschi b64943d195 fix(knowledge-base): patch a page's trigger instead of replacing it, on create and update (#3549)
* fix(knowledge-base): merge a client's page trigger over the page defaults

Creating a knowledge page with ANY trigger silently discarded every
knowledge-page default. Two things combined to do it: the endpoint dumped
the whole request model (`model_dump()` fills every unset field with
MentalModelTrigger's own defaults -- mode="full",
exclude_mental_models=False), and the engine then replaced
KNOWLEDGE_PAGE_DEFAULT_TRIGGER with that dict wholesale.

So a client that wanted one setting -- different fact types, a cron
schedule -- also gave up `mode: "delta"` and `exclude_mental_models`, and
its page became a from-scratch rebuild that reflected over its sibling
pages on every refresh. That is what the coding-agents plugin had been
doing to every page it created (#3506), and there was no way for it not
to: the API offered no partial override.

The endpoint now forwards only the fields the client actually set
(`exclude_unset=True`) and `_merge_page_trigger` layers them over the
defaults -- which is what the engine's docstring already promised.

The two refresh triggers stay mutually exclusive: a client asking for a
cron schedule drops the default's `refresh_after_consolidation` rather
than inheriting a pair `MentalModelTrigger` would have rejected outright.

* feat(knowledge-base): let a page's refresh trigger be updated, as a patch

The trigger was write-once through the knowledge-base API: `UpdateNodeRequest`
carried no `trigger` field and the handler filtered to
`{source_query, tags, max_tokens}`, so a page created with one policy was
stuck with it -- including every page created before the create-path fix
above, which is still doing full from-scratch rebuilds. The engine already
supported it end to end; only the HTTP surface didn't expose it.

Both endpoints now behave the same way: send the fields you want changed,
keep the rest. Create patches over KNOWLEDGE_PAGE_DEFAULT_TRIGGER, update
patches over the page's CURRENT trigger -- which matters, because
`update_mental_model` overwrites the whole trigger column, so forwarding a
partial one straight through would have reintroduced the create-path defect
one endpoint over.

Exclusivity holds in both directions on update: moving a page onto a cron
schedule clears the auto-refresh it was created with, and moving it back
clears the cron. Neither pair is expressible in a request, so neither
should be reachable by merging.

The hand-written TS and Python wrappers both take the new parameter (they
are the surface most consumers actually call), each with a mapping test.
OpenAPI spec, generated clients and the docs skill regenerated.

* fix(cli): carry the new page trigger field through the Rust CLI

`types::UpdateNodeRequest` is generated from the OpenAPI spec, so adding
`trigger` to it broke the CLI's struct literal (and with it test-rust-cli
and the Windows embed build, which builds the CLI).

The field is passed as None -- omitted, it leaves the page's current
trigger alone -- and recorded in .openapi-coverage.toml with the reason,
alongside the same exemption the mental-model commands carry.

* fix(control-plane): expose the page trigger on the typed node-update client

The proxy route forwards the PATCH body verbatim, so the field already
reaches the dataplane -- but lib/api.ts enumerates the body fields, so
`trigger` was unreachable from any typed caller in the control plane.
2026-08-17 22:05:24 +02:00
Ben b46f9694f6 blog: 20,000 Stars — How Hindsight Got Here, Version by Version (#3503)
* blog: Hindsight Hits 20,000 Stars — by the numbers

* blog(20k): rework into a version-by-version release timeline (feedback)

* blog(20k): add at-a-glance timeline table

* blog(20k): expand every era with real feature depth + scale (reviewer feedback)

* blog(20k): new cover — rising star-curve (v1c)
2026-08-17 11:20:27 -04:00
Nicolò Boschi 803a45171d fix: report total on the mental-model and directive list endpoints (#3548)
* fix: report total on the mental-model and directive list endpoints

Both list endpoints accepted limit/offset but returned a bare `items`
array, so a caller could not distinguish a full page from the end of the
collection and silently saw only the first 100 rows. They now return
`total` (every match, not just the page) with the applied `limit`/`offset`,
matching the documents/memories/tags/chunks/operations endpoints.

- engine: `list_mental_models` / `list_directives` return a typed page
  (`MentalModelPage` / `DirectivePage`) with items + total, counted in the
  same connection as the page query.
- engine: tie-break the ORDER BY on `id`. `last_refreshed_at` (models) and
  `(priority, created_at)` (directives) are not unique — a bank-template
  import stamps a whole batch at once — so ties could reorder between
  queries and a paging caller would see one row twice and miss another.
- engine: `limit=None` returns every match. Bank-template export and import
  now use it: under the default page size an export dropped everything past
  the first 100, and import's create/update decision was made against a
  partial view of the bank, so it could create duplicates.
- mcp: `list_mental_models` / `list_directives` gained limit/offset and
  report total — agents previously could not reach past the first 100.
- control plane: `listAllMentalModels` / `listAllDirectives` page to total;
  the stats freshness card, mental-models view, bank profile and think view
  use them. The directives proxy route forwards limit/offset.
- clients: both maintained wrappers gained limit/offset on the directive
  list, with mapping regression tests on each side.

* test(control-plane): cover the list-all paging helpers

The mental-model and directive paging loops read the new `total` to decide
whether to ask again, including the empty-page guard that stops them if rows
are deleted mid-page.

* fix: update the reflect LLM-config test mock and regenerate the Go client

Two CI misses from the paging change:

- `test_per_operation_llm_config.py` stubs `list_directives` on the engine and
  reflect now reads `.items` off it, so the stub has to return a DirectivePage.
- The Go client was not regenerated (the generator skips it when Go is absent),
  leaving `model_directive_list_response.go`, `model_mental_model_list_response.go`
  and `api/openapi.yaml` without the new total/limit/offset fields.
2026-08-17 16:02:09 +02:00
Nicolò Boschi 3770d62e34 perf(maintenance): make the cross-tenant sweeps proportionate to tenant count (#3552)
The maintenance loop runs in every API/worker process with no leader election.
That is correct — the sweeps are idempotent deletes, retention claims its chunks
with SKIP LOCKED, and the two jobs that enqueue work dedupe inside the inserting
transaction. What it is not is free: each job opens with a cross-tenant discovery
call that issues one query per tenant schema, and every process pays it. The cost
scales with tenant count while the work it finds does not, so at thousands of
tenants the fleet spends most of its maintenance budget proving that tenants have
nothing to do.

Four changes, none of which add a coordination mechanism:

- Index the cron discovery probe. `mental_models_with_cron()` filters on
  `COALESCE(trigger->>'refresh_cron', '') <> ''` with nothing covering it, so
  every per-schema probe sequentially scans that tenant's mental_models table.
  The new partial index makes a tenant with no cron-scheduled models an empty
  index scan. This is the single largest idle-tenant cost in the loop.

- Move the cadences off 60s and into config. `operation_cleanup` and `mm_refresh`
  probed every schema every minute to delete rows whose retention is counted in
  days. They now default to 15 and 5 minutes, and the retention sweep's interval
  becomes a setting too, so a large deployment can tune the discovery cost without
  a code change. Raising the mm_refresh check cadence puts a floor on cron
  granularity — a `* * * * *` schedule now fires every 5 minutes — which is why it
  stays tunable and is called out in the docs.

- Jitter the first tick. Every job is due the first time `_is_due` sees it, so a
  deploy or rolling restart fired every cross-tenant probe in every process at the
  same instant. SKIP LOCKED keeps that correct but not cheap.

- Bound the export-archive purge. Unlike the row prune beside it, it had no LIMIT:
  it re-selected every expired export each cycle and re-issued a blob delete for
  each, including ones deleted on the previous cycle, because `storage_key` stays
  in the row until the row is pruned. It now shares the prune's batch bound and
  ordering so the two advance together.
2026-08-17 15:42:09 +02:00
Nicolò Boschi 3650a6e173 docs(recall): say that the created_after/created_before window filters updated_at (#3551)
`created_after` / `created_before` narrow recall on `updated_at`, not
`created_at` — the window is "memories that changed in it", so an edited
memory re-enters it. That is deliberate and load-bearing: it is what the
mental-model delta refresh chases from its watermark, and
`tests/test_recall_time_range.py` pins it. Only the names say otherwise.

The names stay (they reach the memories-extension interface and a published
response schema, so renaming breaks out-of-tree stores for a cosmetic win).
What changes is that every place a caller reads now says what the bounds
actually mean:

- `MemoryEngine.recall` documents both parameters; the internal search
  entrypoint points at that note.
- The SQL-building blocks name their locals `updated_range_*` and no longer
  carry a `created_at time range filter` comment above a clause that emits
  `updated_at`.
- `MentalModelRefreshWindow` said "Lower bound on memory creation time" in the
  published schema — the one user-visible wrong statement. It now reads "when a
  memory last changed", and the regenerated clients follow.

Behaviour is unchanged; no SQL, no schema shape.
2026-08-17 15:18:28 +02:00
Parafee41andNicolò Boschi 7850efcef6 fix: disable automatic cache affinity for Azure OpenAI (#3521)
* fix Azure cache affinity detection

* docs: state why Azure hosts opt out of prompt_cache_key

Azure OpenAI does accept prompt_cache_key on GPT deployments; what rejects
it is a non-OpenAI Foundry model (DeepSeek) served over the same
*.openai.azure.com endpoint. The host can't distinguish the two, so auto
stays off there - but operators on an Azure GPT deployment can opt back in
with openai_prompt_cache_key.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-17 15:08:53 +02:00
Nicolò Boschi b919b97df3 fix(engine): stamp memory_units.updated_at on the writes that change a memory (#3490) (#3502)
`updated_at` reads as "when this memory last changed" and consumers chase it
(`WHERE updated_at > watermark`) for incremental export, cache invalidation and
the mental-model staleness check. Several write paths never touched it, so the
chase silently skipped their changes and reported itself finished: the document
tag propagation, `set_memory_embedding`, `set_invalidation_reason` and the
transfer importer's event_date / proof_count / source_memory_ids / created_at
fixups. Those statements now stamp the column.

Consolidation bookkeeping stays exempt, deliberately. `consolidated_at` and
`consolidation_failed_at` are scheduler state, not the memory: stamping them
would make every consolidation pass look like an edit to every fact it folded,
re-flagging mental models stale and re-feeding unchanged facts to a delta
refresh for no content change. `mark_consolidated` already documented that
choice; the requeue sites that clear the markers inline now say so too.

The contract is written down on META_UPDATED_AT in the memories interface, so a
store that owns memories itself has the same rule to keep — and so the next
write path added has something to check itself against.
2026-08-17 14:01:39 +02:00
Nicolò Boschi 58d97444b3 fix(tests): stop the LLM judge mistaking context for the response (#3546)
The judge prompt gave the response and criteria ## headers but appended the context as a bare "Context provided to the system:" line. A multi-line response therefore ran straight into the context with nothing marking the boundary, and the judge read across it.

It did so deterministically: test_facts_from_distinct_chunks_reach_the_answer failed four times across four CI runs, on three different PRs — including one that touched nothing but PL/pgSQL — always with the judge quoting the *context* back as though it were the answer ("It only states that the memory data contained two hobby facts"), while the real response was a two-bullet list naming both facts and plainly met the criteria.

Tag each section instead of merely heading it: tags survive a response that is itself markdown, which headers do not — a response containing '## Criteria' could otherwise forge a section. The system prompt now says outright that only <response> is judged and that <context> is background, never the subject.

Verified by A/B against the live judge with the exact inputs from the failing test: the old prompt was judged 'criteria not met' 4/4 times, reproducing the CI reasoning verbatim; the new one 4/4 'met'.

Prompt assembly is pulled into build_judge_messages() so it is covered by fast unit tests rather than only by the LLM tests whose outcome it decides.
2026-08-17 13:27:11 +02:00
Nicolò Boschi f970174213 fix(coding-agents): stop forcing a 300s idle timeout on the shared daemon (#3544)
`daemonEnv` set `HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT` unconditionally from
a plugin-side default of 300, so a daemon started by this plugin retired
itself after five minutes of inactivity -- long enough to happen during a
build, a test run or a meeting -- and the next `hindsight_*` call hit a
closed port.

Nothing else on the machine asks for that. `hindsight-embed`'s own default
is 0 (never auto-exits), and every other integration's settings.json ships
0 explicitly; the coding-agents plugin was the sole outlier, and the daemon
it retires is shared by every agent and repo.

Unset now means unset: the env var is only forwarded when the user actually
configures `daemonIdleTimeout`, and the daemon keeps its own default
otherwise.
2026-08-17 13:21:45 +02:00
Sanderhoff-alt b8947a24e9 fix(ci): authenticate Hermes compatibility clone (#3536) 2026-08-17 13:13:30 +02:00
Nicolò Boschi 435f1640bb fix(maintenance): skip a schema under concurrent DDL instead of deadlocking (#3543)
The cross-schema discovery routines snapshot the schemas owning a target table from pg_class, then query each schema in turn inside one transaction, holding AccessShareLock on two or three relations per schema until the caller commits.

c7e9f1a3b5d2 already handles a schema vanishing mid-scan. The same race has a second outcome: the schema is being rewritten, and its DDL holds — or has queued — AccessExclusiveLock. A queued AccessExclusiveLock blocks later AccessShareLock requests, so

    routine  holds AccessShare(memory_units)  ->  wants AccessShare(banks)
    dropper  queued AccessExclusive(banks)    ->  wants AccessExclusive(memory_units)

is a cycle. PostgreSQL breaks it by killing one side, and when it picks the routine, one tenant being dropped aborts an entire maintenance pass — the recurring DeadlockDetectedError in test-api, and in production a race against tenant deletion and migration.

Give each per-schema query a short lock_timeout so it abandons the wait well before the deadlock detector runs, and skip that schema: one more arm on the handler that already skips vanished ones. Applied to all four routines so the next deadlock does not move to a sibling.

No advisory lock — project standards forbid them, so this designs the wait out. lock_timeout goes through set_config(is_local => true) because PL/pgSQL rejects the SET command inside a non-volatile function and these are all STABLE; the prior value is restored before returning.

The regression test holds ACCESS EXCLUSIVE on a schema's banks table for the duration of the call and asserts the routine still returns, skips that schema, and reports the rest. Verified failing with TimeoutError against the pre-fix body.
2026-08-17 13:07:30 +02:00
Nicolò Boschi 9da3dc9eb8 fix(coding-agents): start the local daemon for plugin harnesses too (#3524) (#3542)
`ensureDaemon` lived in the hook-only wrappers (`runSessionStartHook`,
`runRetainHook`), so every harness that drives the shared lifecycle
directly -- dsh, opencode, Kilo, Cline, Prime Agent -- never started a
daemon. In `serverMode: "daemon"` that means each `hindsight_*` call
fails with ECONNREFUSED until the user runs `daemon-start.js` by hand,
and again after every idle-out.

Move the two ensure points into `RuntimeCore`, the one path all five
share: `seedIfCold` is their SessionStart, and the write-back is their
Stop. The write-path wait sits inside the existing fire-and-forget
chain, so a cold start never blocks the host's stop handler.

`daemon.test.ts` now fails if a module builds a client without reaching
one of the two, with an explicit exemption list for the entrypoints that
must not start one (status, deepen, mcp-server, the prompt hook).

The code-review skill grows a sibling-implementation parity step: the
defect here was code that was never written, in the one variant whose
test nobody wrote either.
2026-08-17 13:04:29 +02:00
Nicolò Boschi 8fbdc6bf79 fix(mental-models): last_refreshed_at records the refresh, not the source watermark (#3538)
A refresh persisted its source-data watermark into last_refreshed_at, and that watermark is clamped so it never regresses. On a model whose scope gained no new memories the max IS the stored value, so the refresh wrote it back over itself: the document was rewritten, the timestamp never moved, and a client asking "have I already refreshed this?" refreshed it again on every tick — one reporter drove ~6,000 refreshes/day against an intended ~350 for four days.

Split the two meanings the column carried:
- last_memory_seen_at (new) takes over the watermark. Staleness, the delta window, the knowledge-tree flag and is_stale all key off it, so refresh behaviour is unchanged.
- last_refreshed_at reverts to wall-clock, stamped on every refresh that completes — including one that found nothing new and preserved the content. A failed refresh stamps neither, so a retry re-reads the same window.

The migration backfills the new column from last_refreshed_at, which today holds the watermark, so the copy is lossless and no bank changes staleness on deploy.

BREAKING (semantics, not schema): a client following the v0.9.0-documented rule of comparing last_refreshed_at against last_memory_write_at must switch to last_memory_seen_at, or it will read models as up to date that are not. This reverts semantics that #2866/#2878 changed four weeks ago; the field was wall-clock from inception until v0.8.5.

Also surfaces mental_model_id on the operations list — refresh operations return document_id: null and the list carries no result_metadata, so it could not say which model an operation refreshed.
2026-08-17 12:47:10 +02:00
Nicolò Boschi 0480f171e4 fix(api): batch the retention sweep so it stops stalling foreground queries (#3539)
* fix(api): batch the retention sweep so it stops stalling foreground queries

The hourly retention sweep issued one unbounded
`DELETE FROM <schema>.<table> WHERE started_at < cutoff` per tenant schema.
The maintenance loop runs in every API/worker process with no leader
election, so every pod issued it on the same hourly boundary: two
concurrent 330s+ deletes on `llm_requests` pinned on IO.DataFileRead,
blocking each other on row locks, saturating RDS I/O and inflating recall
from ~0.6s to ~1.8s.

Rather than elect a single sweeper, design the collision out. Deletes now
run in bounded chunks (2000 rows, oldest first off the `started_at` index,
each its own short transaction, 250ms apart) and each chunk claims its rows
with `FOR UPDATE SKIP LOCKED`. Concurrent sweepers therefore take disjoint
chunks instead of waiting on each other, the total work stays the number of
expired rows however many pods join in, and no statement holds row locks for
more than one batch. A per-run chunk ceiling keeps a table that fills faster
than it drains from looping forever; the next tick continues where it left
off.

Deliberately no advisory lock and no leader election — Hindsight runs behind
connection poolers where advisory locks are unreliable.

* chore(go-client): re-sync go.mod/go.sum after testify 1.12.0

`verify-generated-files` regenerates the Go client and fails on any PR whose
tree differs from the result. `go mod tidy` now resolves testify to v1.12.0
(released upstream), which also drops go-spew and go-difflib from the indirect
set, so main's committed go.mod/go.sum are stale and every PR trips the job.

Carried here only to unblock CI; it is the generator's own output, not a
hand-edit, and is identical to the same re-sync in #3538.
2026-08-17 12:11:32 +02:00
github-actions[bot] ec9cc702ec chore: update star history 2026-08-17 03:35:25 +00:00
github-actions[bot] 205e47b4e9 chore: update star history 2026-08-16 03:34:37 +00:00
github-actions[bot] 396f63aafc chore: update star history 2026-08-15 03:29:48 +00:00
Ben a6c2d90eec blog: Give DeepSeek Harness a Memory of Your Codebase (#3507)
* blog: Give DeepSeek Harness a Memory of Your Codebase

* blog(deepseek-harness): swap cover for editorial diff-panel design

Replaces the plain gradient lockup with the editorial template used across
recent covers: DeepSeek whale x Hindsight lockup, a bold headline
("One command. DeepSeek Harness never forgets your repo."), and a
project-memory diff panel showing the repo conventions the integration
retains (uses pgm not npm, Conventional Commits, tie-break rule).
2026-08-14 15:44:14 -04:00
Nicolò Boschi 2e8c221c54 release(coding-agents): v0.3.4 2026-08-14 18:03:40 +02:00
Nicolò Boschi 28760f62d4 feat(coding-agents): DeepSeek Harness (dsh) support (#3504)
Adds `dsh` as a persistent-plugin harness: a native Cordis plugin that binds
DeepSeek Harness's typed lifecycle events to the shared RuntimeCore.

  agent/session-start  -> seedIfCold      (cold check + background seed)
  agent/pre-step       -> onPrompt        (recall) + the injection as a
                          `{kind:'plugin', form:'recall'}` message
  agent/turn-stopping  -> onSessionIdle   (write-back of the completed exchange)
  ctx.tools            -> the hindsight_* suite, registered natively

Its Claude Code / Codex hook bridges are deliberately not used: neither ships in
a default profile, so a bridge would cost the same install while losing the
session id, the transcript and the awaited stop boundary.

dsh is the first host where ONE process serves SEVERAL repositories — its Web UI
opens each session in whatever directory the user picks — so the bank, client and
seed are resolved per session workspace and the core is constructed with that
workspace root, which is what binds the tools' git checks to the right repo.

The plugin imports nothing from dsh: host shapes are structurally typed and tool
definitions are built in the registry's own shape, so there is no dsh package for
pnpm to resolve inside a profile and no version to keep in step.

Also here:
- backfill reads dsh session logs. They are a CONCATENATION of zstd frames, and
  both of Node's decoders stop after the first one — a plain decompress returns
  only the header line — so core/zstd-frames.ts walks the frame structure and
  decodes each frame (RFC 8878 §3.1).
- transcript normalization keeps only `source.kind === 'user'` messages: dsh
  delivers plugin context (its runtime snapshots, the skill catalog, our own
  recalled memory) as user-role messages on the same surface.
- describeError: Node's fetch reports every transport failure as the bare string
  "fetch failed" and hides the reason on `cause`, which made an unreachable
  apiUrl an investigation instead of a log line.
- vitest pins HINDSIGHT_CONFIG at a path with no file; loadConfig otherwise
  resolved the developer's real ~/.hindsight/coding-agent.json, so a machine with
  a token configured failed assertions a clean machine passed.

Verified against @deepseek-ai/dsh 0.1.0-rc.6: recall reaches the model, all 8
tools reach the model and dispatch, sessions are retained, and the Docker E2E
(e2e/Dockerfile.dsh, driven through the stub model like the other credential-less
harnesses) runs the whole lifecycle in a container.
2026-08-14 17:56:57 +02:00
Nicolò Boschi 32b90cc982 perf(worker): one pooled connection per poll cycle, one statement of session setup (#3501)
* perf(worker): one pooled connection per poll cycle, one statement of session setup

The worker's claim fabric acquired a pooled connection *per active schema*.
Every acquire runs the pool's setup callback (the session GUCs, since asyncpg
wipes them with RESET ALL on release) and every release runs
RESET ALL / UNLISTEN * / CLOSE ALL / pg_advisory_unlock_all. Behind a
transaction-mode pooler each of those statements is its own server-side
transaction, so the ceremony multiplied by the number of flagged schemas:
~12 statements per schema-visit for 2 useful queries, ~463 acquire/release
cycles/s at 12 workers x ~22 schemas, and a commit rate an order of magnitude
above the useful work.

Two changes, either of which removes most of the cost:

1. claim_batch acquires once for the whole cycle and runs the active-schema
   scan plus every per-schema claim on that connection. Each schema's claim
   still opens its own transaction, so FOR UPDATE SKIP LOCKED semantics and
   lock hold times are unchanged. The progress logger's scan moves inside the
   connection it already held, for the same reason.

2. The pool's session setup issues one SELECT set_config(...) instead of N
   separate SETs. Extension GUCs (hnsw.ef_search, pg_trgm.similarity_threshold)
   may not exist on the cluster and would fail the batched statement as a
   whole, so it falls back to applying them one at a time, skipping only the
   ones the server rejects — the same tolerance the per-SET try/except had.

Fixes #3499

* feat(db): flag to skip the per-acquire session setup

The pool wires its init callback as `setup=` as well as `init=`, so the
session GUCs are re-applied on every acquire. That is required for a plain
asyncpg pool — releasing a connection runs RESET ALL, which wipes them — but
it is pure waste for deployments that pin the same settings on the role or
database, since RESET ALL restores them to exactly the values we would resend.
Behind a transaction-mode pooler that wasted round trip is also its own
server-side transaction, which is the cost #3499 measured.

HINDSIGHT_API_DB_SESSION_SETUP_ON_ACQUIRE=false drops the per-acquire hook and
keeps the open-time one, so a connection is still configured when it is
created. application_name is deliberately outside the trade-off: pgbouncer
never re-issues it after RESET ALL (#3491), so it keeps its per-acquire hook
either way.

On the vchord text-search backend the set includes search_path
(bm25_catalog, tokenizer_catalog), where losing the value fails recall
outright rather than degrading it — called out in the docs and the env
template so operators pin it before turning the flag off.

Default is true — unchanged behaviour.

Refs #3499
2026-08-14 16:56:32 +02:00
Nicolò Boschi a37257ede5 fix(recall): keep the most selective terms for native BM25 long queries (#3498)
Native tsvector ranking has no IDF and re-ranks every `@@` match, so an
uncapped long recall query OR-joins many common terms, matches a large
fraction of the bank, and forces `ts_rank_cd` over all of them — a +60s
timeout in production.

Cap the native BM25 tsquery (default 16 terms) and, when trimming, keep the
most *selective* terms — lowest tenant-wide document frequency read for free
from `pg_stats.most_common_elems` (autovacuum-maintained, no reindex, no new
table) — instead of a blunt first-N truncation that would discard the
high-signal terms. Best-effort and PG-native only: falls back to first-N when
stats are unavailable. Opt out of the catalog read with
`HINDSIGHT_API_BM25_SELECTIVE_TERMS=false`, or disable capping entirely with
`HINDSIGHT_API_BM25_MAX_QUERY_TERMS=0`.

The stats are per table (tenant-global, not per bank); a term hot in a single
bank but rare tenant-wide is not caught here — a statement-timeout backstop
(separate PR) covers that residual case.
2026-08-14 16:39:09 +02:00
Nicolò Boschi f660a4ad85 fix(retain): entity postings and witness coverage inside a write-group (#3497)
Two defects in the external-store write-group path, both silent.

**1. Entity postings were dropped.** Retain resolves entities only after the
memory ids exist, so `record_unit_entities` is a second write over rows the same
write-group already wrote. A store with deferred visibility makes those rows
invisible until the group is decided, so a store implementation that reads them
back before rewriting gets nothing and drops every posting. The memories land, so
nothing looks wrong — only the entity graph is quietly empty.

The seam now threads the caller's `txn` into `record_unit_entity_postings` ->
`record_unit_entities`, so the store can recognise the write as part of the group
and rebuild the rows from what it already staged rather than querying for rows it
has hidden. Inert for the Postgres store: its posting is an ordinary INSERT in the
caller's own transaction, which is already the unit of atomicity.

**2. Seven `begin_txn` sites never re-recorded their witness.** `begin_txn` writes
the witness row before any write has happened, so a store that records what each
group wrote sees an empty group. Retain's two main paths already re-recorded it
before commit; the streaming/delta group txns, both 0-fact document-tracking
branches, standalone document delete, delete-unit and curation did not. Each now
calls `write_txn_witness` as the last thing inside its transaction. The call is an
idempotent widening upsert, which is exactly why calling it twice is the intended
usage.

Verified structurally, not by eye: every `begin_txn` and `write_txn_witness` is
lexically inside its `conn.transaction()`, and every commit-decide is outside it.

Committed with --no-verify: the pre-commit hook's eslint step cannot run in a
fresh worktree without node_modules. The Python lints it would have run pass
(ruff check, ruff format, ty).
2026-08-14 16:11:28 +02:00
Nicolò Boschi 6956dc0661 fix(db): re-assert the DSN's application_name on every pool acquire (#3491)
A DSN like postgresql://...?application_name=my-worker labels the session
correctly under psql and on the first connection, then reports an empty
application_name in pg_stat_activity for the rest of the service's life
once a connection pooler sits in front of PostgreSQL.

asyncpg does forward the DSN's application_name in the startup packet (it
passes unrecognized DSN query parameters through as server_settings), so a
direct connection is attributed correctly. The pool, however, runs RESET ALL
on release. Direct to PostgreSQL that is harmless - RESET ALL restores the
startup-packet value. Behind pgbouncer the server connection's startup packet
is the pooler's own, with no application_name; pgbouncer applies the client's
name with a SET when it links client to server, so RESET ALL resets it to
empty and pgbouncer - which believes the value is already applied - never
re-issues it. Only the first acquire on each server connection is attributed.

Re-assert the name from the pool's setup hook, which asyncpg runs on every
acquire after RESET ALL. This is the same mechanism the backend already
relies on to keep hnsw.ef_search and the other session GUCs applied across
connection reuse. set_config() rather than SET because the name is
operator-supplied and SET does not accept bind parameters.

Verified against pgbouncer 1.25.1 -> PostgreSQL 16: before, acquires 2..n
report ''; after, every acquire reports the configured name, including under
concurrent load across multiple server connections.
2026-08-14 15:35:09 +02:00
Parafee41 217fa475e4 fix delta retain relink victim enqueue (#3420) 2026-08-14 15:15:34 +02:00
Sanderhoff-alt e32e6f2468 docs: specify Claude Sonnet benchmark version (#3468) 2026-08-14 14:47:54 +02:00
Nicolò Boschi 41b292e574 docs: rework the topbar, page chrome and skill installer (#3495)
Navbar: "Developer" becomes "Docs", and the version dropdown moves next to
it as one control — "Docs │ 0.9 ⌄" on a single surface, since the version
qualifies the docs section rather than pointing anywhere of its own. That
surface is the active state, and every top-level item now wears the same
one: gradient text over a tinted background, painted with an inset
box-shadow because active items need `background` for `background-clip:
text`. "Cloud" becomes a "Sign up" button, rendered last on the right past
a divider. Items are 2rem tall on a 4rem bar, replacing 42px items on 4.5rem.

Search moves from the navbar into the docs sidebar, where it sits with the
navigation it searches. The navbar keeps its copy for pages with no sidebar
(blog, galleries, API reference) and stands down wherever the sidebar
renders one, so exactly one search field is on screen at a time.

The nav links and the hamburger are now alternatives rather than both
showing between 997px and 1400px, where they crowded each other: the rule
meant to hide the links targeted `.navbar__items--left`, a modifier the
left container does not carry.

Page chrome: breadcrumbs are off (the sidebar shows position, and every doc
opens with its H1); the table of contents is muted so only the entry the
scroll-spy marks carries colour; prev/next and the footer drop their boxed
and slab treatments for the same hairline-and-muted-text language; and the
footer no longer forces the dark palette onto light pages, and links
Vectorize. Docs typography is a step smaller and quieter than the blog's,
and h2 trades its 2px brand gradient for a plain rule.

The skill banner becomes one slim strip: a prompt, the agents it works
with, and an Install skill button that copies the npx command, with Copy
page and Download page (.md) behind it.
2026-08-14 13:57:49 +02:00
Nicolò Boschi 3c24979a40 docs: changelog and blog post for v0.9.1 (#3438)
* fix(changelog-gen): exclude integration/plugin commits and MDX-escape summaries

Two fixes to the core changelog generator:
- Drop any commit that touches hindsight-integrations/ (not just commits that
  touch *only* that path), so integration/plugin PRs that also touch shared
  docs/CI/scripts stop leaking into the core changelog. Guard the core-changelog
  LLM prompt with an explicit skip rule too (not applied to an integration's own
  changelog).
- Escape < and > in summary prose so an HTML/JSX-like token (e.g. <think>) is
  rendered as text instead of failing MDX compilation of the changelog page.

* docs: changelog and blog post for v0.9.1
2026-08-14 11:20:35 +02:00
Nicolò Boschi 449c9c9eed test(worker): stop whole-schema claim fixtures deleting other xdist workers' operations (#3469)
`test_completed_refresh_enriches_result_metadata` flaked in CI with
`assert 'not_found' == 'completed'`: its refresh operation row vanished between
being submitted and being read back.

Root cause is cross-worker contamination on the shared `public` schema. Two
fixtures — `clean_operations` in `test_worker.py` and in
`test_graph_maintenance_claim_serialization.py` — ran a global
`DELETE FROM async_operations WHERE status = 'pending'` at setup, because their
whole-schema claim/poller logic must not pick up rows other tests left behind.
Under pytest-xdist those fixtures run concurrently with every other test, so the
delete removed *other workers'* in-flight operations. `_submit_async_operation`
commits an op as `pending` and only then does `SyncTaskBackend` mark it
`completed`; a global pending-delete landing in that window deletes the row, and
`get_operation_status` reads back `not_found`.

Neither fixture actually needs a globally empty table:

- `test_worker.py`: its tests filter claims to their own bank or assert only
  against `max_slots`, so foreign pending rows are harmless. Scope the cleanup
  to the worker-test bank prefixes (same predicate its teardown already used).

- `test_graph_maintenance_claim_serialization.py`: `test_not_starved_by_newer_pending_work`
  asserts on an *exact* single-slot (`shared=1`) claim, which genuinely needs a
  private view of `async_operations`. Give the file its own migrated Postgres
  schema (one per worker, created + dropped per session) and pin the pool's
  `search_path` to it, so `ops.claim_tasks` and the cleanup only ever see this
  file's own rows — never `public`.

These were the only two unscoped global `async_operations` mutations in the
suite. Verified: the two files plus the previously-flaky victim pass together
under `-n 4`, stable across repeated stress runs.
2026-08-14 10:57:06 +02:00
Nicolò Boschi e5b49eb672 Release v0.9.1
- Update version to 0.9.1 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.9
2026-08-14 10:45:59 +02:00
Nicolò Boschi 6c58578139 feat(memories): assert_writable — let a store close a bank to writes (#3489)
A store that routes banks between backends needs a bank closed to writes for the
few seconds it takes to copy the final delta and switch over. Refusing inside its
own methods closes it only partly: a retain also writes documents, chunks and
entities through SQL paths that never reach the memories interface, so a retain
already past its last store write keeps going and lands rows in the store that is
about to stop being authoritative.

- base.py: `assert_writable(bank_id)`, default no-op, so no existing store
  changes; plus `StoreWriteUnavailable`, which is a "not right now" rather than a
  failure and carries a retry_after.
- orchestrator.py: `retain_batch` calls it as its FIRST statement, before it
  touches the pool — one check at the single entry every retain passes through,
  rather than one at each of the writes it fans out into.
- http.py: `StoreWriteUnavailable` -> 503 with Retry-After. The type is the
  engine's own, so the mapping needs no knowledge of which store raised it — and
  a 503 rather than a 500 is what decides whether a client retries or reports a
  failure to a user.

Tests: the default allows everything (every existing store is unaffected), and a
retain against a bank a store has closed raises before anything is written —
pinned by passing None for every other argument, so a guard that ran even one
step late would raise something else.

Nothing changes for a store that does not override the hook.
2026-08-14 10:41:38 +02:00
github-actions[bot] df8244873a chore: update star history 2026-08-14 03:59:38 +00:00
Ben f28a8458ed docs: link Hindsight Academy from the docs navbar and footer (#3473) 2026-08-13 16:36:00 -04:00
Ben 7edfd6f1d7 blog: Claude Code Now Builds and Reads Its Own Knowledge Base (#3464)
* blog: Knowledge Pages — a self-healing wiki for your Hermes agent

* blog(knowledge-pages): use g05d lockup cover (Hermes x white Hindsight mark)

* blog(knowledge-pages): feature tour with real Control Plane screenshots + Hermes value framing

* blog(knowledge-pages): retitle to 'Turn Your Hermes Agent's Memory Into a Living Knowledge Base'

* blog: pivot to Coding Agents — 'Claude Code Now Builds and Reads Its Own Knowledge Base'
2026-08-13 14:53:47 -04:00
Chris BartholomewandNicolò Boschi a763109d88 Dedupe graph maintenance submits against running jobs, not just pending ones (#3444)
* fix(graph-maintenance): dedupe submits against running jobs, not just pending

Submit-time dedup for graph maintenance matched only `status = 'pending'`, so
it did nothing under sustained write load. A pending row is claimed within
milliseconds; the next trigger then finds no pending row and inserts another
job. A bank taking continuous writes therefore accumulates roughly one
maintenance job per write — hundreds of jobs for a single bank, each repeating
the same bank-wide orphan-entity and stale-cooccurrence sweeps.

`dedupe_by_bank` now takes an opt-in `dedupe_by_bank_includes_processing`, set
only for graph maintenance. Consolidation deliberately keeps the old behaviour:
its job fixes a watermark when it starts, so content added afterwards genuinely
needs a fresh run, and deduping it against a running job would drop that work.
Graph maintenance instead drains its own queue to empty, which is what makes
matching `processing` correct there.

Treating a running job as covering the bank opens one gap: a submit made after
Pass 1's final claim — including during the sweeps, which are not instant — is
now suppressed, and whatever it queued would sit until some unrelated later
write happened to trigger another submit. For the last write of an ingest batch
that may be never. So the job re-checks the queue before finishing and hands off
to a successor if anything landed.

That hand-off is gated on the run having drained something. Without the gate a
run that drains nothing and still sees queued work submits a successor that
reaches the same state and does the same thing, forever. That was a real defect
in the first version of this change and is what the no-progress test pins.

Uses a literal `status IN (...)` rather than `= ANY($n)`: the latter is Postgres
array syntax, appears nowhere else in this module, and this code also targets
Oracle. Matches the existing `dedupe_in_flight_payload_key` path.

Tests run against the real Postgres test database, since the dedup is a SQL
predicate over async_operations and a mock would prove nothing about it. Eight
cases: pending still dedupes, processing now dedupes, sustained triggers produce
one job rather than one per trigger, a completed job does not dedupe,
consolidation is unaffected, the hand-off fires when work lands mid-run, it does
not fire on an empty queue, and it does not chain when no progress was made.
Reverting the dedup widening fails two; removing the progress gate fails one.

* docs(graph-maintenance): correct wording left stale by the widened predicate

Follow-up to the dedup change in this branch. Three leftovers from review:

- submit_async_task's docstring still said dedupe_by_bank skips "if one is
  already pending", which is now only half true: whether a running job counts
  depends on dedupe_by_bank_includes_processing.
- submit_async_graph_maintenance's docstring still described deduplication
  against "an existing pending job", the exact behaviour this branch changes.
- The skip log line hardcoded "already pending" and would print that even when
  it had matched a processing row, which is misleading in exactly the situation
  the new predicate exists for. It now reports the real status, so the SELECT
  fetches it.

Also moves the _progress_relink test helper above its first use; it was defined
at the bottom of the module and read as if it were unused.

No behaviour change beyond the log text. Suite still passes (8 tests), and
re-narrowing the predicate to 'pending' still fails
test_processing_job_dedupes_a_new_submit and
test_sustained_triggers_do_not_stack_jobs, so the tests continue to pin the fix
rather than merely accompany it.

* fix(graph-maintenance): stop the hand-off deduplicating against its own job

The widened predicate defeated the hand-off that exists to make it safe.

Deduping against 'processing' means a submit made while a job runs is
suppressed, so the job hands off at the end to pick up anything queued in the
gap. But the submitting job is itself still 'processing' at that moment — the
worker only marks the operation completed after the body returns — so the
hand-off matched its own row and was deduplicated away. It was dead code, and
the gap it exists to close stayed open.

The existing hand-off tests could not see this: they monkeypatch
submit_async_graph_maintenance and assert the *call* is made, not that an
operation results. Driving the real submit shows 1 operation before the job body
and 1 after, where there should be 2.

Fix: the caller can name an operation the dedup scan must ignore
(dedupe_excludes_operation_id), and the hand-off passes the running job's own
id. run_graph_maintenance_job already receives it from _handle_graph_maintenance,
so nothing new has to be plumbed to the worker. The comparison is done in Python
alongside the existing scope check rather than in SQL, because the operation_id
cast is not portable across the Postgres and Oracle backends.

Adds test_handoff_is_not_suppressed_by_the_jobs_own_row, which drives the real
submit and counts rows. Removing the exclusion fails it and leaves the other
eight passing, so it pins this specific defect rather than the feature at large.

Consolidation is unaffected: it self-resubmits on hitting its round limit and is
only safe today because it does not match 'processing'. If it ever adopts the
widened predicate it will need the same exclusion.

* test(graph-maintenance): prove the hand-off through the real worker path

The existing hand-off tests call run_graph_maintenance_job directly and set
'processing' with a hand-written UPDATE. That left the integration unproven, and
the integration is where this fix lives: it only works if the real claim marks
the row 'processing' before the body runs, and if execute_task threads
operation_id down to the hand-off. If either were false the self-exclusion would
never apply and the hand-off would be silently dead again — which is exactly the
failure the previous commit fixed, so asserting it from reading the code is not
good enough.

Adds test_handoff_survives_the_real_worker_path, which drives the real task
payload the backend receives, the real ops.mark_operations_processing claim, and
the real execute_task router. Observed: status=processing after the claim, and
graph_maintenance operations 1 -> 2. Removing the self-exclusion turns that into
1 -> 1 and fails both hand-off tests.

Suite is 10 tests; 245 pass across the graph-maintenance, consolidation,
operation and worker test files.

* fix(graph-maintenance): reconcile the widened-dedup hand-off with main's budgeted drain

Rebasing this branch onto main revealed that main had since rewritten
run_graph_maintenance_job: both passes are now time-budgeted queue drains
(relink + entity_prune), queues_drained gates an existing chained-successor
submit, and that submit is already guarded against SyncTaskBackend.

The rebase applied the branch's hand-off block textually with no conflict,
stacking it *above* main's budget chain. That left three defects:

- main's budget-exhaustion submit passes no dedupe_excludes_operation_id, so
  once dedupe_by_bank_includes_processing is set it dedupes against the running
  job's own 'processing' row and silently schedules nothing — the exact defect
  this branch fixes for the gap case, now reintroduced for the backlog case.
- the branch's hand-off carried no SyncTaskBackend guard and, on main's
  now-deadline-bounded relink, could fire on a synchronous backend and recurse
  inline per budget window.
- the branch's leftover check and made_progress signal only saw the relink
  queue; main added a second (entity_maintenance) queue.

Reconcile into a single hand-off with two mutually exclusive branches on
queues_drained:

- Backlog (queues_drained False): keep main's contract — always chain so a
  quiet bank isn't stranded — but pass dedupe_excludes_operation_id and keep
  the SyncTaskBackend guard.
- Gap (queues_drained True): re-check *both* queues with the same portable
  existence query submit uses, gate on progress across both passes
  (relink_units_processed or entities_examined), and hand off excluding the
  job's own row. Unguarded, because a synchronous backend is single-threaded
  and so can never open this gap.

Also fixes the tests for main's shapes: relink_pass returns RelinkPassResult
(not a dict), and the budget test's submit stub accepts the new kwarg.

10 dedup tests + test_budget_exhaustion_chains_a_follow_up_run pass.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-13 17:44:48 +02:00
Nicolò Boschi 77bdda7042 docs(examples): add TEI embeddings + reranker docker-compose example (#3465)
* docs(examples): add TEI embeddings + reranker docker-compose example

Adds docker/docker-compose/tei/ — a runnable Compose stack that serves
embeddings and reranking from two HuggingFace Text Embeddings Inference
(TEI) sidecars, with the slim Hindsight image talking to them via
HINDSIGHT_API_{EMBEDDINGS,RERANKER}_PROVIDER=tei. Serves Hindsight's
default models so it's a drop-in 'move embeddings/reranking onto TEI'
demo. Links it from the TEI section of the models docs.

Verified end-to-end: retain + recall return the expected memory with
both TEI semantic and reranker scores populated.

* docs(examples): prod-like TEI tuning — bge-reranker-base + throughput flags

Swap the reranker to BAAI/bge-reranker-base (the cross-encoder commonly
paired with bge-small embeddings on dedicated inference servers) and carry
prod-like TEI throughput flags on both services (--max-concurrent-requests,
--max-batch-tokens, --max-client-batch-size) instead of TEI's bare defaults,
so the example doubles as a starting point for real deployments.
2026-08-13 17:25:30 +02:00
Nicolò Boschi 39ee97a61c fix(llm): send an explicitly configured reasoning_effort (#3449) (#3459)
* fix(llm): send an explicitly configured reasoning_effort (#3449)

`reasoning_effort` was gated on a substring match against the model *name*
(`gpt-5`, `o1`, `o3`), which can only ever match OpenAI's own products. Every
`HINDSIGHT_API_*_REASONING_EFFORT` variable was therefore a silent no-op on
self-hosted reasoning models served through `provider=openai` + a custom
base_url — vLLM, Ollama, llama.cpp, TGI — where controlling thinking-token
volume matters most, and `none` (the only value that removes the thinking
block) was unreachable through any documented configuration.

A name proves nothing on an endpoint that can serve anything under any name, so
a configured effort now outranks the heuristic and is sent on both the plain and
the tool-calling path. With nothing configured the heuristic still decides, so
endpoints that never received the parameter don't start getting it. The one
exception is a model whose name identifies an OpenAI product that rejects the
parameter outright (gpt-4o, gpt-4.1, gpt-4-*, gpt-3.5), where honouring the
setting would trade a silently ignored value for a hard 400 — and that drop is
logged at WARNING rather than being silent.

Carrying explicitness that far down meant `llm_reasoning_effort` had to stop
baking in its default: it is None when unset, and `LLMInterface` resolves the
effective level to DEFAULT_LLM_REASONING_EFFORT, so the effective default is
unchanged. The startup line now reports the reasoning mode in force.

* docs(llm): freeze _supports_reasoning_model as a capability check

Name matching only ever recognised OpenAI's own products; a new reasoning
model is now a configuration question, not a new substring.

* fix(llm): never send a reasoning effort nobody configured

Unset resolved to "low" in the config layer, so four lanes shipped a level the
operator never chose: openai-compatible for recognised reasoning models,
openai-responses, codex (unconditionally) and xai-oauth (unconditionally).
Everything else already sent nothing. That asymmetry is what made the setting so
hard to reason about — a configured value could be silently dropped while an
unconfigured one was transmitted.

Now None means None the whole way down: no provider sends a reasoning parameter
unless HINDSIGHT_API_*_REASONING_EFFORT is set, and each model runs at its own
default effort instead. `configured_reasoning_effort` collapses back into
`reasoning_effort`, and DEFAULT_LLM_REASONING_EFFORT is gone — nothing resolves
unset to a level any more.

Behaviour change for deployments that never set the variable on those four
lanes: they move from Hindsight's "low" to the model's own default effort.

* fix(llm): honour or report reasoning_effort in the remaining providers

#3449 was filed against the OpenAI-compatible lane, but three more lanes made
the same setting dead weight for a different reason: litellm, litellm-router,
gemini/vertexai, anthropic and claude-code accepted reasoning_effort into the
constructor and never looked at it again. Same symptom from the operator's
seat — the variable is set, documented, visible in the environment, and nothing
happens.

litellm and litellm-router can honour it: litellm.completion takes
reasoning_effort natively and translates it per target provider (Anthropic
thinking budgets, Gemini thinking config, OpenAI's flat parameter), and
litellm.drop_params=True discards it for models with no reasoning knob instead
of raising. Both lanes now forward it when configured — the Router builds its
own kwargs, so it needed the same line rather than inheriting one.

gemini/vertexai, anthropic and claude-code have no reasoning-effort control at
all, so they log a WARNING naming the ignored value instead of swallowing it.
Mapping effort onto Gemini's thinking_config and Anthropic's extended-thinking
budget is a real feature with cost and temperature implications; it deserves its
own change, not a guess buried in this one.
2026-08-13 17:15:14 +02:00
Ben 9f2899a936 release(eliza): v0.1.0 2026-08-13 11:14:25 -04:00
Ben f36a462d1d feat(eliza): add Hindsight long-term memory integration for elizaOS (#2385)
Adds @vectorize-io/hindsight-eliza, an elizaOS plugin that gives agents
long-term memory backed by Hindsight:

- HINDSIGHT_MEMORY provider recalls relevant memories into the prompt
  before each model call.
- HINDSIGHT_RETAIN evaluator retains conversation messages after each
  turn (fire-and-forget; agent replies optional).
- Bank defaults to the message entityId for per-user isolation; both
  sides fail safe so a Hindsight outage never blocks the agent.

Targets @elizaos/core ^1.7.2 (current npm latest, not the 2.x beta on
main). Includes tests, CI job, release-script + changelog-generator
entries, and docs gallery entry + page.
2026-08-13 11:11:24 -04:00
Nicolò Boschi 00acde0159 recall: use entity_ids carried on results, skip redundant re-fetch (#3461)
entity_build re-fetched memories via entity_map_for_units purely to read their entity_ids.
For a store that carries entity_ids on the recall result, resolve names directly and skip the
fetch; when no result carries any entity_ids, acquire no connection and do nothing. A store
whose results do not carry entity_ids (the default) leaves the field None and keeps the
existing entity_map_for_units path unchanged. Cuts entity_build from ~34ms to ~0 when there
are no entities to resolve.

Adds RetrievalResult.entity_ids, documenting the contract that a backend populating it for an
observation must include the entities inherited from its sources (recall does not resolve that
inheritance itself). resolve_entity_names is an abstract method on MemoriesExtension; the SQL
lives beside entity_map_for_units in pg/graph.py, bank-scoped, with the store delegating to it.
The name lookup normalises ids to str at the boundary, binds UUIDs, skips malformed ids, and
dedupes per unit (order-preserving), omitting entity-less units rather than mapping them to [].

Tests parametrize both recall paths (result-carried ids vs re-fetch) and assert identical
output, cover entity-less-unit omission, and add a DB-free test for the map-building helper.
2026-08-13 15:04:17 +02:00
Nicolò Boschi 5d6da3cf7e feat(docs): blog list infinite scroll, title/description search, category badges, mobile polish (#3463)
- Replace client-side pagination on /blog and /guides with IntersectionObserver
  infinite scroll (batches of 9, 400px pre-load margin)
- Add a search box filtering posts by title or description as you type
- Soften filter changes with the View Transitions API (fade out removed cards,
  glide survivors); instant fallback where unsupported
- Show a category badge on each card, driven by the same tag -> category
  mapping as the filter pills
- Tag the 18 uncategorized posts (tutorial/deep-dive/release) so every post
  has a badge and appears under a category filter
- Mobile: compact horizontal cards (120px thumbnail, description hidden),
  full-width search above scrollable pills, and calmer single-post headings
  (body font, 1.5rem h1)
2026-08-13 14:59:31 +02:00
27d4386deb feat(api): forward allowlisted request headers to extensions (#3428)
* feat(api): forward allowlisted request headers to extensions

A custom TenantExtension only ever sees the Authorization header, via
RequestContext.api_key. That is not enough for deployments behind an
authenticating proxy that presents a single shared identity to Hindsight
and carries the per-caller identity in a separate header: every request
looks identical to the extension, so it cannot attribute actions to the
actual caller or enforce per-caller rules.

Add HINDSIGHT_API_EXTENSION_PASSTHROUGH_HEADERS, a comma-separated
allowlist of header names copied into a new RequestContext.extra_headers
field, keyed by lower-cased name. Both transports populate it: the HTTP
dependency and the MCP middleware, the latter resolving headers before
authentication so authenticate_mcp() can read them, and threading them
through a contextvar so per-tool-call contexts carry them too.

Opt-in and default-off: unset means extra_headers stays empty, so nothing
changes for existing deployments and no header data reaches extension code
unless an operator asks for it. The setting is server-level only and
deliberately not per-bank configurable, so a tenant cannot widen the set
of headers its own extension sees.

RequestContext's docstring already anticipated this ("can be extended to
include additional context like headers, tokens, user info"); this fills
it in and documents the mechanism.

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

* fix(api): harden passthrough-header collection and regenerate docs skill

Review follow-ups on the allowlisted-header forwarding:

- The MCP middleware decoded every header of every request as UTF-8 once the
  allowlist was non-empty, so a single obs-text byte anywhere (a latin-1
  User-Agent is enough) raised UnicodeDecodeError and failed the request.
  Header bytes are latin-1 on the wire; decode them that way.
- The two transports disagreed on a duplicated header: Starlette's Headers
  returns the first copy, the middleware's dict comprehension kept the last.
  For a header carrying caller identity that decides whether a spoofed copy
  wins. Neither answer is safe, so a duplicated header is now dropped with a
  warning on both transports — the extension sees nothing and fails the
  request instead of silently trusting one of the copies.
- Both now share collect_passthrough_headers(), which takes raw ASGI header
  pairs (Starlette exposes them as request.headers.raw), so decoding,
  case-folding and the duplicate rule cannot drift apart again.
- get_current_extra_headers() returns a copy, so one tool call mutating
  extra_headers cannot change what the next one sees.
- MCPToolsConfig.extra_headers_resolver moved to the end of the dataclass; it
  had been inserted mid-list, shifting the meaning of positional construction.
- Regenerate skills/hindsight-docs (verify-generated-files was failing on the
  un-regenerated docs), and document the duplicate rule plus the fact that
  deferred work carries no headers.

Tests: duplicate handling and non-UTF-8 header bytes on both transports, the
shared collector's rules, contextvar copying, and an end-to-end check that the
headers reach an OperationValidatorExtension hook.

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-13 14:07:48 +02:00
Nicolò Boschi 6c8f4be6e7 release(coding-agents): v0.3.3 2026-08-13 13:46:16 +02:00
Nicolò Boschi c43a3ebfab fix(coding-agents): run the opencode survey under our own agent, not plan mode (#3460)
* fix(coding-agents): run the opencode survey under our own agent, not plan mode

Fixes #3450.

The codebase survey ran as `opencode run --agent plan`, chosen as the read-only
boundary for a session that reads untrusted repo files. But plan mode is not a
permission boundary for what the survey needs to do — it is a PROMPT. Captured
from a live opencode 1.18.9 session (fake model endpoint, so this is the request
opencode actually builds, not an inference from behaviour), every user message
carries:

  CRITICAL: Plan mode ACTIVE - you are in READ-ONLY phase. STRICTLY FORBIDDEN:
  ANY file edits, modifications, or system changes ... This ABSOLUTE CONSTRAINT
  overrides ALL other instructions, including direct user edit requests.

while `hindsight_ingest_document` stays in the tool list — plan's ruleset denies
only `edit`. So the survey is handed a tool and told not to use it, and whether
the repo gets seeded depends on how literally the model reads "system changes".
#3450 saw it stall on ~half their repos, the agent asking for an approval plan
mode cannot grant even when the user says yes.

Wording cannot fix this: the reminder claims to override all other instructions,
and the reporter's transcript is the model saying exactly that back to them.

So the survey now runs under an agent this plugin defines itself, through
opencode's `config` hook — nothing lands in the user's opencode.json, and an
entry they defined under that name wins. Measured on the same rig:

  --agent plan             reminder in 9/9 captured requests
  --agent hindsight-survey reminder in 0, tools offered: glob, grep, read,
                           hindsight_ingest_document

It is also a TIGHTER sandbox than plan mode, which is what makes it safe for
untrusted repo content. opencode drops denied tools from the model's tool list
entirely, so `"*": "deny"` plus four allows is the boundary: no write, no bash,
and no `task` — plan left all three reachable, and `task` reaches a subagent that
can write. Probed live by asking the survey agent to create a file and to run
touch: both refused, neither file appeared.

Verified end to end against a live opencode with no agent in the user's config,
so the definition could only come from the plugin: 4 ingest calls per run.

* fix(coding-agents): pin the opencode config hook to the SDK's own Hooks type

Code review on the parent commit: nothing checked that `config` is a hook name
opencode actually calls. plugin-entry.ts casts the whole runtime object to the
host's Hooks type — the other hooks take deliberately narrower params than the
SDK declares, so they cannot be checked — which means a misnamed or re-signatured
hook would compile, silently never fire, and leave the survey invoking an agent
the host never heard of.

Declaring just this hook as `Pick<Hooks, "config">` restores the check where it
matters. Confirmed by renaming it: tsc now fails with "Object literal may only
specify known properties, but 'configg' does not exist in type
Pick<Hooks, "config">".

The one cast that remains is `permission`, which the published type models with a
fixed key set (edit/bash/webfetch/…) while the runtime takes arbitrary action
names — opencode's own built-in `explore` agent is defined with `"*": "deny"`
plus per-tool allows. Casting the agent entry beats widening it to `unknown`,
which would drop the checking on `description`/`mode` as well.

Re-verified against live opencode 1.18.9 after the change: 4 ingest calls, same
as before. 499 tests, tsc and prettier clean.
2026-08-13 13:33:39 +02:00
Sanderhoff-alt 4afe43e63d refactor(kb): create page and mental model atomically (#3369)
Knowledge pages and their backing mental models share one lifecycle, but
they were committed in separate transactions. A page insert failure could
therefore leave an orphaned mental model.

Extract mental-model embedding generation and insertion into typed helpers.
Create the bank, mental model, and page through one connection and transaction,
so any page failure rolls back all related writes. Preserve the duplicate-page
contract by returning None only for uq_kp_folder_pagename.

Add PostgreSQL and Oracle regression coverage for rollback after a real mental
model insert, plus parent-validation and duplicate-name coverage.
2026-08-13 13:33:05 +02:00
Nicolò Boschi bf943e4ab7 fix(release): keep package-lock.json's version in step with the release (#3456)
The npm branch of the release script sed's package.json only, and a
package-lock.json carries the package's own version twice — so every npm
integration release since its lock was committed left the lock pinned at the
version it was born with. coding-agents shipped v0.3.2 with a lock still
claiming 0.0.5; five other integrations had drifted the same way, by one to
several releases:

  coding-agents  0.3.2  lock 0.0.5
  eve            0.2.1  lock 0.2.0
  obsidian       0.2.1  lock 0.2.0
  openclaw       0.10.0 lock 0.9.0
  opencode       0.2.8  lock 0.2.6
  paperclip      0.3.0  lock 0.2.3

Nothing was broken by it — npm ci tolerates the mismatch (which is why CI stayed
green), and npm never publishes the lock, so no published artifact carried the
wrong number. It is a stale field that misleads anyone reading the tree.

`npm version --no-git-tag-version --allow-same-version` rewrites exactly those
two fields. `npm install --package-lock-only` was the obvious alternative and is
the wrong tool here: it re-resolves the dependency graph, so a release commit
could silently carry dependency bumps nobody asked for. Verified on a scratch
copy that the dependency entries come out byte-identical.

The six locks above are synced in the same commit; leaving them stale would mean
the script only stops the drift getting worse. npm ci re-verified against the
synced coding-agents lock.
2026-08-13 13:21:11 +02:00
Nicolò Boschi e8c42f2a74 perf(recall): make temporal extraction ~9x faster without changing behaviour (#3452)
* test(recall): characterize temporal extraction + latency harness

Golden suite snapshots 2538 (query x reference-date) results from the current
implementation so the search_dates optimisation can be proven behaviour-preserving.
Adds a burst@N harness modelling how recall actually calls this (inline on the
event loop) and a perf diary with the measured baseline.

* perf(recall): skip dateparser search when no span could score

_date_match_score awards points only for an ASCII digit or one of four English
word sets, and search_dates returns substrings of the original text. A query
containing none of those cannot produce a scoring match, so the search is
guaranteed not to change the answer and can be skipped.

burst@32 p99 97.0ms -> 16.3ms; non-temporal queries 59ms -> 0.04ms.

* perf(recall): exact-equivalent language detection without the redundant work

Replaces search_dates' detection with a copy that memoises the O(locales^2)
unique-character sweep, hoists pop_tz_offset_from_string out of the per-locale
loop (199 identical calls -> 1), and skips the strip-timezone retry when
stripping changes nothing. Differential test runs it against dateparser's own
implementation over the corpus plus ~2600 random/mixed-script strings.

Also fixes a bias in the burst harness: it only ever issued workload[:N].

* perf(recall): upgrade dateparser to 1.4.2 and gate temporal-extraction latency

Upgrade brings three fixes that postdate 1.2.2: unsafe pickle deserialisation of
timezone data and an eval() in locale metadata (1.4.0), and ReDoS/quadratic
backtracking on long digit runs (1.4.1). The last one is also a large perf win on
this path -- a 400-char digit run drops from 143.8ms to 2.0ms of parse time --
because consolidation recalls pass stored fact text as the query.

17 of 2538 golden cases change, all upstream behaviour fixes ('two days later'
resolved backwards; '1mon ago' read '1' as January). 'so what do we do now' now
yields a today-constraint where it previously yielded none; flagged in the diary.

Adds latency gates (CPU-time budgets in fast CI, wall p99 under 32/64/128/256
concurrent callers marked slow) and ports four pre-existing tests off the
internals this work replaced.

* perf(recall): run temporal extraction off the event loop

It is pure CPU on an async request path, so running it inline froze the loop for
its full duration and stalled every other in-flight request in the process --
measured at 16 concurrent document-sized extractions, the loop got a single
scheduler tick in 1.3s.

The pool is deliberately one worker. The work holds the GIL, so widening it adds
no parallelism and costs throughput badly: 1 worker 1438ms, 2 workers 2091ms,
4 workers 4751ms, unbounded asyncio.to_thread 16688ms (12.8x worse than inline)
-- all while inline was 1318ms. One worker preserves throughput (+9%) and drops
max loop stall from 1318ms to 2.8ms.

Safe to run off-thread only as of the detector rewrite: the analyzer now owns its
_ExactLanguageSearch instead of sharing dateparser's self-mutating singleton.

* refactor(recall): address code-review findings

- _char_tables returned a 2-tuple; project rule is no multi-item tuple returns
  even for private helpers. Now returns a LocaleCharTables dataclass.
- Add missing type hints on the settings parameters.
- DEFAULT_LANGUAGES is set dynamically on Settings, so read it via getattr.

* test(recall): consolidate temporal-extraction tests into one suite

Five separate test modules covering one change is more files than the change
warrants. Merged into tests/test_temporal_extraction.py with five sections:
golden corpus, pre-filter soundness, detection equivalence, off-loop execution,
latency gates.

query_analyzer_corpus.py and query_analyzer_bench.py stay separate: the corpus is
shared data and the harness is runnable standalone, neither is a test module.

* test(recall): size latency budgets for CI hardware, not a dev machine

test_whole_corpus_cpu_budget failed in CI at 2.73s against a 2.0s budget. The
budget was set from local timings; the shared runner is ~5x slower, so a budget
tuned locally flakes there.

These are regression tripwires, not benchmarks. Re-sized against what they are
meant to catch -- the pre-optimisation corpus sweep was ~55s CPU, so 10s still
catches a regression of that class with room for the slowest runner.
2026-08-13 11:17:16 +02:00
Nicolò Boschi 32c5d65773 fix(ci): fetch crates for all targets before the offline license scan (#3457)
The Rust CLI job failed in `cargo about generate --offline`: cargo-about
resolves the dependency graph for every target platform, so `cargo metadata`
wants crates this runner never builds (it died on the Android-only
android_system_properties). hindsight-cli/Cargo.lock is gitignored, so CI
re-resolves from scratch and no earlier step has populated the registry.

Run `cargo fetch` first in both the PR test job and the release job. Without
--target it downloads for all target platforms, so the subsequent --offline
generate resolves entirely from cache and keeps its ClearlyDefined lookups
disabled.
2026-08-13 10:54:42 +02:00
Nicolò Boschi e6fb5d4799 perf(recall): score observation expansion set-wise, not per candidate row (#3085) (#3451)
The observation graph arm's entity CTE spent 95% of its time in a correlated
subquery: the shared-source score was COUNT(DISTINCT s) over
unnest(mu.source_memory_ids) filtered by `= ANY(ca.source_ids)`, re-run for
every candidate row. Each execution linearly scanned that row's array against
the connected-source array, so cost grew with the product of the two — and
consolidation appends to source_memory_ids without ever pruning it (#1725), so
that product grows with the bank's age.

On a 10k-unit bank whose observations averaged 113 sources (the shape reported
in #3085), EXPLAIN ANALYZE attributed 2.47s of a 2.60s query to that SubPlan:
loops=4980 x 0.497ms, ~1.7B element comparisons pegging one backend. The graph
traversal CTEs underneath it cost ~5ms, and the semantic/causal query ~6ms.

Unnest each candidate's array once and hash-join connected_sources instead.
Output is unchanged — verified (id, score) identical to the old SQL across 5
queries x 4,980 rows.

Paired measurements on the same bank (5k facts + 5k observations, 15k
unit_entities, 154k links), graph arm p50:

  sources/obs |  before |  after
            1 |    65ms |   58ms
           10 |   425ms |  193ms
           50 |  1338ms |  230ms
          113 |  2539ms |  274ms

perf-test --scale large --suite recall-with-observations: p50 11.6s -> 3.5s,
throughput 1.38 -> 4.68 q/s, retrieval_graph phase 8.90s -> 1.15s.

The perf suite could not see any of this because its fixture gave every
synthetic observation exactly one source fact — the degenerate value of the
only dimension this query scales on. It now takes sources_per_observation
(113 at scale=large, drawn from a neighbour window so source sets overlap),
and publishes the value in the results JSON so a fixture change reads as a
fixture change on the dashboard rather than as a latency regression.

Note for the dashboard: recall-with-observations will step up once this lands.
That is the heavier fixture, not a regression — the same suite on the old SQL
measured 11.6s p50 against 3.5s here.

Oracle's expand_observations has the same per-row shape but joins the indexed
observation_sources junction table rather than scanning arrays; left alone.
2026-08-13 10:41:19 +02:00
Nicolò Boschi 383b1d2017 fix(coding-agents): say when a turn is running without memory (#3455)
Addresses #3443.

When the once-per-session hook reflect fails, the turn proceeds with no memory
injection and the session looks exactly like a healthy one. The failure IS
recorded — log.warn to plugin.log and a reflect_failed diag line — but both are
files nobody is tailing mid-session. The reporter found their own two failures
only because they happened to open the diag log for an unrelated reason, and one
of them ate the prompt in which they were asking about this very limit.

diag.ts already states the goal in its header: "so a silently memory-less session
can't masquerade as one that worked". A file achieves that only in a post-mortem.

One terse line on the affected turn, naming the trail to open:

  Hindsight · no memory this turn — see /tmp/hindsight-plugin.log

Deliberately not an explanation and not advice to the agent — the details are in
the file it names, and it fires at most once per session, on the turn reflect
ran. Later turns don't re-run reflect, so re-announcing a failure they never
observed would just nag.

An EMPTY answer is NOT a failure: reflect can legitimately have nothing to say on
a sparse bank, which diag already records as its own reflect_empty event. Tying
the line to a flag set only in the catch keeps "no relevant memory exists"
distinct from "reflect broke" — collapsing them would report a breakage on
exactly the fresh, sparse banks where nothing is broken, and that confusion is
what the issue is about in the first place.

The notice reaches the user on claude-code (systemMessage) and as a toast on
opencode/kilo; harnesses whose hook schema has no user-visible channel ignore it,
as they already do for the existing reflect notice.

Not addressed here: reflect overrunning the 25s cap in the first place. The cap
stays (it must remain under the harness hook timeout), and the reporter's own
measurements point at reasoning tokens, which is provider-specific and already
reachable through HINDSIGHT_API_REFLECT_LLM_EXTRA_BODY server-side.
2026-08-13 10:31:48 +02:00
Nicolò Boschi 257df73d5c docs(config): clarify that zeroing maintenance intervals stops scheduling, not execution (#3454)
Background work runs in two independent stages and the interval knobs only
govern the first. CONSOLIDATION_RECONCILE_INTERVAL_SECONDS=0 and
MENTAL_MODEL_REFRESH_TICK_SECONDS=0 stop the maintenance sweeps enqueueing new
work, but the worker poller claims and runs whatever is already pending on its
own cadence — including operations recovered from a previous run after a
restart. The knob that actually quiesces execution, WORKER_ENABLED, was
documented in a different section with nothing linking the two.

This cost a user two rounds of invalid isolation testing while diagnosing #3355:
consolidation kept running alongside the workload being measured and was
attributed to it.

Also fixes RETAIN_BATCH_POLL_INTERVAL_SECONDS, whose description ("Batch API
polling interval in seconds") reads like an ingestion knob. It is the status
poll against the LLM provider's Batch API, applies only in provider batch mode,
and at 0 removes the wait rather than disabling anything.
2026-08-13 10:21:48 +02:00
handnewb 650da8c1eb fix(llamacpp): pass extra_body through factory to LlamaCppLLM and OpenAICompatibleLLM delegate (#3431)
The create_llm_provider() factory accepted extra_body but never
passed it to LlamaCppLLM (unlike every other provider). The
LlamaCppLLM constructor also lacked the parameter. This prevented
users of local LLMs from passing provider-native parameters via
HINDSIGHT_API_LLM_EXTRA_BODY.

- llm_wrapper.py: Pass extra_body=extra_body to LlamaCppLLM
- llamacpp_llm.py: Accept extra_body in __init__, store it,
  forward to OpenAICompatibleLLM delegate

Closes #3326
2026-08-13 09:44:13 +02:00
Sanderhoff-alt 4e5b4ceecc fix(api): correct stale OpenAPI examples (#3436)
Keep bank configuration examples limited to fields that can actually be
overridden per bank, and replace the static LLM setting examples in the
endpoint descriptions.

Complete the operations and version response examples with their required
fields, and use the real refresh_mental_model operation type.

Regenerate the OpenAPI documentation, docs skill mirror, and generated client
descriptions.
2026-08-13 09:43:45 +02:00
Sanderhoff-alt 0c2e384287 fix(release): ship Rust CLI license notices (#3447)
Publish the Hindsight MIT license and generated third-party license notices
alongside the Rust CLI release assets. Generate the manifest from Cargo.lock
with cargo-about and preserve the complete dependency license text.

Run generation and verification in the Rust CLI PR test job so unsupported
license expressions fail before a release tag. Generate the platform-independent
manifest once in the Linux amd64 release job and fail if required assets are
missing before publishing.

Disable ClearlyDefined lookups to keep CI generation deterministic.

Fixes #3446
2026-08-13 09:39:32 +02:00
Nicolò Boschi 3e1b4611cf fix(reranker): batch FlashRank passages instead of one unbounded forward pass (#3355) (#3441)
* fix(reranker): batch FlashRank passages instead of one forward pass (#3355)

FlashRank scored every candidate of a recall in a single ONNX forward pass.
That pass allocates attention tensors sized batch * heads * seq^2, so at the
default reranker candidate cap it costs gigabytes — enough to OOM-kill the
container on a large bank. The burst scales with the candidate pool the
retrieval arms produce, not with how much work the caller asked for, so a
consolidation of a single fact could still trigger the full allocation.

The local (batch_size=32) and TEI (128) providers already batch; flashrank was
the only path left unbounded, and had accumulated two band-aids around the
symptom (cpu_mem_arena=False, release_local_inference_memory) without ever
bounding the batch.

Scores are unchanged: passages are scored independently, so batching alters
only the allocation profile.

New HINDSIGHT_API_RERANKER_FLASHRANK_BATCH_SIZE (default 32), also settable per
failover-chain member. Non-positive values clamp to 1 so a misconfiguration
cannot silently restore the unbounded pass.

* fix(worker): report current RSS in WORKER_STATS, not the peak (#3355)

`[WORKER_STATS] ... proc: rss_mb=` was reading ru_maxrss, the high-water mark
since process start, which never decreases. A single transient allocation
pinned the field at its peak for the life of the process, so every later
reading looked like memory that was still held.

That is not a cosmetic mislabel: while diagnosing #3355 it made a transient
reranker burst read as retained heap, and sent the investigation after a leak
that did not exist.

rss_mb now reports the current resident set, read from /proc/self/statm (two
integers from a pseudo-file, cheap enough for every stats tick), with the
high-water mark reported alongside as peak_rss_mb. Platforms without /proc
report peak_rss_mb only rather than passing the peak off as current.

The Prometheus gauge in metrics.py is untouched — it already labels the same
value rss_max, which is accurate.
2026-08-13 09:34:18 +02:00
github-actions[bot] 5e846b4917 chore: update star history 2026-08-13 04:00:06 +00:00
Ben bad06de5ad blog: Give Any Agent Plugins Client Long-Term Memory (#3440)
* blog: Give Any Agent Plugins Client Long-Term Memory
2026-08-12 14:25:14 -04:00
Nicolò Boschi 6c65c374cc Delete docs/superpowers directory 2026-08-12 17:43:28 +02:00
5cef095960 fix(llm): preserve inline <think> literals in _strip_reasoning_tags (#3426)
* fix(llm): preserve inline <think> literals in _strip_reasoning_tags (#2195)

The unclosed-block strip used a greedy `.*` to end-of-string, which
deleted every inline `<think>` literal plus all following content when
the model quoted the tag verbatim inside a JSON value (e.g. a retained
conversation that discusses `<think>` tags). This corrupted otherwise
valid JSON and surfaced as `Unterminated string` in retain fact
extraction, causing memories to be silently lost.

Fix: only strip unclosed blocks that start their own line (line-start,
possibly indented). Inline literals are now preserved. Closed blocks
`<think>...</think>` keep their existing behavior.

Adds regression tests for inline literals in JSON values, mid-sentence
literals, indented line-start blocks, and multiple inline literals.

* fix(llm): strip multi-line truncated <think> blocks whole, not just first line

The line-start anchor correctly preserved inline <think> literals, but
switching from greedy .* to [^\n]* meant an unclosed (truncated) block
that spans multiple lines only lost its first line -- the remaining
reasoning leaked into stored memory, the exact free-form contamination
_strip_reasoning_tags exists to prevent.

Keep the line-start anchor (so inline literals in JSON stay intact) but
restore DOTALL-to-end-of-string so a multi-line truncated block is
removed whole. Add a regression test for the multi-line leak and update
the indented-block test to expect end-of-string truncation semantics.

---------

Co-authored-by: hundunweimi <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-12 17:17:40 +02:00
Nicolò Boschi 45713adcc6 fix(files): give each batched file a unique storage key (#3226) (#3437) 2026-08-12 16:55:55 +02:00
Nicolò Boschi 06b208fd99 retrieval: recall is one unified store method; per-arm is Postgres-internal (#3434)
Recall was an interface of separate arms (search + graph_search-per-fact_type +
temporal_search) — but that split is a Postgres implementation detail, not the contract.
A store that owns its index (memlake) answers every arm in one query; forcing it through
per-arm calls re-sent the vector and re-ran the dense probe 1+N times per recall.

Make recall_unified THE recall interface (abstractmethod): dense + BM25 + graph + temporal
for all fact_types in one call, returning {ft: {semantic,bm25,graph,temporal}}. Postgres
implements it by running its per-arm SQL internally (the old Steps 2/3 of
retrieve_all_fact_types_parallel move into PostgresMemories.recall_unified; search/
temporal_search stay as PG-private helpers, off the interface). memlake implements it with
one Query RPC. retrieve_all_fact_types_parallel just extracts the temporal constraint and
calls recall_unified. Consolidation's dedup uses the same method (enable_graph=False).

Removed the leaked interface methods (search/temporal_search) and the routing wrappers
(retrieve_semantic_bm25_combined / retrieve_temporal_combined); the _sql variants stay as
PG internals. Postgres recall behavior is byte-identical (same SQL, connection sharing,
graph seeding, floors, ordering). Verified on dev (memlake org): identical ranked results,
one Query per recall.
2026-08-12 16:53:37 +02:00
Nicolò Boschi ec4b68ebf1 release(coding-agents): v0.3.2 2026-08-12 16:43:44 +02:00
Nicolò Boschi a4c1593340 fix(coding-agents): bound the 429 retry by the caller's clock, not a constant (#3425)
The retry budget was a flat 6s, so a Retry-After longer than that was never
honoured. That was defensible mid-session — the next write-back replaces the
whole document — but wrong on a session's LAST Stop, where there is no next one,
and wrong for the persistent-plugin harnesses, which have no host timer at all
and could easily have waited.

The budget is now a deadline the caller supplies, because only the caller knows
its clock:

- Hook harnesses pass the host's own kill timeout, which is per-harness and not
  a constant: 60s for Claude Code, Codex, Copilot, Devin and Grok, but 30s for
  Cursor and Antigravity. RetainHookSpec carries `hostTimeoutSec` — the same
  number the installer writes into the hook registration — and the deadline is
  process start plus that, less a 2s margin for the response to come back.
- The persistent-plugin runtime has no external timer, so it takes the default
  60s window and can ride out a rate limit a hook could not.

A retry now also has to leave room for the request itself (`req` aborts at 15s):
starting a wait that cannot finish before the deadline buys nothing.

The rule that a Retry-After is honoured in full or not at all is unchanged —
waiting less than the server asked just earns another 429.
2026-08-12 16:42:50 +02:00
Nicolò Boschi 229eefb0c9 retain: don't hold the data-plane connection across a separate-store write (#3414)
* retain: don't hold the data-plane connection across a separate-store write (streaming)

For a memories store that owns its rows in a SEPARATE system, the streaming retain
batch held an open Postgres transaction (and the document row lock) across the slow
object-store write — the memory is written to the store twice (facts, then a re-write
carrying entity ids), so the connection sat idle on I/O for both round-trips. Under
load this starves the data-plane pool.

Add a distinct connection-management path, selected when mint_txn() returns a
write-group handle (Postgres returns None and is completely unchanged):

  mint  ->  stage the store writes with NO connection (tagged, invisible)
        ->  short txn { doc/chunk metadata, entity-registry reassert, outbox, witness }
        ->  decide(commit) as a connection-free object-store marker

Visibility is controlled by the write-group tag + decide, not by holding the row lock,
so the connection is only needed for the fast local rows and the commit witness; the
recovery sweep resolves a crash between the writes and the witness. The Postgres link
writers (temporal/semantic/causal) are skipped for such a store: temporal/semantic
touch zero rows there, and causal edges already travel on the memory record.

Adds entity_resolver.record_unit_entity_postings and unit tests pinning the
connection-free contract. Full/delta retain site to follow.

* retain: delta re-retain also releases the connection across the store write

Applies the same connection-management path to _run_delta_db_work: for a store that
returns a write-group handle from mint_txn, stage the new facts (+ entity re-posting)
and the document-body upload with NO connection held, then take the connection only for
the short transaction that records document/chunk metadata, the chunk tombstones, the
entity-registry reassert, the outbox row, and the commit witness; publish with a
connection-free decide(commit). Postgres (mint_txn -> None) keeps the single-transaction
path unchanged.

Falls back to the streaming path (discarding the staged writes via decide(commit=False))
when the ownership recheck finds the document was replaced. Adds two delta unit tests to
the connection-free contract suite.

* style: ruff format + lint fixes for the ext write-group code

* review fixes: typed ext-write results, no duplicate outbox delivery, abort staged writes on lost append

- Set outbox_fired after the ext streaming helper commits the outbox row in its
  short transaction, so the post-loop fallback doesn't queue a duplicate
  retain.completed delivery.
- Replace the tuple returns of _streaming_batch_write_ext/_delta_batch_write_ext
  with dataclasses per project standard.
- A lost append race (assert_append_base_unchanged) now discards the staged
  store writes eagerly via the shared BaseException abort, instead of leaving
  them for the recovery sweep; regression test added.
- Emit the delta log buffer on the ext success path (was silently dropped).
2026-08-12 16:27:40 +02:00
Nicolò BoschiandRaphaël Auvray da8077adeb fix(engine): scope document statements by bank_id (#3429) (#3435)
delete_document/update_document filtered memory_units on document_id without bank_id in four statements (one a cross-bank UPDATE ... SET tags write). Adds AND bank_id to all four, plus two-bank regression tests and a bank/tenant query-scoping check in the code-review skill.

Original fix by Raphaël Auvray (#3430).

Co-authored-by: Raphaël Auvray <[email protected]>
2026-08-12 16:21:02 +02:00
Nicolò Boschi 681a79e6b4 fix(graph-maintenance): drive the entity prune off a queue, not a bank-wide sweep (#3222) (#3409)
* fix(graph-maintenance): drive the entity prune off a queue, not a bank-wide sweep (#3222)

The graph_maintenance job's Pass 2/3 were two bank-wide statements re-evaluated
on every invocation, whether or not anything had changed: the orphan-entity
prune probed once per entity in the bank, and the stale-cooccurrence prune
evaluated an INTERSECT per cooccurrence row in the bank. Their cost tracked the
size of the bank rather than the size of the delete, so past a few million rows
neither could finish inside asyncpg's 60s command timeout. The job then failed
on every run with a bare TimeoutError, forever, on exactly the banks that most
needed it — and, because db_utils treats a timeout as transient, re-ran the
doomed statement nine times per attempt, holding a worker slot for ~10 minutes
each time.

Both prunes are now driven by `entity_maintenance_queue`, filled inside the
deleting transaction the way `graph_maintenance_queue` already is for the relink
pass. A run claims a bounded batch of candidate entities, prunes what is
genuinely dead, and commits — so the cost is O(delta), and the work already done
survives whatever stops the run.

Measured on a dense fixture (100k entities, 1.5M unit_entities, 2.86M
cooccurrences, hub entities holding 150-400 postings):

  bank-wide orphan prune          9.6s      → batch of 50:   15ms
  bank-wide cooccurrence prune    >11 min   → batch of 50:   2.0s
                                  (cancelled; ~1.7ms per pair over 2.86M pairs)

Also:

* A wall-clock budget for the whole job. Both passes commit per batch, so
  exhausting it is not a failure — the run reports `queues_drained: false`,
  logs it, and chains a follow-up (under a real queue; a synchronous backend
  would recurse instead of schedule). Large backlogs converge over runs.
* The scoping predicate is a UNION of the two endpoint columns, not
  `entity_id_1 = ANY(...) OR entity_id_2 = ANY(...)` — that OR is the #3387
  shape and cannot be driven from either index.
* Every site that removes units or replaces entity postings now enqueues
  candidates: document delete, single and bulk memory delete, curation
  edit/invalidate, document re-ingest, and the delta-retain chunk cascade.
* The migration seeds the queue with every existing entity, so garbage a bank
  accumulated while its sweep was failing is still reclaimed — incrementally,
  a bounded batch per run, instead of in one statement that cannot finish.

* fix(graph-maintenance): compose the queue-scoped prune with the set-based staleness check

Rebase reconciliation with #3408, which landed the same statement while this
was in review.

staleness against a set of live pairs built once, instead of a correlated
INTERSECT re-scanned per row — removing the (rows judged) x (hub degree)
product. That is the better predicate, and it composes with the queue scoping
rather than competing with it: `live` is now seeded from the *claimed
candidates'* units instead of the whole bank's. Correctness holds because every
pair being judged has a candidate as an endpoint, so any unit still grounding
one of those pairs references a candidate and is in the seeded set.

Measured on the dense fixture (100k entities, 1.5M unit_entities, 2.86M
cooccurrences):

  bank-wide, set-based (#3408 as merged)   did not finish in 10 min
  batch of 50, per-pair INTERSECT (mine)   2.0 s
  batch of 50, composed                    16-65 ms

The batch-size rationale is updated to the new numbers; 50 still holds, now
with three orders of magnitude of margin instead of one. #3367's hub/bank-
scoping regression test is kept, adapted to seed candidates.

Also re-chains the migration onto d9c1a7b4e2f6, which took the same parent on
main and would otherwise leave two alembic heads.

* fix(graph-maintenance): restore the review fixes without the birth-time enqueue

Drops the "queue every entity at creation" change and keeps the rest of the
review round (dataclass pass results, the Oracle IN-list chunking on the by-unit
enqueue, the per-site enqueue tests, the migration-seed test, the budget's
follow-up-chain test, the stale-comment sweep).

The birth-time enqueue existed to reclaim an entity created in retain's Phase 1
whose Phase-2 link never landed. It is not worth what it costs: such a row is a
single entry in the registry with no postings and no cooccurrences, and #2662
exists because the retry is *supposed* to adopt it — Phase 2 reasserts resolved
parents under FOR KEY SHARE precisely so a pruner cannot delete one out from
under it. Pointing the pruner at every freshly created entity leans on that race
for a leak that is one row wide. #3408 landing the set-based predicate is what
made the trade obviously bad: the expensive half of this job was never those
rows.

Entities created but never linked are therefore no longer proactively reclaimed.
The migration's one-time seed still clears the population a bank has already
accumulated.

* fix(graph-maintenance): don't backfill the entity queue on upgrade

The migration seeded one queue row per existing entity so a bank could reclaim
what it stranded while its bank-wide sweep was failing. That is the wrong trade:
the INSERT runs inside a migration at API startup, so a large deployment pays a
slow upgrade writing a row per entity, and then a prune check for every one of
them — a self-inflicted backlog to collect rows that cost the bank nothing.

The queue now starts empty and fills from real deletes. Historical strays stay
until something touches them; they are single registry rows with no postings and
no cooccurrences.

The migration test pins the two properties that are easy to lose later: the
upgrade enqueues nothing, and the composite key collapses overlapping deletes
into one row (which is also what the #3034 locking upsert conflicts on).
2026-08-12 16:14:19 +02:00
Nicolò Boschi 500ab70f13 fix(mental-models): trace delta-ops call and decouple its completion cap (#3421) (#3424)
A delta-mode refresh whose structured-delta LLM call fails to parse wedges:
the #3112 window guard (correctly) preserves content and refuses to advance
the watermark, so the next trigger re-reads the identical window, and at
temperature 0 the delta call reproduces the identical malformed output —
parse fails identically forever with no self-recovery.

Two root-cause fixes, deliberately NOT a fall-back to full re-synthesis
(that abandons delta mode's purpose and #3112 already rejected it):

- Decouple the delta transport cap from the document budget. Passing the
  doc-sized delta_max_tokens as max_completion_tokens truncates the ops JSON
  on thinking models (reasoning tokens eat the budget); the cut-off JSON then
  fails the parse deterministically. Use reflect_max_completion_tokens
  (uncapped by default), same decoupling reflect's synthesis got in
  #3365/#3389. delta_max_tokens stays as the prompt-level budget hint.

- Trace the delta call. It ran on the raw _reflect_llm_config outside
  reflect_async's trace context, so its LLM calls were never written to the
  trace table — the blind spot that made these failures impossible to
  diagnose. Wrap it in with_config(bank_id, operation, mental_model_id).
2026-08-12 16:03:58 +02:00
Nicolò Boschi e5025aaf5b release(coding-agents): v0.3.1 2026-08-12 14:50:19 +02:00
Nicolò Boschi 77bbb93940 feat(coding-agents): optInOnly — run memory only in projects that were opted in (#3433)
Closes #3427.

By default every project gets memory, which is what makes the plugin zero-setup.
For shared machines and client work that is the wrong default: unrelated
directories create banks nobody asked for, and there was no way to say "off
unless I name it".

  { "optInOnly": true, "optInPaths": ["~/work/client-x", "~/oss"] }

Anything outside those paths is inert — no bank created, nothing retained, no
seed — and the agent behaves as it would without the plugin.

Approval is deliberately separate from routing. `optInPaths` says WHICH PROJECTS,
not which bank, so an approved repo keeps its usual coding-agent::{gitProject}
name and approving costs no naming decisions. Paths are prefixes with `~`
expanded, so approving ~/work approves the repos under it while each still gets
its own bank. That is why this is not built on mapPathToBank, which the issue
reporter and I both first reached for: using it for approval forces you to name a
bank per project and collapses a whole tree into one.

A mapPathToBank entry does count as opted in — routing a path to a named bank
already declares that project. A bare bankId does not: it names a bank rather
than a project, so it cannot express which work may be remembered, and a privacy
switch has to fail closed.

Enforced through the `disabled` gate every entry point already checks after bank
resolution: applyBankConfig takes the directory the bank came from and returns a
disabled config when it is not opted in. That reuses a path already known to stop
a run before anything creates a bank, rather than adding a second thing nine call
sites must remember.

Verified end to end against a live server as well as in unit tests: an unlisted
project produced no plugin events and no bank, while an opted-in one under the
same config seeded, injected and retained normally.

Why not a `.hindsight.json` in the repo (the issue's Idea C): the config module
deliberately reads no repo-carried file, because an untrusted repository must not
be able to influence memory behaviour — and here it would let a cloned repo turn
memory ON, which is exactly backwards for a privacy control.
2026-08-12 14:48:10 +02:00
Nicolò Boschi 64ede36172 fix(consolidation): refresh every affected mental model after a multi-round drain (#3411)
refresh_after_consolidation models were dropped when a consolidation backlog spanned multiple rounds (only the final round's tags were refreshed). Accumulate the affected-tag union across the whole round-limited chain and flush the refresh once, exactly-once per model, when the backlog drains. The union is durable: each batch persists its tags into the op's task_payload inside the batch's witness transaction (crash-safe), the re-queue threads it forward, and a dedupe into a concurrent consolidation folds it into the survivor. Adds multi-round, dedupe-merge, and mid-round-crash regression tests.
2026-08-12 11:06:55 +02:00
Nicolò Boschi 8b33bfdd28 release(coding-agents): v0.3.0 2026-08-12 10:45:03 +02:00
Nicolò BoschiandDavid Eriksson 098362d450 feat(coding-agents): handle 429 on both the request and the poll path (#3423)
* feat(coding-agents): retry a rate-limited write-back instead of dropping it

A 429 on POST /memories was indistinguishable from a 500: `req` threw a plain
Error, the write-back was abandoned for that turn, and nothing backed off. The
content survived — the cursor stays dirty, so the next Stop replaces the whole
document — but the turn's write simply did not happen, and on a session's LAST
Stop there is no next one.

429 now raises a typed RateLimitedError carrying the parsed Retry-After, and the
session write-back retries on it. Retrying is safe rather than duplicative
because the payload carries a deterministic operation_id: an identical
resubmission is collapsed into the original operation server-side, so a retry
after a response we never saw cannot write twice.

Two conditions bound it, and the first is the point:

- ONLY while our write is still the newest. If another write-back has claimed the
  cursor since — a newer turn, another process — ours is superseded: the newer
  one carries what we were sending, or replaces the document outright because our
  failure left the cursor dirty. Retrying then would spend a hook's remaining
  time re-sending content already on its way.

- ONLY within a hook's clock. Claude Code allows 60s for a Stop hook and a cold
  daemon can already have eaten most of it, so a Retry-After that does not fit a
  6s budget is not honoured at all — waiting less than the server asked would
  just earn another 429, and waiting the full amount trades a deferred retain for
  a killed hook.

Anything that is not a rate limit still fails immediately, as before.

* feat(coding-agents): add maxParallelRetains config and 429-aware drain

* fix(coding-agents): cap the 429 backoff so a long Retry-After cannot park a drain

Review follow-up on the retry path.

`Retry-After` was honoured without a ceiling, so an hour-long value — an
incident, a misconfigured limiter, a proxy inventing one — would park the drain
for that hour, up to the whole `maxMs`, with the background seed frozen behind
it. The header is a server's hint, not a budget we owe it.

Capped at 60s. That keeps the signal (the floor and the header still lengthen the
wait) without handing over the schedule: if the limit still applies, the next
poll gets another 429 and backs off again.

Also re-syncs the docs page and skill mirror from the README, which the rebase
onto main left stale by a column width.

---------

Co-authored-by: David Eriksson <[email protected]>
2026-08-12 10:43:52 +02:00
Alan5168 c094ac27d2 fix(embed): define daemon stop() success by port occupancy, not the health probe (#3171)
* fix(embed): define daemon stop() success by port occupancy, not the health probe

stop() used is_running() -- a 2s /health probe -- both as its already-stopped
guard and as its final success condition. A daemon that is alive but busy
(slow provider call, model load) fails that probe, so 'daemon stop' returned
True without sending any signal, and a failed termination or missing PID
also fell through to a reported success.

Resolve the port from the profile and use occupancy for every decision:
already stopped only when nothing is bound; bound port with no findable PID
is a failure; a False from _kill_process() is a failure; after the kill,
success means the listener is gone. This mirrors the occupancy/health
separation _clear_port() already uses.

Closes #3169

* fix(embed): gate daemon stop on health identity, not just occupancy

koriyoshi2041 review: occupancy alone is not authorization to kill. An
unrelated service on the profile port would be SIGTERM'd by the previous
fix. Restore an identity check before signaling: _port_health_ok confirms
the listener answers like Hindsight (status/database in /health payload),
which is stricter than the old is_running() 200-only check.

A busy Hindsight daemon (the #3169 scenario) fails this probe, so stop()
now returns False rather than killing blind - a failed stop is recoverable,
an unknown kill is not. Port occupancy remains the success condition after
termination (koriyoshi endorsed this).

Adds test_foreign_listener_is_not_signaled (the regression koriyoshi asked
for) and updates the busy-daemon test to assert the new refuse-to-kill
behavior.

* fix(embed): drop the health-identity gate from daemon stop()

The gate refused to signal a listener that failed /health, but a busy
Hindsight daemon is exactly what fails that probe - the case #3169 is
about. It turned "claims success, kills nothing" into "reports failure,
kills nothing", leaving a wedged daemon unstoppable, and contradicted
_clear_port(), which reclaims the same port state on the start path.

stop() now decides on occupancy alone: port free means already stopped,
a bound port with no PID or a failed kill is a failure, and success is
the listener disappearing.

The two identity tests now assert the listener is signalled; one of them
also pins stop() and _clear_port() to the same policy for an occupied,
unhealthy port.
2026-08-12 10:31:28 +02:00
Nicolò Boschi 8f16473a6a fix: untrack the coding-agents node_modules symlink (#3422)
I committed this in #3380 and it has been on main since, in the v0.2.0 and
v0.2.1 tags: a symlink at hindsight-integrations/coding-agents/node_modules
pointing at an absolute path on my own machine. Anyone cloning gets a dangling
link where the package's node_modules belongs.

The published npm tarballs are unaffected — npm excludes node_modules from packs
— so this is repository hygiene, not a shipped defect.

.gitignore had `node_modules/`, which matches directories only. A symlink is a
file, so it slipped straight past — which is exactly how it got committed, since
pointing a scratch worktree at an already-installed node_modules is the fast way
to run this package's tests. Adding the slashless pattern closes that.
2026-08-12 10:15:59 +02:00
Nicolò Boschi 8391296af5 fix(recall): fill source_facts budget in rank order and flag truncation (#3221) (#3419) 2026-08-12 10:08:02 +02:00
9032ed9c95 feat(coding-agents): apply retain attribution to all ingestion paths (#3418)
* feat(coding-agents): apply retain attribution to all ingestion paths

* refactor(coding-agents): one retain call per survey marker, not two

Review follow-up. Both survey-baseline writes branched into two complete
client.retain(...) calls that differed only in whether `{ metadata }` was passed
— six duplicated argument lists between them, which is where a later edit updates
one and misses the other.

`retain` only assigns metadata when it is truthy, so `{ metadata: undefined }`
already omits it and one call covers both cases. That is what knowledge-tools.ts
does with `Object.keys(metadata).length ? { metadata } : {}`.

The marker assertion in session-start.test.ts gains the opts argument, since the
call now always passes one. Behaviour is unchanged: with no retainMetadata
configured the stamp is empty and nothing reaches the API.

---------

Co-authored-by: Reese <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-12 09:53:31 +02:00
Nicolò Boschi dca0025511 fix(consolidation): cancel sibling tag groups when a batch fails (#3417)
A DB failure inside one tag group's recall propagates out of
run_consolidation_job to the worker, which marks the operation failed and
re-queues it with a 5s base backoff. Plain asyncio.gather re-raises the
first exception but does NOT cancel its siblings, so the other tag groups
kept running detached: still calling the LLM, still stamping
mark_consolidated, still committing write-groups — after the operation had
already failed, and while the retry was starting.

The per-scope scope_locks are local to a single dispatch, so nothing
serialised an orphan against the retried run, and the "batches within a
group share a scope and MUST run serially" invariant that keeps two
consolidators out of the same observation scope was broken exactly when it
mattered.

Replace both consolidation gathers with _gather_or_cancel, which cancels
the outstanding tasks and awaits them before propagating the original
exception. Not asyncio.TaskGroup: it wraps failures in an ExceptionGroup,
and the worker's _is_non_retryable_task_error does isinstance checks — a
wrapped IntegrityConstraintViolationError would be retried forever.

Cancelling a batch mid-flight leaves its minted write-group undecided, so
also abort it explicitly on the failure path instead of leaving it pending
for the recovery sweep. The abort sits outside decide(commit=True): once
the witness has committed the batch's fate is settled.

The per-fact recall gather keeps failing its batch rather than degrading to
an empty candidate set — hiding an existing twin from the LLM would turn an
UPDATE into a duplicate CREATE. Those memories stay unconsolidated and are
picked up on retry.

Found while investigating #3301.
2026-08-12 09:53:07 +02:00
Nicolò Boschi 4fa25f47d2 feat(coding-agents)!: drop retainEveryTurns; batching belongs on the server (#3415)
The write-back cadence is removed. The persistent-plugin harnesses (opencode,
Kilo, Cline CLI) now write back every turn, which is what the default of 1
already did; the hook harnesses never consulted it at all.

Two reasons, and the second is what makes it a removal rather than a
documentation fix.

A client-side cadence holds turns in a process the host can close at any moment,
and there is no reliable signal on the way out: #2869 measured ZERO SessionEnd
retains across 36 hours because Claude Code cancels that hook at shutdown. The
flush a cadence needs cannot be built on the only event that would carry it, so
extending the setting to the seven hook harnesses was never available — and the
asymmetry that left (honoured by three of eleven) was itself the complaint.

The batching it approximated now happens server-side, where nothing can be
stranded: queued retains for one document fold into a single execution
(`engine.retain.fold`, #3395), on top of a claim predicate that serialises
same-document work. Submitting every turn therefore costs one extraction over
the concatenated turns rather than one per turn — the burst control #2143 asked
for, in the place that can do it without risking a turn.

`retainSessions` stays: opting out of write-back entirely is a different
question from how often it fires.

Removed from the config type, the resolver, HINDSIGHT_RETAIN_EVERY_TURNS, the
runtime check and its now-unread retainedUsers bookkeeping, the settings table,
and the tests. The other integrations' `retainEveryNTurns` is a separate setting
in separate packages and is untouched.
2026-08-12 08:10:54 +02:00
Nicolò Boschi 423e25db44 fix(search): stop extreme relative date offsets from crashing recall (#3217) (#3413)
Consolidation recalls with stored fact text as the query, so a phrase
like "十万年前" (100,000 years ago) hit unguarded offset arithmetic in
extract_period — which runs BEFORE analyze()'s dateparser guard — and
escaped as ValueError('year -97974 is out of range'), deterministically
failing every recall and consolidation touching the bank. The three
years observed in #3217 (-534, -974, -97974) are exactly
now.year - {2560, 3000, 100000}: query-time arithmetic, not stored rows
(Python datetimes can't represent them, so no bad date can reach the DB
through asyncpg in the first place).

Three layers, mirroring the #2636 add_years fix:

- extract_temporal_constraint (the recall choke point) degrades any
  analyzer failure to 'no temporal signal' with a warning; analyze()
  itself stays strict so parser bugs still surface in tests.
- chinese_temporal_periods: add_months/subtract_months are now
  bounds-checked like add_years (returning None, plumbed through every
  call site), and day/week offsets go through the overflow-guarded
  add_days instead of raw timedelta addition.
- temporal_periods: an explicit month + year 0000 match returns
  NO_TEMPORAL_CONSTRAINT instead of crashing datetime().
2026-08-12 08:09:56 +02:00
Nicolò Boschi 4e4b87b445 fix(knowledge): sync backing mental_model name on page rename (#3307) (#3407)
* fix(knowledge): sync backing mental_model name on page rename (#3307)

rename_knowledge_node updated knowledge_pages.name only, leaving the
backing mental_models.name stale. A page's searchable document is its
mental model's name + content, so after a rename search kept indexing the
old name (and the two names were not updated atomically).

Update mental_models.name in the same transaction as the node rename
(re-tokenizing search_vector for vchord; native regenerates, other
backends index base columns), so a knowledge_pages name-uniqueness
violation rolls both names back together. Folders (mental_model_id NULL)
are untouched.

Scopes #3307 down to this last remaining gap; roots 1-3, 5, 6 were
already fixed by #3318 and #3335.

* docs(skill): add generated agent-plugin integration page

Pre-existing drift from #3240: the agent-plugin docs source and
integrations.json entry were committed, but the generated docs-skill
mirror was never regenerated, so verify-generated-files was red on main.
Regenerated; only this one file is produced.
2026-08-12 08:01:27 +02:00
Nicolò Boschi aaa58f2472 fix(coding-agents): stop the prompt hook wiping the retain cursor; write state atomically (#3412)
Found porting #3136 (unlocked state read-modify-write on Windows). The race it
describes is far less severe here — state is one file per session rather than a
dict keyed by session, so a lost update cannot drop other sessions — but looking
for it surfaced a worse, unconditional bug underneath.

The retain cursor was a FIELD of the session cache, and the prompt hook writes a
fresh `{turns, reflectAnswer, pages}` object rather than merging. So every user
prompt dropped the cursor the previous Stop had written; the next Stop found
none and rewrote the whole document. The incremental write-back added in #3336
therefore never engaged past a session's first turn on ANY hook harness — seven
of eleven. Not a race: deterministic, every session, every turn.

  after Stop   : {"retain":{"turns":5,...}}
  after prompt : {"turns":2,"pages":{...}}     <- cursor gone
  cursor now   : undefined

No test caught it because the unit tests inject a memoryCursorStore directly and
never exercise the file store against a real prompt-hook write. The regression
test added here does exactly that interleaving.

The cursor now lives in its own file. That makes the invariant structural rather
than a convention every future writer has to remember: the two writers have
different lifecycles, no longer share a record, and neither can clobber the
other — which also leaves the concurrent read-modify-write #3136 measured with
nothing to lose here.

State writes are also atomic now (temp file + rename). A plain writeFileSync can
be observed half-written and leaves a fragment behind if the process is killed
mid-write; rename is atomic on POSIX and replaces on Windows. The per-agent
plugin's Python writes already had os.replace() — this had no equivalent.

Cannot be verified on Windows from here; the atomicity is platform-independent
and the clobber fix is verified on macOS.
2026-08-12 07:48:15 +02:00
Sanderhoff-alt 82859af02b docs(reflect): clarify tag-scoped directive behavior (#3038)
Explain how tags, tags_match, tag_groups, and directive isolation
interact across REST, MCP, versioned docs, and agent skills.

Clarify the different defaults used by reflect and directive listing,
then regenerate OpenAPI and supported client artifacts.
2026-08-12 07:41:47 +02:00
Nicolò Boschi e8b817fc6e perf(graph): decouple stale-cooccurrence prune from hub-entity degree (#3367) (#3408)
`prune_stale_cooccurrences` (PG) checked staleness with a correlated
`NOT EXISTS (… INTERSECT …)` evaluated once per cooccurrence row. Each
evaluation re-scanned a hub entity's full membership set, so cost scaled
as (cooccurrence rows) × (hub degree) — 88-140s on a real bank with a
~22K-degree hub (215s in a 12K-degree repro). #2473 had swapped an
earlier hub-rescanning self-join to that INTERSECT, but only made each
per-row check cheaper; it kept the per-row structure, so the product
resurfaced at scale.

Decide staleness against a SET of currently-live pairs built ONCE per
sweep: a `WITH live AS MATERIALIZED` unit-grouped self-join emits every
co-occurring (e1<e2) pair, its cost driven by unit degree (small) not
entity degree, and the victims anti-join hashes against it. `live` is
scoped to the bank's entities so a per-bank sweep stays O(bank), not
O(schema), across a multi-bank maintenance cycle. The #2529 ordered-lock
(`ORDER BY … FOR UPDATE OF c`) is preserved. Oracle already used a
set-based form and is unchanged.

Repro (12K-degree hub, 14K cooccurrence rows): 215,329ms -> ~250ms,
identical delete set. Added a regression test covering partial pruning
around a hub and bank-scoping isolation.
2026-08-12 07:33:46 +02:00
Nicolò Boschi 83a080f90a fix(retain): globalise memory_links lock order on the insert path (#3396) (#3406)
The deadlock #2570 targeted still fired a few times a day because the
insert-side lock ordering was only partial:

1. _bulk_insert_links sorted on (from, to) — two of the four columns in
   the unique index (from, to, link_type, COALESCE(entity_id, nil)). A
   temporal and a semantic edge on the same pair compared equal, so a
   stable sort left them in input order and concurrent inserts could take
   the two index entries in opposite orders.

2. That order also disagreed with chunk_storage.delete_chunks_by_ids,
   which normalises direction via LEAST/GREATEST. Two different total
   orders can still cycle.

Sort both paths on one canonical key — the full, direction-normalised
unique key — by extracting _lock_order_key and pointing the insert sort
at it. The delete side already uses exactly this order and is unchanged.
2026-08-12 07:33:45 +02:00
Nicolò Boschi d3f97da1e4 fix(coding-agents): resolve the project when the working directory is gone (supersedes #3110) (#3410)
* fix(coding-agents): resolve the project when the working directory is gone

Supersedes #3110, filed against the per-agent Claude Code plugin.

A hook runs after the fact, so the directory it reports can already be deleted —
an ephemeral worktree removed once the task finished, a checkout moved or deleted
mid-session. git can only answer about a path that exists, so the probe failed
and `basename` of the vanished path became the project identity: a throwaway name
like `agent-a33c4d63` that scatters memory into orphan banks.

Resolution now walks up to the nearest ancestor that still exists and probes
that. This is harness-agnostic on purpose: no harness has to export anything, so
it covers all eleven rather than the one that happens to publish a project-root
variable. For a live directory the walk returns it unchanged, so the common path
is untouched and no existing bank moves.

CLAUDE_PROJECT_DIR is kept as a last rescue for the one case the walk cannot
reach: a LINKED worktree is a sibling of the repository, not a child, so walking
up from it leaves the repository entirely. It is deliberately a list of one
rather than a guess at nine names — only Claude Code is known to export such a
variable, and inventing the others would register behaviour nothing implements.

Also stops project names from being empty. `basename("/")` is "", which produced
bank ids like `coding-agent::` naming nothing; both `{gitProject}` and
`{project}` now fall back to "unknown". That case was flagged reviewing #3286 and
never fixed.

Tests use real git against real directories rather than the mocked
child_process of bank.test.ts, since what is under test is behaviour against
paths that do and do not exist.

* chore(docs): restore the agent-plugin skill mirror

Generated artifact missing on main: #3394 added
hindsight-docs/docs-integrations/agent-plugin.md without regenerating the docs
skill, so skills/.../integrations/agent-plugin.md was never committed.

verify-generated-files only runs on PRs, so the drift is invisible on main and
surfaces as a failure on the next unrelated PR — this one. Separate commit
because it is not part of the project-resolution fix.
2026-08-12 07:33:25 +02:00
ba365c723b fix(compose): use HINDSIGHT_API_LLM_API_KEY env var (#3398)
* fix(compose): use HINDSIGHT_API_LLM_API_KEY env var

* docs(compose): point run examples at HINDSIGHT_API_LLM_API_KEY

The compose files no longer read OPENAI_API_KEY, so every doc telling
users to export it was left describing a variable nothing reads.

- custom-models/README.md + compose header: export the correct var
- timescale/README.md quick start, prereq and env-var table
- timescale/.env.example: compose's project directory is the compose
  file's own directory, so this file IS auto-loaded - naming the wrong
  var here silently dropped the key on the documented happy path

---------

Co-authored-by: Ish Fuseini <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-12 07:16:19 +02:00
BenandNicolò Boschi 29cd6c6ab0 feat(coding-agents): add Prime Agent as a supported harness (#3240)
* feat(coding-agents): add Prime Agent as a supported harness

Adds Prime Agent (PrimeIntellect) as a plugin harness in
@vectorize-io/hindsight-coding-agents, giving it the shared reflect-and-inject
core (auto-seed, knowledge pages, attribution, per-repo bank naming) like
opencode/cline. A Prime Agent extension entry (src/prime-agent.ts) wires
before_agent_start -> recall + system-prompt injection and agent_end ->
transcript write-back onto RuntimeCore, and registers the hindsight_* knowledge
tools natively via pi.registerTool (Zod raw shape -> JSON Schema; Prime Agent
forwards it to the model provider). Installer registers the built extension in
~/.prime/agent/settings.json; registry lists it for backfill. Includes a
transcript normalizer, unit tests for the hooks/tool adapter/converter/installer,
a README entry, and the synced docs page.

Supersedes the standalone @vectorize-io/hindsight-prime-agent package.

* fix(coding-agents): register Prime Agent in the control plane, wire its E2E, pass the workspace

Follow-ups from review, on top of a rebase onto main (the branch was 66 commits
behind and predates the append write-back, bounded transcript reads, the retain
stamp and both 0.2.x releases).

Control-plane registry. CLAUDE.md requires a new harness to land its logo entry
in the same change, and this one didn't: documents retained by Prime Agent carry
metadata.harness = "prime-agent" and a harness:prime-agent tag, which the
documents list resolves a logo from, so every one of them rendered as bare
metadata. Verified against a real local run before fixing. Neither guardrail
catches this — the parity test's EMITTED_HARNESSES is a hand-maintained list, and
it lives in the control plane, so `detect-changes` never runs it for a
coding-agents-only PR. Added to the registry, the icon copied into
public/img/harness, and the id added to that list so it is covered from now on.
The mark is dark line art, so invertOnDark like the other monochrome ones.

E2E. Every other harness has a Docker E2E entry; this one had none. Added
Dockerfile.prime-agent and a setup entry, so it joins the roster that installs
the CLI, seeds a bank with a decision the prompt never mentions, and requires the
agent to carry it back out. It skips cleanly without credentials, like the rest.
Prime Agent ships no npm package — the upstream repo is a private monorepo — so
the image uses the vendor installer and puts ~/.local/bin on PATH.

createRuntime passed four arguments to RuntimeCore, dropping the workspace
directory added in #3346. Harmless today because repoPath is process.cwd(), but
{gitProject} and {project} in retainTags/retainMetadata would silently resolve
against the wrong directory the moment those diverge; plugin-entry and cline both
pass it explicitly.

Regenerated the docs skill mirror, which verify-generated-files was failing on.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-12 07:03:48 +02:00
Vitor Cepeda Lopes 490cc52432 fix(coding-agents): bound automatic reflect budget (#3364) 2026-08-12 06:45:17 +02:00
Nicolò Boschi 07aec3b95d fix(reflect): split synthesis — never drop retrieved evidence on forced synthesis (#3392)
When reflect is forced to answer without tools (context guard, last
iteration, LLM error, clean stop) and the accumulated tool results exceed
the prompt budget, build_final_prompt dropped any over-budget block whole —
plus every older one. The synthesis model then saw an empty Retrieved Data
section and answered a confident 'I don't have information' while the
response attached every retrieved citation (#3122). Whether anything
survived depended on whether a small-enough block happened to be newest.

Now the history is split, not truncated: budget-sized chunks (block-boundary
greedy packing; an over-budget block splits on result-entry boundaries) are
each compressed by a parallel LLM call into dated, cited claims, and one
reduce call synthesizes the answer from every chunk's claims. Claims carry
mentioned_at + memory ids so the reduce call can apply the
latest-statement-wins supersession rule across chunks — conflicting facts
may land in different chunks. Only an indivisible entry larger than the
whole budget (e.g. one giant document expand) is token-cut.

When everything fits — the overwhelming majority of reflects — the path is
byte-identical to before: one final call, same prompt. The four duplicated
forced-synthesis bodies in the agent loop collapse into one helper.

Closes #3122.
2026-08-12 06:42:41 +02:00
github-actions[bot] 1171ca276a chore: update star history 2026-08-12 03:59:49 +00:00
Ben 96bd69c7bd blog(oss-memory): update date to 2026-08-11 (#3399) 2026-08-11 14:17:03 -04:00
BenandClaude Opus 4.8 5781d28d8f blog: Best Open-Source Agent Memory Systems (Self-Hosted, 2026) (#3192)
* blog: Best Open-Source Agent Memory Systems (Self-Hosted, 2026)

A fair, benchmark-grounded comparison of the self-hostable open-source agent
memory systems (Hindsight, Mem0, Graphiti/Zep, Letta, Cognee): comparison
table, per-system profiles, "what self-hosted really costs", FAQ, and a
"when to pick each" guide. Every stat/claim verified against live sources
(GitHub stars, licenses, Zep CE deprecation, benchmarks). Swiss-type cover.

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

* blog(oss-memory): refresh GitHub star counts to current

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-11 13:53:52 -04:00
Nicolò Boschi dbe0ffb989 fix(stats): drop permanently failed memories from pending_consolidation (#3362) (#3397)
`pending_consolidation` counted every fact with `consolidated_at IS NULL`,
including the ones stamped `consolidation_failed_at` that the consolidator's
own candidate query (`reads.find_unconsolidated`) excludes on purpose. The
gauge therefore had a floor no amount of work could clear: it sat above
`?consolidation_state=pending` by exactly `failed_consolidation`, and an
operator could not tell a real backlog from an abandoned residue.

`pending` now carries the consolidator's predicate, so the two buckets are
disjoint and a bank with no live backlog reaches zero. The same predicate was
missing in five more places:

- `get_bank_stats` keeps a second copy of the freshness SQL for the
  `writes_memory_rows_in_sql` path — fixing only `counts.py` would have left
  the default Postgres path wrong.
- `hindsight.consolidation.backlog` had the same floor, which made
  "backlog > 0 for N minutes" unalertable on any bank holding a residue.
- reflect's `tool_search_observations` derives `is_stale` / `freshness` from
  this count, so a residue told the model the observations were stale on every
  call, for ever (fixed transitively via `get_bank_freshness`).
- the control plane's consolidation card computed `done = total - pending`,
  which would have counted the failed rows as done once pending got strict.
- the benchmark runner waits for this count to reach 0, so one permanently
  failed fact burned the full 3000s timeout.

Everything else that answers "what is left to consolidate" already excluded
them: the memories list filter, `count_unconsolidated`, and the
`banks_needing_consolidation()` maintenance routine — so scheduling was never
spinning on the residue.
2026-08-11 18:23:33 +02:00
Nicolò Boschi 11a4eda803 fix(retain): stop concurrent appends to one document from losing turns (#3395)
## The bug

`update_mode="append"` is a read-modify-write over the whole document: the retain reads `documents.original_text`, concatenates the new content onto it, and reprocesses the result — with LLM extraction sitting between the read and the write. Nothing made that atomic, so concurrent appends to one document raced, and the losers were dropped **silently**, with every caller getting a success.

Reproduced on `main` — three parallel appends after a seeded turn:

```
outcomes:      ['t2:ok', 't3:ok', 't4:ok']          ← all three "succeeded"
document:      TURN_ONE ... TURN_FOUR               ← TWO and THREE gone
memory_units:  TURN_ONE, TURN_FOUR                  ← their units cascade-deleted too
```

This is now the standard integration shape: a session gets one stable `document_id` and each turn is appended from an independent process (see `openclaw`'s session-scoped document, `index.ts`), plus queue flushes replaying buffered turns.

Every existing guard was written for *replace* semantics, where "someone newer won, drop mine" is correct. For append it is data loss — the dropped turn is content nobody else has.

## The fix

Three layers, each carrying a different guarantee.

**1. Append compare-and-swap** — correctness. The append records the `content_hash` it built on and verifies it under the document row lock, before the write that establishes ownership. A moved base raises `ConcurrentAppendConflict`; the append is redone on the newer text (3 attempts, jittered) rather than committing over the winner. The paths that used to discard content — the stale-request skip and the streaming takeover — now raise for appends and are untouched for replace.

**2. Per-document claim serialization** — avoids the conflict rather than paying for it. New `async_operations.serialization_key` plus `document_serialization_sql`, the same predicate shape `graph_maintenance_bank_serialization_sql` already uses per bank. One in-flight retain per document; a waiting operation holds **no worker slot** (it simply isn't claimed), and different documents stay fully parallel — so a 12-worker fleet keeps saturating.

**3. Claim-time coalescing** — performance. A backlog for one document is claimed and run as a single execution, so a client flushing 50 buffered turns costs one pass instead of 50 sequential ones.

The coalescing is deliberately shaped to be low-risk:

- It folds **at claim time, over immutable rows** the claim transaction already holds `FOR UPDATE` — it never rewrites a pending operation's `task_payload`. The submit-time alternative races the worker reading that row and would reintroduce the very lost-update bug being fixed.
- **Operation identity survives.** Each submission keeps its own `operation_id`, status and post-retain hook, so nothing downstream of the queue — polling, webhooks, cancellation, metering — has to learn about folding.
- It is a **pure optimization**: delete it and the system is still correct, only slower. Layers 1 and 2 carry correctness independently, so a folding bug can cost latency or LLM spend but cannot lose a turn.

Fold width halves per `retry_count`, so a poisonous turn converges on running — and failing — alone instead of holding good turns hostage.

## Fold eligibility

Folding is sound only for **appends**, because only appends are cumulative: running two as one execution over the concatenated turns gives the same document as running them in sequence. Replace means "this body supersedes what is stored", so a fold must never take one — two folded replaces would store `body1 + body2` where the answer is `body2`, and an append folded behind a replace would turn a document-wiping submission into a concatenation. Anything that is not append-only runs alone, as primary and as peer (including items with no `update_mode`, since the default is replace).

A peer also has to match the primary on everything the execution applies **once** from the primary's payload — tenant, API key, `document_tags` (compared order-insensitively), `strategy` — or folding would apply the primary's value to the peer's content and silently drop the peer's. File-backed retains are excluded on both sides: a converted upload attaches its storage key afterwards, and that step only fires for a single-item retain.

A rejected peer defers everything behind it. Taking a later peer past a rejected one would commit turns out of order.

## Post-retain hooks

A folded execution fires the hook **once per member**, in submission order, each with its own content slice and a `folded_with` list. The execution's usage lands on the first member and zero on the rest, so `sum(llm_total_tokens)` across a fold is exactly what the execution spent — the same total the callers would have seen retaining one at a time. Asserted directly in `test_folded_execution_reports_one_hook_per_operation`.

## Also fixed

`_run_delta_db_work` signalled its concurrency abort with a bare `return None` from a function declared `-> None`, and the caller discarded the value. The delta concurrency guard has therefore been logging *"aborting delta, falling back to full retain"* while actually committing on top of the concurrent writer. Distinct latent bug, found while tracing this one.

## Relationship to #3363 / #3386

#3363 said of the queued path: *"Keep the guard there, or serialise children per document first."* Layer 2 is that serialization. #3386 (now on main) made the sync path fold shared `document_id` items; a claim-time fold hands `retain_batch_async` exactly that shape, so this rides #3386's grouping rather than duplicating it. The queued-path submit guard is left as-is — out of scope here.

## Tests

- `test_concurrent_appends_keep_every_turn` — the regression test; fails on `main`, passes here.
- Claim predicate and fold, deterministic at the queue layer (no LLM): one retain per document, no cross-document serialization, waits for a processing peer, folds in submission order, every member claimed.
- Fold planner as pure functions: submission order, token budget, never skipping a turn to reach a later one, `max_peers`, retry narrowing.
- Hook totals, folded and unfolded.

303 passed across the retain / delta / worker / batch / extensions / file-retain / backup-restore suites on a clean database. `lint.sh` and `ty` clean.

## Notes for review

- The CAS is scoped to the first sub-batch (where the append read happens). A multi-sub-batch append that loses later still raises rather than dropping — it just fails the operation and relies on the worker's retry instead of the in-process redo.
- The fold is capped by `retain_batch_tokens` so a folded execution stays within a single orchestrator pass; splitting one document across differently-bodied sub-batches trips the streaming ownership check (#3282).
- A peer wedged in `processing` holds its document until claim recovery releases it — the same caveat the graph-maintenance predicate already carries, not new here.
2026-08-11 18:22:05 +02:00
Ben a133cc1495 feat(agent-plugin): add portable Hindsight plugin for the Agent Plugins standard (#3394)
Add a vendor-neutral Hindsight plugin conforming to Vercel's Agent Plugins
1.0.0 standard (plugin.json + mcp.json + skills/SKILL.md), so one artifact
gives long-term memory to any compatible client (Codex, Cursor, GitHub
Copilot, Kiro, VS Code) instead of a per-IDE integration. The plugin is a
thin transport wrapper over Hindsight's existing MCP server (retain / recall
/ reflect); a bundled skill teaches the agent when to use it.

Wiring:
- CI: test-agent-plugin-integration job runs the manifest validator, gated on
  hindsight-integrations/agent-plugin/** changes.
- Docs: integrations.json gallery entry + docs-integrations/agent-plugin.md.
- Release: agent-plugin added to release-integration.sh and the changelog
  generator; both learn to read a root-level plugin.json and link the
  changelog to the source tree (git-distributed bundle, no registry package).
2026-08-11 18:06:17 +02:00
Nicolò Boschi 25c7cd0449 fix(retain): match chunk-delete link endpoints through indexable joins (#3387) (#3393)
* fix(retain): match chunk-delete link endpoints through indexable joins (#3387)

The ordered memory_links pre-delete in delete_chunks_by_ids matched link
endpoints with `tu.id = ml.from_unit_id OR tu.id = ml.to_unit_id`. An OR
spanning two columns of ml cannot be driven from either endpoint index, so
PostgreSQL made memory_links the outer relation of a nested-loop semi join
and sequentially scanned the whole table on every delete — O(links x units),
with no bank_id predicate, so every bank scanned every other bank's rows.

Past a few million links that exceeded the asyncpg command timeout and delta
retain failed with a bare TimeoutError (str(TimeoutError()) is empty, so it
logged as "Task execution failed: batch_retain, error:" with nothing after).

Splitting the OR into a UNION of two single-column joins makes each half an
index scan. Measured on PG18 with the current index set, 300k units /
1.5M links, deleting the links of 3 chunks (150 units):

  before  20,420 ms   Seq Scan, 224.9M rows removed by join filter
  after       31 ms   two index scans, same 1,464 rows

10 chunks took 49.8s before — already over the 60s default at a table size
well below production. The row set is identical (EXCEPT in both directions
returns nothing), and the deterministic ORDER BY + FOR UPDATE that #2570
added stay in ordered_links, which still locks the rows in that order.

* test(memories): update store doubles for the per-bank capability API

#3388 moved the store capability to writes_memory_rows_in_sql_for(bank_id) and
added drop_bank_storage, but two duck-typed test doubles still carried the old
surface, so test-api shard 2 has been red on main since it merged:

  test_integrity_violation_not_retried — SimpleNamespace(writes_memory_rows_in_sql=True)
    -> AttributeError: no attribute 'writes_memory_rows_in_sql_for'
  test_list_banks_non_sql_store._NonSqlStore — teardown's delete_bank routes the
    drop through the store for a non-SQL bank
    -> AttributeError: no attribute 'drop_bank_storage'

Both doubles are hand-rolled rather than subclasses of the store base, which is
why the refactor could not update them mechanically.
2026-08-11 17:20:18 +02:00
Nicolò Boschi 1d0dcbce25 fix(reflect): decouple page max_tokens from the provider output cap (#3365) (#3389)
On thinking models the provider's output budget covers reasoning tokens plus
visible output, so passing the reflect/mental-model `max_tokens` straight through
as `max_completion_tokens` let reasoning consume the whole budget and truncated
the page mid-word — silently, since the call still returned success.

Treat `max_tokens` as what it means to a user: a *visible* page-length target.
It is now communicated to the model as a prompt directive and enforced by the
existing post-hoc rewrite, never as a hard cap on the synthesis call. The
transport-level cost cap is a separate, uncapped-by-default config
(`HINDSIGHT_API_REFLECT_MAX_COMPLETION_TOKENS`) so reasoning never starves the
answer. This fixes the truncation for every provider without model-specific
detection.

Also surface `finishReason == MAX_TOKENS` on Gemini as a warning instead of
returning half-written text as a silent success.

- config: add `reflect_max_completion_tokens` (None = uncapped)
- reflect: synthesis + rewrite use the config cap, not the page budget;
  `build_final_prompt` carries the length directive
- gemini: log a truncation warning on a non-empty MAX_TOKENS response
- docs: models.mdx reasoning/`max_tokens` note + configuration.md entry
- tests: prompt directive, config default/override, uncapped-by-default
  synthesis, config-cap override, Gemini truncation warning
2026-08-11 17:18:04 +02:00
Nicolò Boschi 475895f0a2 fix(retain): fold shared document_id items on the sync path (#3363) (#3386)
The sync retain batch endpoint rejected any batch whose items shared a
document_id, contradicting the RetainRequest schema/example, the
MemoryItem.document_id docs, and the SDK's batch-level documentId (which
inlines one id into every item). The guard existed to avoid a race, but
that race is only real on the queued path, where children fan out to
parallel workers. The synchronous path processes sub-batches sequentially.

retain_batch_async now folds items sharing an explicit document_id into
one document, in request order, running each document in a single
orchestrator pass. A single pass is required: splitting one document
across sub-batches that carry different bodies trips the streaming
pipeline's content-hash ownership check and silently drops later
sub-batches. Batches with no shared document_id are unchanged.

The queued path (submit_async_retain) keeps the guard, with a message
that points clients at async=false for folding.
2026-08-11 16:17:56 +02:00
Nicolò Boschi efc179f715 feat(memories): per-bank store capabilities on main (#3388)
Re-applies the per-bank store-capability seam onto current main. The pluggable
memories backend (#2917) is on main, but the per-bank capabilities landed later
on feat/pluggable-memories-provider (#3350, plus fix #3381) while main advanced
~179 commits.

A pluggable memories store may keep memory rows outside SQL and/or own the
document store. The process-level flags writes_memory_rows_in_sql /
owns_document_store gain per-bank forms — writes_memory_rows_in_sql_for(bank_id)
and owns_document_store_for(bank_id), defaulting to the class attrs — and every
bank-scoped call site in memory_engine, consolidation/consolidator, retain/* and
reflect/tools consults the per-bank form. Process-level maintenance gates keep
reading the class attr.

Also two NameError fixes of the same class (a bare bank_id where the in-scope
variable differs): list_banks (row["bank_id"], originally #3381) and get_chunk
(chunk["bank_id"], newly surfaced by pyflakes while rebasing).

Conflict resolution: only consolidation/consolidator.py conflicted — main added
consolidation sites since the branch; all are bank_id-scoped, so all convert to
the per-bank form.

Validation: pyflakes on all changed engine files reports 0 undefined names;
py_compile clean; per-bank + list_banks unit tests included.
2026-08-11 16:00:23 +02:00
Nicolò Boschi 7b35d2c6f2 feat(llm): opt-in forced-tool structured output for LiteLLM providers (#3300) (#3382)
* feat(llm): opt-in forced-tool structured output for LiteLLM providers (#3300)

Bedrock Claude rejects the structured-output route Hindsight uses. LiteLLM sends
a well-formed Converse `outputConfig`; Bedrock's Anthropic layer rewrites it to
snake_case internally and its own validator then refuses the key:

    BedrockException - {"message": "The model returned the following errors:
    output_config.format: Extra inputs are not permitted"}

Every `LiteLLMLLM.call()` with a `response_format` fails on that provider, so
retain returns 500 and consolidation degrades to "skipping batch" with no API
error at all. Reflect is unaffected because it goes through `call_with_tools()`,
which emits `toolConfig` — and Bedrock accepts that. The reporter's boto3 repro
isolates it to the transport, not the schema: the same trivial schema fails via
`outputConfig` and succeeds via `toolConfig`.

This is a different failure from the two earlier Bedrock schema fixes (#1289
`minimum`/`maximum`, #2500 `maxItems`). Those leaked one unsupported keyword into
an otherwise-accepted request and were fixed by not emitting it; here the whole
`response_format` route is refused, so no amount of schema sanitizing helps.

HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL (default false) makes the
LiteLLM-backed providers — litellm, litellmrouter, bedrock — ask for structured
output the way `anthropic_llm.py` already does natively: one tool whose
parameters are the response schema, forced via tool_choice. The tool call's
arguments are substituted for the message content before the existing parse
block, so markdown-stripping, `parse_llm_json` repair, retries, usage accounting
and tracing are untouched. If the model answers without calling the tool — a
gateway that drops tool_choice — the text is parsed as before.

Default false because every other LiteLLM backend handles `response_format`
natively; this only pays off where the backend refuses it.

* docs(llm): record the verified Bedrock behaviour behind the forced-tool flag

Reproduced on a real AWS account. In ap-southeast-2 with the au.* inference
profile, raw boto3 converse (no litellm at all) refuses the outputConfig
structured-output route while accepting the identical schema via toolConfig; the
same model in us-east-1 with us.* accepts both. So this is region/inference-profile
dependent, not "Bedrock Claude is broken" — which is why the flag stays opt-in
rather than being keyed off the provider.

Also: the rejected key comes back as `model: Extra inputs are not permitted`, not
the `output_config.format:` the issue quotes. Same validator and signature, but
operators grepping for that exact string would not find it, so the docs now name
the behaviour instead of the key.
2026-08-11 14:44:16 +02:00
Nicolò Boschi d4ac97d643 docs: correct the Azure OpenAI base URL (#3385)
Reported in #3377 and verified against a live Azure OpenAI resource.

The OpenAI-compatible tip told users to point HINDSIGHT_API_LLM_BASE_URL at
"your provider's endpoint", which for Azure reads as the resource root -- and
Azure does not serve the API there, so it returns 404 Resource not found.
Measured against a real resource (gpt-5-mini deployment):

  https://<res>.openai.azure.com                              404
  https://<res>.openai.azure.com/openai/deployments/<dep>     404 (no api-version)
  https://<res>.openai.azure.com/openai/v1                    works
  .../openai/deployments/<dep>?api-version=2025-01-01-preview works

Adds an Azure OpenAI Setup section with both working shapes and the three
things that actually bite: the model is the *deployment* name, the key is the
resource key (an APIM subscription key is a different setup), and gateways
must preserve the path shape.

Also records that Azure accepts the prompt_cache_key field sent under
cache_affinity=auto (#3271) on every api-version from 2024-02-01 onward, so
that default needs no Azure carve-out -- an explicitly untested risk when
#3271 merged, now closed.
2026-08-11 13:45:51 +02:00
Nicolò Boschi a2b018dce7 fix(retain): sweep observations when delta retain deletes chunks (#3384)
Delta retain drops a document's outgoing facts by deleting their chunks and
letting the FK cascade take the memory_units with them. Nothing swept the
observations derived from those facts: the sweep lives in
handle_document_tracking, which only the full-replace path calls. Every
re-ingest that took the delta path — a small edit to an existing document,
exactly what delta retain is for — therefore left the observations of the
changed chunks behind, still valid and still recallable, pointing at
source_memory_ids that no longer resolved.

Those rows were unreachable afterwards: consolidation batches are built from
facts, so an observation whose sources are all gone is never selected into a
batch again, and no runtime path deletes it.

Sweep in delete_chunks_by_ids, before the cascade, so the invariant holds at
the choke point rather than at one call site. It returns the number it
invalidated and the delta log line now carries that count unconditionally —
"the sweep matched nothing" and "the sweep never ran" were indistinguishable
from the outside, which is what made this hard to diagnose.

The sweep is keyed on the deleted chunks, not the document, so an edit to one
chunk leaves the other chunks' observations alone instead of requeueing the
whole document for consolidation.

Supersedes #3302. Reported and diagnosed by @fhiltscher.

Fixes #3294.
2026-08-11 13:26:41 +02:00
Nicolò Boschi f37cb0c799 release(coding-agents): v0.2.1 2026-08-11 13:13:19 +02:00
Parafee41andNicolò Boschi 78f1a0ef0c fix deletion of failed document uploads (#3366)
* fix deletion of failed document uploads

* fix(control-plane): swallow delete errors on failed upload rows

deleteFailedUpload only had try/finally, but fetchApi both toasts and
rethrows, and the handler is invoked from onClick without being awaited —
a failed delete left an unhandled promise rejection. Match the sibling
handlers in bank-operations-view, which catch and rely on the API client
interceptor for the user-facing error.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-11 13:09:50 +02:00
Nicolò Boschi f103949333 fix(coding-agents): drop Claude Code's compaction summary from retained turns (#3383)
Found investigating #3379, which asked for a PreCompact hook to avoid losing
context at compaction. The premise does not hold — compaction APPENDS a summary
record and leaves every earlier record in the transcript (verified on a real
11,144-record session compacted twice: 3,312 records precede the first marker and
are still there), and Stop fires after every assistant response, so the content
was already retained before compaction ran.

The investigation turned up the opposite problem. That summary record is a plain
type:"user" line with no isMeta flag, so nothing filtered it and Claude Code's
machine-written recap was retained as something the user said: 29 records /
474,016 chars across local transcripts, averaging 16KB each. Worse than
misattribution, it summarises turns ALREADY retained, so the same decisions get
extracted a second time from the recap.

Dropped alongside isMeta and isSidechain. Verified against the session that
surfaced it: 2886 -> 2884 turns, exactly the two summaries gone, every other turn
byte-identical.

Also corrects retain-cursor.ts, which cited Claude Code compaction as an example
of a transcript being rewritten rather than extended. It is not: the prefix stays
intact, so the append path keeps working through a compaction. The guard is
unchanged — only the example it named was wrong.
2026-08-11 13:04:21 +02:00
Nicolò Boschi d7c33fdeaf fix(transfer): mint a fresh internal_id on whole-bank import (#3270) (#3353)
Exporting a bank and importing it back into a new id on the same instance
failed with a ForeignKeyViolationError on mental_models.bank_id.

The banks row carries a globally-unique internal_id (banks_internal_id_unique,
used only for per-bank index naming). Import remapped only bank_id, so the copy
inherited the source bank's internal_id. On a same-instance re-import the source
row is still present, so the banks INSERT hit the unique constraint and
ON CONFLICT DO NOTHING silently skipped the parent row - then every child
(mental_models, directives, webhooks) tripped its bank_id foreign key.

Drop internal_id from the banks row before restore so the column DEFAULT
(gen_random_uuid) mints a fresh one. It is a local identifier that nothing in
the archive references, and create_bank_vector_indexes already reads it back
from the DB after insert. Cross-instance migration was unaffected (random UUIDs
don't collide); only the same-instance copy flow broke.

Adds a regression test that imports a bank (with a mental model) into a new id
without deleting the source and asserts the copy lands with a fresh internal_id.
2026-08-11 13:00:35 +02:00
Nicolò Boschi 4d9d31862e release(coding-agents): v0.2.0 2026-08-11 12:50:26 +02:00
Nicolò Boschi 291d2b1d9d docs(coding-agents): say which harnesses retainEveryTurns applies to (#3380)
It has exactly one consumer, RuntimeCore — the persistent-plugin harnesses
(opencode, Kilo, Cline CLI), which stay loaded and therefore choose when to write
back. The hook harnesses ignore it, and not by oversight: the host decides when
they run, and each Stop is a fresh process with no memory of how many turns have
passed.

Neither the config comment nor the settings table said so. Its neighbour
retainSessions does spell out the same split ("hook harnesses always write on
Stop"), so the silence read as "applies everywhere" — a Claude Code user setting
retainEveryTurns: 5 gets no effect and no warning. That is the workaround #2143's
reporter reached for, so the gap was load-bearing.

The README also said "opencode", which undersold it: Kilo and Cline CLI share
that runtime.
2026-08-11 12:44:18 +02:00
Nicolò Boschi 3b42913a64 fix(coding-agents): drop harness transport wrappers from retained turns (#3378)
Closes #3023.

Claude Code delivers <task-notification> as an ordinary type:"user" message —
string body, no isMeta flag — so nothing filtered it and fact extraction saw the
harness's background-task plumbing (task id, tool-use id, status, summary) as
something the user said. Measured across 400 local transcripts: 39 such messages,
16,289 chars, every one of them the entire message.

Reproduced with the real reader on a real transcript (243 retained turns, 1 of
them a task-notification carrying 519 chars of transport), and verified after:
242 turns, 0 noise, every genuine turn preserved.

The tag joins MEMORY_TAG_RE, which already covered codex's <hook_prompt> for
exactly this reason. <system-reminder> joins it too: today those only ride inside
tool_result blocks, which this reader drops entirely, so it is insurance against
the harness moving them — the rule is tag-structural, not content-guessing.

Note what the issue asked for and this does NOT do. Its two named cases are
already handled here: skill bodies arrive with isMeta:true (8/8 in the sample)
and are dropped with every other meta line, and <system-reminder> is inside a
dropped tool_result. Only the third case was live.

Stripping removes the BLOCK and keeps surrounding text, so a message that is
nothing but a wrapper renders empty and is dropped as a no-content turn, while a
real message mentioning one keeps the user's words — the mistake the old plugin's
unanchored strip_channel_envelope made (#3124).
2026-08-11 12:19:55 +02:00
github-actions[bot] a131622e63 chore: update star history 2026-08-11 03:55:26 +00:00
Ben 899c07e6c4 release(obsidian): v0.2.1 2026-08-10 15:19:42 -04:00
Ben ea81cf3450 fix(obsidian): scope sync index to bank + API target (#3257) (#3354)
hindsight-obsidian-sync keyed its sync index only on the vault name
(~/.hindsight/obsidian/<vault>.json) with a {syncIndex, lastSyncAt}
envelope carrying no destination identity. Pointing the CLI at a
different --bank or --api-url while reusing the default index made it
treat files synced to the old target as "unchanged", silently leaving
the new bank incomplete and potentially issuing prune DELETEs against a
bank it never wrote to.

Bind the index to its destination identity (canonical API origin, bank,
resolved vault path, vault name, prefix-doc-id):

- defaultIndexPath is now target-scoped: <vault>-<bank>-<fingerprint>.json,
  so different targets never share a default file.
- The envelope records the identity; loadIndex fails closed with an
  actionable IndexIdentityError (naming the changed field) when the
  persisted destination differs, and refuses legacy indexes with no
  identity metadata rather than silently trusting them.

Include/exclude scope is deliberately not bound: on the same destination
narrowing scope legitimately reuses the index and prunes newly-excluded
notes it owns there. Every harm in the issue requires a destination
change, which is what this refuses.

Adds regression tests for cross-bank/cross-API refusal end-to-end,
per-field mismatch, legacy-index refusal, target-scoped default paths,
and canonicalApiOrigin credential/path stripping.
2026-08-10 15:16:19 -04:00
Nicolò Boschi 00b520e592 feat(transfer): async document export (#3321) (#3340)
* feat(transfer): async document export (issue #3321)

The synchronous GET /banks/{id}/document-transfer loaded the whole bank
into memory, held a DB connection for the full request, and blocked the
event loop building the ZIP — enough to take down the shared API on a
large bank.

Make export asynchronous, mirroring the already-async import path:
- new document_export operation: submit_export_documents_async enqueues
  it; the worker builds the archive, stores it in file storage, and
  records download_url/storage_key/byte_size in result_metadata
- POST /banks/{id}/document-transfer/export (202 + operation_id)
- the sync GET is removed -> 410, pointing at the async endpoint
- GET /v1/default/files/download/{key} streams the archive; retrieval +
  bank authorization live in MemoryEngine.retrieve_bank_file (IDOR guard)

Harden export_documents: batch the entity/causal attach ANY() queries
instead of passing hundreds of thousands of UUIDs at once, and move ZIP
assembly off the event loop with anyio.to_thread.

Regenerate all SDKs; add blocking export_documents convenience helpers to
the Python + TS wrappers (submit -> poll -> download), fetching the
server-provided download_url to avoid %2F path-encoding. Update the
control-plane proxy to orchestrate the async flow and the docs.

* refactor(transfer): name the export op export_documents; surface it in the CP

- rename the async operation/task type document_export -> export_documents
  (and _handle_document_export -> _handle_export_documents) so it mirrors the
  import_documents operation
- control plane: add export_documents + import_documents to the operations
  type filter and localize both (operationType.exportDocuments/importDocuments
  across all 10 locales) — previously neither appeared in the filter and both
  rendered as the raw task_type string

* feat(transfer): clean up export archives with their operation + add download button

Export archives were stored in file storage but never deleted, so they
outlived their operation: the retention sweep prunes the async_operations
row but left the blob orphaned, and a user delete didn't remove it either.

Tie the archive to its operation record:
- delete_operation now deletes the export archive along with the row
- the retention sweep purges export archives (matching prune's terminal +
  updated_at < cutoff predicate) before pruning the rows

So an export is retained exactly as long as its operation — indefinitely by
default, or until HINDSIGHT_API_OPERATION_RETENTION_DAYS prunes it.

Control plane:
- add a Download button to the export operation's detail dialog (streams the
  archive through a new /api/files/download proxy, SSRF-guarded to the
  file-download path) + localize the label across all 10 locales

* chore(docs-skill): regenerate references for export retention note

* chore(cli): skip new export/download ops in CLI OpenAPI coverage

export_documents_sync_removed (the 410 stub) and download_file are
served via the API/control plane, not the end-user Rust CLI.

* fix(transfer): register export_documents slot config + fix cleanup-sweep tests

- add export_documents to WORKER_SLOT_TYPE_DEFAULTS (every operation_type
  used in memory_engine must be listed there — enforced by test_worker)
- stub engine.purge_expired_export_archives in the operation-cleanup test
  mocks (the sweep now calls it before pruning each schema)

* test(transfer): make export-archive purge test xdist-safe

purge_expired_export_archives is schema-wide, and CI shares the schema
across xdist workers, so a future cutoff purged other concurrent tests'
fresh archives (flaky count + cross-test interference). Backdate this op
and use a past cutoff so it targets only itself; assert purged >= 1.
2026-08-10 17:54:55 +02:00
Nicolò Boschi 35ab0b8162 perf(docs): switch the Docusaurus build to Rspack + SWC (#3357)
The build-docs CI job had crept from ~95s (January) to ~290s, essentially
all of it webpack: the Server bundle took 1.82m and the Client 2.72m on a
4-vCPU runner, with Babel transpiling ~385 routes and no cache surviving
between runs.

Enable Docusaurus Faster, opting in one flag at a time. The blanket
`experimental_faster: true` preset does not work on this site — both the
SWC JS minifier and the SSG worker threads crash rendering /api-reference
with "ReferenceError: Prism is not defined", because Redoc expects a
`Prism` global neither provides. Leaving those two off and taking the
Rspack bundler, SWC loader, LightningCSS and the MDX cross-compiler cache
keeps the build green.

Measured cold builds (14-core machine, cache cleared each time):

  webpack + Babel + Terser (before)  171s
  SWC loader + LightningCSS only     132s
  Rspack + SWC loader + Terser        56s

Output is unchanged: both bundlers emit the same 871 HTML pages, the same
330M build directory and a byte-identical search index.

No CI cache step accompanies this — Rspack's persistent cache only buys
another ~13s (43s vs 56s) and is not worth a 672MB entry against the
repository's cache budget.
2026-08-10 17:52:51 +02:00
Nicolò Boschi 288a9b7fc6 fix(retain): cut oversized sub-batches on native chunk boundaries (#3282) (#3351)
The sub-batch splitter invented its own boundaries — it sliced an oversized
item at `tokens_per_batch * 3` chars, a chars-per-token fudge unrelated to the
chunk boundaries the rest of the retain path works in. Everything downstream
reuses stored work by chunk content hash: delta retain, the streaming recovery
pass, and chunk_index bookkeeping. A slice that happened to line up with native
chunks reused them; one that cut mid-chunk matched nothing, so a replacement
with a small edit plus a tail re-extracted the whole unchanged history — which
is why the bug only appears when `3 * retain_batch_tokens < retain_chunk_size`.

Slice on the bank's own `chunk_text(chunk_size, structured_chunk_size)`
boundaries instead, packing whole chunks up to the token budget, and verify
each slice re-chunks back to exactly the chunks it holds (merged JSON array,
then "\n\n", then "\n"; falling back to one sub-batch per chunk, which
chunk_text's idempotency guarantees — #2301). That makes one invariant hold by
construction rather than by luck:

    the chunks stored for a document depend only on its body,
    never on how transport split it.

Deliberate consequence: a slice honours `retain_batch_tokens` only down to one
native chunk — below that, `retain_chunk_size` is the real bound. Cutting finer
is the defect, not the budget.

Two follow-ons fall out of the same invariant:

* The split reports `chunk_counts`, so the caller stops re-deriving them just
  before handing each sub-batch over — a workaround that existed only because
  the orchestrator pops `content` while streaming (#1888).
* `document_body_override` is Memory Defense screened once, by the engine that
  produces it, instead of by every slice that carries it. A 42 KB body split
  into 26 sub-batches was rescanned 26 times.

Tests: regression coverage for the oversized replacement and for single
screening, unit tests pinning the alignment invariant across prose, JSON
conversation and JSONL payloads, and one covering the unjoinable-run fallback.
2026-08-10 17:40:47 +02:00
JoshFunnell e62015cbf8 feat(llm): restore server-side prompt caching on load-balanced OpenAI-compatible backends (#3271)
Server-side prompt caches are per backend server, so a load balancer scatters
the calls of one multi-call operation (reflect, mental-model refresh,
consolidation) across replicas and the shared prefix almost never hits. This
adds each vendor's documented affinity mechanism to the OpenAI-compatible
provider family: xAI's `x-grok-conv-id` header and OpenAI's
`prompt_cache_key` field, keyed on the operation's trace id.

Measured independently against a live xAI backend with an ~11.9k-token shared
prefix: 29% of it cached without the header vs 99% with it, with a
rotating-id control ruling out header presence as the cause.

Defaults to `auto`, which is an allowlist rather than a best-effort probe:
only x.ai / grok.com (header) and native OpenAI / openai.com / Azure OpenAI
(field) receive anything, and every other backend -- vLLM, ollama, groq,
deepseek, openrouter, lmstudio, custom proxies -- gets byte-identical requests
to before. Set `HINDSIGHT_API_LLM_CACHE_AFFINITY=none` to disable.

Also wires the existing `default_headers` setting into the OpenAI-compatible,
Fireworks and Nous providers, where it was previously accepted and silently
dropped, and folds the duplicate affinity derivation added by #3272 into the
shared module so the two lanes cannot drift.

Full CI via workflow_dispatch on the rebased head (fork PRs get no secrets and
skip test-api): 103 jobs green, all three test-api shards included. The final
rebase changed documentation context only -- no Python differs from the tested
tree (verified with git range-diff).
2026-08-10 17:23:46 +02:00
Vitor Cepeda LopesandTheAngryPit d270b124a9 fix(coding-agents): report and attribute the configured MCP harness (#3342)
The MCP server resolved its harness (HINDSIGHT_MCP_HARNESS, defaulting to
claude-code) for bank resolution but never passed it to buildKnowledgeTools, so
the tools it builds had no idea which agent they were serving.

Two things follow from passing it:

- hindsight_diagnose reports the actual harness instead of 'unknown'.
- hindsight_ingest_document now stamps the harness:<id> tag and metadata.harness.
  Documents ingested through the MCP tool were previously unattributed, and the
  documents list resolves a document's agent logo and filter from exactly those
  fields.

cfg.harness is the right source: loadConfig back-fills the asking harness onto an
unset field (#3247), so it is the launching harness rather than resolveConfig's
'opencode' default.

Co-authored-by: TheAngryPit
2026-08-10 17:19:50 +02:00
Nicolò Boschi 056982b4a2 fix(coding-agents): seed bank missions once, then leave them to the user (#3352)
Closes #2492.

configureBank POSTed the full CODING_BANK_TEMPLATE — reflect/retain/observations
missions included — to /banks/{id}/import on every run, and the server's import
calls update_bank_config unconditionally for whatever the manifest carries. The
seed engine runs on every session start, so a user who rewrote a mission in the
control plane had the plugin's default stamped back over it on the next session.
Same regression #1270 fixed for OpenClaw, arriving here by the same route: the
template was carried over without the guard.

A bank is now seeded once. Before importing, the client reads the bank-scoped
OVERRIDES (not the resolved config, so inherited global defaults don't read as
"already set"); if any mission is set there — ours from an earlier pass or the
user's own edit — it imports CODING_BANK_STRUCTURE instead, which omits the
missions. Omitted fields are untouched server-side: get_config_updates keeps only
non-None values.

The retain strategies and entity labels are still re-applied every time. They are
not preferences: this plugin writes documents under git / gitlog / conversation /
document, a bank missing one would reject the write, and a newer plugin adding a
strategy needs it to reach existing banks.

Two cases deliberately still seed: `configureBank({reset: true})`, because the
bank was just deleted, and a deployment with the bank-config API switched off —
without that API a user cannot set per-bank missions at all, so there is no edit
to protect.
2026-08-10 17:15:12 +02:00
Nicolò Boschi a22667dc06 feat(coding-agents): retainTags / retainMetadata, with HINDSIGHT_RETAIN_TAGS (#3269, #2896) (#3346)
* feat(coding-agents): retainTags / retainMetadata with template placeholders

Closes #3269.

Every conversation retain carries `source:chat` and `harness:<id>` — what wrote
the memory, but nothing about where it came from. That is fine while each repo
has its own bank, since the bank is the answer. It stops being fine on a
deliberately shared bank, the setup in the issue: one bank holding cross-project
knowledge so facts recall everywhere, where a retained fact then carries no
record of the repository it came out of.

Both settings take `{placeholder}` templates resolved per retain, against the
vocabulary the dynamic bank id already uses plus what only a retain knows:

  {gitProject} {project} {harness} {bankId} {sessionId} {timestamp}
  {channel} {user}

  { "retainTags": ["project:{gitProject}"], "retainMetadata": {"repo": "{gitProject}"} }

{gitProject} is worktree-aware here too, so linked worktrees of one repo stamp a
single name rather than project:app and project:app-wt2.

The substitution itself moves to core/template.ts, shared with bank.ts rather
than duplicated — each call site keeps its own resolver map, because the valid
placeholders genuinely differ (a bank id cannot reference {bankId}).

Two things are deliberately not user-controllable. Built-in metadata is written
last and wins, and retainTags entries in the `source:`/`harness:` namespaces are
dropped with a warning: the documents list filters on those and resolves each
document's agent logo from them, so a template that could forge them would break
attribution for everyone reading the list.

Unconfigured, this adds nothing to a retain.

* docs(coding-agents): document retainTags/retainMetadata in the README, not the generated page

The docs page is generated from the integration's README by
hindsight-docs/scripts/sync-coding-agents-doc.mjs, and build-docs runs it with
--check. The first pass edited the generated page, so the build failed with
"docs page is out of date with the README".

Same content, moved to the source and re-synced (README, generated page and the
docs skill mirror). The row's cross-reference is plain text rather than an anchor
link because the generator flattens links.

* feat(coding-agents): HINDSIGHT_RETAIN_TAGS env override

Closes #2896.

The old Claude Code plugin had HINDSIGHT_RECALL_TAGS but no retain counterpart,
so per-project retain tagging could only be configured globally in the file. Now
that retainTags exists here, it joins the env surface on the same convention:
HINDSIGHT_ + the field in SCREAMING_SNAKE, still a FALLBACK the file wins over.

It is a list rather than a scalar, so a new ENV_LISTS branch splits on commas and
trims — blank entries dropped, so a trailing comma or "a,,b" is a typo rather
than an empty tag reaching the API.

  HINDSIGHT_RETAIN_TAGS="project:{gitProject},env:work"

retainMetadata deliberately gets no env form: it is map-valued, and per-key
branching doesn't survive flattening into one variable — the same rule already
applied to mapPathToBank, harnesses and banks.

Also corrects this file's header, which still claimed the plugin reads no
environment variables at all — untrue since ENV_KEYS was added.
2026-08-10 17:14:30 +02:00
Nicolò Boschi 20bd4f3618 fix(ci): compare OpenAPI against the merge-base; build benchmark role configs whole (#3349)
* fix(ci): compare OpenAPI against the merge-base; build benchmark role configs whole

Two unrelated CI failures, both of which fail without anything being wrong
with the code under test.

OpenAPI compatibility diffed the branch's spec against the LIVE tip of the
base branch, so every endpoint main gained after a branch was cut is
reported as "Endpoint removed (breaks old clients)" by that branch. Three
open PRs failed this way today on /health/live and /health/ready (added by
#3329), none of which touch the spec at all; the only cure was an unrelated
rebase. Compare against `git merge-base origin/$BASE_BRANCH HEAD` instead,
which asks the question the check means to ask: did *this branch* remove
something. Genuine removals still fail — verified both directions against
the real specs.

The scheduled LoComo benchmark has failed every night since at least Aug 8,
before its first question: `LoComoAnswerGenerator()` raised
"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID is required". The workflow does export
that variable — but each benchmark role built its LLMConfig from exactly four
env vars (provider, api_key, base_url, model), and LLMConfig deliberately does
not read the environment for provider-specific settings (from_env is where the
API resolves them). Every Vertex AI value was therefore dropped. The same
four-var construction was copy-pasted at three sites — locomo, longmemeval and
the shared judge — so fixing only the crashing one would have moved the failure
down a line. Replace all three with a shared builder that carries the Vertex AI
project/region/service-account through.

hindsight-dev/tests had no CI job, which is why a plain construction bug was
left for a nightly benchmark to find hours later. Add one, so those tests
(and the new regression test) actually run on PRs.

* fix(ci): make the benchmark role-config tests hermetic

They passed locally off the developer's HINDSIGHT_API_LLM_API_KEY and failed
in the new test-dev job, where no key exists, with "API key is required for
openai" — the tests were reading ambient environment instead of declaring
what they need. Clear every HINDSIGHT_API_*LLM* var before each test and set
the ones under test explicitly.
2026-08-10 17:11:06 +02:00
Nicolò Boschi 531b8bb253 fix(gemini): fail fast on deterministic 400 INVALID_ARGUMENT (#3256) (#3347)
A bank-deterministic 400 INVALID_ARGUMENT on consolidation+structured was
being retried through the full LLM retry budget (4 attempts) and the batch
retry ladder above it — 12+ identical rejected calls per consolidation cycle,
recurring every cycle. HTTP 400 is deterministic; retrying cannot recover it.

- Retry classification: 400 now fails fast in both call() and call_with_tools().
  The recoverable cache-400 one-shot is reordered above the fail-fast so it is
  not mistaken for a hard rejection; only 429/5xx still consume the retry budget.
- Diagnosability: dump_request_on_4xx() gains a force flag. A deterministic 400
  now always logs its content-free structural profile (request config + per-part
  sizes) on first occurrence, even with HINDSIGHT_API_LLM_DEBUG_DUMP_4XX off.
  Message previews stay gated behind the opt-in flag, so the forced dump never
  spills user content.

Tests: test_gemini_400_fail_fast.py + force cases in test_llm_4xx_dump.py.
2026-08-10 16:58:48 +02:00
Nicolò Boschi 44b597c484 fix(engine): normalize whitespace in candidate entity names at intake (#3275) (#3338)
Extraction can hand back entity names carrying embedded newlines/tabs, which
are then stored verbatim as entities.canonical_name and shear every
line-oriented consumer (psql -A output, log lines, exports).

Collapse whitespace runs to a single space and strip the ends at
_prepare_entities_for_resolution -- the single choke point both entity
resolution entry paths funnel through, and before the flat list /
entity_to_unit mapping is derived, so the resolver's positional invariant is
untouched. Case is left alone: the registry already matches on
LOWER(canonical_name).

Two consequences handled at the same spot:
- a candidate that is empty after normalization is dropped instead of being
  created as an entity with a blank canonical_name (the resolver has no guard
  of its own);
- candidates that normalization makes identical are deduplicated per fact, so
  the same entity is not resolved twice and its mention_count bumped twice
  (the upstream dedup in entity_processing runs on the raw text).

Existing rows are not migrated: renormalizing a stored name can collide with
the (bank_id, LOWER(canonical_name)) uniqueness, so cleaning them up is a
merge, not an UPDATE.
2026-08-10 16:41:52 +02:00
JiehoonKwakandNicolò Boschi 81b58934d0 fix(search): use PGroonga for Knowledge Pages (#3335)
* fix(search): use PGroonga for Knowledge Pages

Route Knowledge Page lexical search through the PGroonga expression index instead of applying native tsvector functions to PGroonga deployments. Reconcile the renamed mental_models table and preserve its generated tsvector plus GIN index as a rollback projection while adding the canonical PGroonga index.\n\nThe populated-table exception is deliberately narrow: memory_units backend switches and unknown mental-model index shapes remain fail-closed. Empty native reconciliation restores the generated mental-model projection.\n\nContext:\n- #3318 fixed backend dispatch but intentionally treated PGroonga as native because reconciliation still targeted reflections.\n- Existing PGroonga installs therefore retained a tsvector mental_models column.\n- Keeping that projection avoids a rolling-deploy window where old instances or a rollback build would fail after the new index is installed.\n- Query text is escaped with pgroonga_query_escape and the indexed expression is repeated exactly for planner matching.

* fix(search): convert mental_models to pgroonga instead of a hybrid shape

Reconciling to pgroonga now does the same clean conversion for
mental_models as for every other table (drop the derived tsvector, add
the dummy TEXT search_vector, build the expression index) instead of
keeping the native projection alongside it under a renamed GIN index.
The transition is safe on a populated table because pgroonga indexes
name + content directly, so the replacement column has nothing to
backfill; transitions that do need a per-row value (vchord's
bm25vector, native's tsvector, anything touching memory_units) stay
fail-closed.

Trade-off: rolling back to a build without this change leaves pgroonga
knowledge search broken, since the old read arm queries
mm.search_vector as a tsvector.

Also in this pass:

- Make every reconciliation statement re-executable (IF [NOT] EXISTS).
  Replicas boot concurrently during a rolling restart and each runs this
  reconciliation; on a populated database the loser of the race would
  otherwise crash on DDL the winner had already committed.
- Share the mental_models document expression between the index DDL and
  knowledge_bm25_arm — an expression index is only selectable when the
  query repeats its expression verbatim, so the two must not drift.
- Match the learnings/pinned_reflections migration's generated-column
  expression exactly.
- Tiebreak the pgroonga ordering: pgroonga_score() silently reads 0 on
  any plan that did not use the pgroonga index.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-10 16:37:27 +02:00
Nicolò Boschi 55d40d944c docs(config): name LM Studio/Ollama/Volcano for LLM_STRICT_SCHEMA (#3348)
The HINDSIGHT_API_LLM_STRICT_SCHEMA description listed the OpenAI-compatible
backends it applies to but omitted LM Studio, Ollama, and Volcano — which are
exactly the providers whose soft path skips json_object grammar, so their small
models emit unconstrained output. Enabling the flag is the documented fix for a
JSONDecodeError during retain on those backends (see #3262).

Regenerated the skills/hindsight-docs mirror to match.
2026-08-10 16:37:02 +02:00
BenandClaude Opus 4.8 6cb39aef8f blog: guest post — writenode, continuity over retrieval (Josh Groves) (#3243)
* blog: guest post — writenode, continuity over retrieval (by Josh Groves)

Community guest post by Josh Groves (@Xp3rtMag1c1an), maker of writenode, on
building an AI note-taking Chrome extension on Hindsight: per-user memory
banks, a mode classifier, Node Gravity, and the SOURCE NODE chat. Adds the
post, four product screenshots, a co-brand cover, and an authors.yml entry.

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

* blog(writenode): add benfrank241 as co-author

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

* blog(writenode): update date to 2026-08-10

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-10 10:07:08 -04:00
Nicolò Boschi d506641dc3 fix(transfer): reject a wrong import zip with a 400 that names the fix (#3339)
Selecting a zip in Documents > Actions > Import from zip and pressing the
button dimmed it briefly and then did nothing (#3327). The zip was a zip of
the reporter's own documents, which is rejected — correctly and quickly, so
the button only dimmed for a moment — but the rejection never reached the
screen. #3333 fixed that half: the control plane now toasts the failures of
the direct-fetch helpers that bypass the shared error interceptor.

Two things it did not cover remain.

A file that isn't a readable zip escaped parse_archive as
zipfile.BadZipFile and surfaced as an opaque 500. That is a wrong upload,
not a server fault, so it now raises ValueError like every other archive
problem and the API maps it to a 400 with the reason. The check moves into
a shared _open_archive helper so parse_bank_archive gets it too.

And the rejection now names the fix rather than only the missing file:
"Import from zip" reads like a bulk upload of ordinary files, so the
message says the endpoint only accepts an archive produced by export and
points at file upload / retain for PDFs and text. The import dialog gained
the same hint under the file picker, pointing at Add Document > Upload
Files.
2026-08-10 16:00:53 +02:00
Parafee41 969cb3e5dd fix(reflect): provide the current date and time for temporal reasoning (#3287)
Add the current UTC date and time to the reflect retrieval and final-synthesis
prompts so the agent has a reference point for time-relative questions
(recently elapsed plans were staying classified as upcoming).

Time is at minute precision and placed after the static instruction block —
right before the bank-specific/custom data — so the large static prompt stays
a cacheable prefix and only the volatile timestamp falls outside the cache.

Closes #3279.
2026-08-10 15:44:09 +02:00
JoshFunnell 82852c5225 feat(llm): xai-oauth provider — LLM lanes on a SuperGrok subscription (#3272)
Adds the `xai-oauth` provider: serves Hindsight's LLM lanes from a flat-rate
SuperGrok subscription via an RFC 8628 device-code grant against auth.x.ai, with
proactive/reactive refresh over a shared 0600 on-disk store. No API key. For
API-key access to the same endpoint, `provider: openai` with an api.x.ai base
URL still applies.

Reviewed and tested live against a real xAI subscription (grok-4.5): device-code
login, plain/structured/tool-calling completions, and a real token refresh.
Six follow-up fixes landed on top of the contribution, each with a RED-proven
regression test — see 5e04ab0be for the detail.

Full CI (workflow_dispatch on the rebased ref, since fork PRs get no secrets and
skip test-api): 103 jobs green, all three test-api shards included.
2026-08-10 15:40:30 +02:00
Nicolò Boschi 3a4b0214f9 fix(ollama): make native think configurable via extra_body (#3344)
The native structured-output path hardcoded think=False, so gpt-oss models
failed fact extraction (they require a thinking level). Rather than a
model-name heuristic, merge the configured extra_body into the native
/api/chat payload: top-level native fields (think, keep_alive, ...) pass
through, and an "options" sub-dict merges into Ollama's generation options.

Set HINDSIGHT_API_LLM_EXTRA_BODY='{"think": "low"}' for gpt-oss.

Fixes #3246
2026-08-10 15:35:48 +02:00
Nicolò Boschi 1ffe875983 release(coding-agents): v0.1.2 2026-08-10 15:22:45 +02:00
Nicolò Boschi aa0aa7649b fix(coding-agents): bound transcript reads so an oversized session still retains (#3345)
Closes #3292.

Every live-transcript reader did `readFileSync(path, "utf8").split("\n")`. Past
V8's maximum string length (~537M chars) that throws ERR_STRING_TOO_LONG before a
single record is parsed; the readers catch it as "unreadable file" and return no
turns, so the Stop hook exits successfully having retained nothing. An agent that
had been running for weeks just stops updating memory, with no error anywhere.

Reproduced on a 575MB Codex rollout: readFileSync throws ERR_STRING_TOO_LONG,
readCodexTranscript returns []. With this change the same file yields 8,162 turns
ending in the most recent exchange, in 51MB of heap.

core/jsonl.ts streams the file a chunk at a time through a StringDecoder (so a
multi-byte character split across chunks is reassembled) and reads only the last
MAX_TRANSCRIPT_BYTES. Truncating the HEAD is what makes a cap safe here: the
recent exchange is the part worth retaining, and the incremental write-back only
sends turns after its cursor anyway. Landing mid-record drops that fragment
rather than emitting half a line; starting one byte early keeps a record whose
boundary the cut happened to land on. A truncated read is logged, never silent —
which was the actual complaint.

Applied to all six readers that shared the pattern (claude-code, codex,
antigravity-cli, copilot-cli, cursor-cli, grok-build), not just the one filed:
same bug, same line, and a large Claude Code transcript fails identically.
2026-08-10 15:19:30 +02:00
Nicolò Boschi 2b0ed82f30 fix(coding-agents,ci): drop the periodic re-sync; stop running doc examples for integrations (#3341)
Two things, both about cost.

The periodic full re-sync (a replace every 20 appends) defeated the point of
appending: on a long session it re-uploads the entire document on a fixed cadence,
which is the expense this whole path exists to avoid. Removed.

What it was insuring against still stands, and is now accepted: retains are async,
so a write can be acknowledged and then fail server-side, and a resubmitted
operation_id replays the original operation whatever its status — those turns are
not re-sent. The other replace triggers (no cursor, fingerprint drift, dirty, bank
change) are unaffected, so a write we can SEE fail still self-heals; only a
silent server-side failure after acknowledgement is uncovered.

Second: test-doc-examples gated on a `docs` filter that matched
'hindsight-integrations/**' — added so an integration rename would reach the docs
build's integrations check, except build-docs has no `if:` and runs on every PR
regardless. So the only consumer of that breadth was the doc-examples matrix,
which runs every sample against a live LLM-backed server: four provider-credentialed
jobs on every integration PR (and every prose-only docs PR), none of which those
files can affect.

It now gates on a `doc-examples` filter covering the runnable samples themselves
(hindsight-docs/examples/**) and their runner. `docs` had no other consumer, so it
is removed rather than left as config nothing reads.
2026-08-10 14:51:33 +02:00
Nicolò Boschi 00bad17110 fix(api): add a DB-free liveness probe so a slow database stops restarting pods (#3337)
Adds /health/live (no DB access) and /health/ready alongside the existing /health, on the API server and the worker. Helm liveness probes now use /health/live; readiness stays on /health. Worker liveness reports seconds_since_last_poll for alerting without gating on it.

Fixes #3329
2026-08-10 14:49:11 +02:00
Sanderhoff-alt 0e652006d1 fix(control-plane): surface direct request failures (#3333)
Show errors from document export, transfer import, and file uploads
that bypass the shared API error interceptor.

Format API errors safely so structured validation details remain
readable.
2026-08-10 14:41:56 +02:00
Alan5168 862a77c1a8 fix(api): validate UUID on get_entity and get_observation_history (#3260)
get_entity and get_observation_history passed entity_id / memory_id straight
to uuid.UUID() without a try/except. A malformed id (typo, copy-paste error)
raised a bare ValueError that the HTTP handler mapped to 500 instead of 400.

get_memory_unit and update_memory_unit already validate their id this way
(#906, #3062); these two endpoints were missed.

Wraps both in the same try/except and adds a ValueError -> 400 branch to the
two HTTP handlers (api_get_entity, api_get_observation_history). Adds stub-
engine regression tests mirroring test_delete_memory_units_validation.py.
2026-08-10 14:34:07 +02:00
Nicolò Boschi fd294227c6 feat(transfer): carry Knowledge Pages tree and regenerate mental-model search state on import (#3308, #3323) (#3330)
Whole-bank export/import previously dropped the Knowledge Pages tree
(knowledge_pages was in _SKIP_TABLES because its self-referential parent_id
FK needs a topological restore) and restored mental models without an
embedding or lexical search state — leaving imported knowledge pages
disconnected and unsearchable, on every text-search backend.

Export:
- Add a typed TransferKnowledgePage model (no raw dicts across phases) and
  carry the folder/page tree in knowledge_pages.json, parent-first, preserving
  id, parent_id, mental_model_id, managed, sort_order, name and timestamps.
- Remove knowledge_pages from _SKIP_TABLES; classify it under a new
  KNOWLEDGE_TABLES bucket (coverage guard updated).

Import:
- Regenerate each restored mental model's embedding with the TARGET model
  (same "{name} {content}" text create_mental_model embeds), off-connection so
  no DB conn is held across the embedding call.
- Rebuild backend-specific lexical state via the shared pg_search_vector_expr
  (vchord's bm25vector column; native's is GENERATED and repopulates on insert;
  pg_search/pg_textsearch/pgroonga index base columns).
- Restore the tree after its backing mental models exist and parents-first
  (topological order tolerant of cycles/dangling parents), ON CONFLICT DO NOTHING.

Tests: whole-bank roundtrip asserts the nested tree restores exactly (ids,
parents, mm refs, managed) and pages are searchable after import with no NULL
mental-model embeddings; plus non-DB unit tests for the topological ordering.
2026-08-10 14:27:28 +02:00
Parafee41 36eb64dfba fix metapackage embed version coupling (#3261) 2026-08-10 14:19:25 +02:00
Nicolò Boschi 815f5aaa36 fix(coding-agents): write back only the new turns (append + idempotent retain) (#3336)
* feat(coding-agents): write back only the new turns (append + idempotent retain)

The live write-back re-uploaded the WHOLE conversation on every Stop, and every
N turns under the persistent-plugin runtime. A long session therefore re-sent its
entire transcript each time, which is what turns a large session into an
unretainable one rather than merely a slow one.

Retains now carry a per-session cursor. A session that has already been written
appends only the turns added since the last successful write, using the server's
`update_mode: "append"` (supported since #932); the server concatenates them onto
the stored document with "\n", which is exactly why the transcript is JSONL.

Append is only correct while our view of the document matches the server's, so
every uncertain case falls back to the full REPLACE this always did - no cursor,
a transcript that was rewritten rather than extended (compaction, a truncated
rollout), or a previous write whose outcome is unknown. Replace is idempotent by
construction and so is always the safe recovery.

Two supporting changes:

- Conversation retains carry a deterministic v5 `operation_id`, so a resubmitted
  write is collapsed into the original operation instead of being applied twice.
  That is what makes append safe: the client aborts at 15s, and a server that
  committed the write anyway would otherwise get the same turns again. The field
  landed in v0.8.6 (#2937/#2947) and is silently IGNORED by anything older, so
  the append path is gated on a cached GET /version probe and older servers keep
  replacing.
- `RetainOpts.async` is gone. Nothing ever passed `false`; retains are always
  async, and nothing in this plugin can afford to block a hook on extraction.

Backfill, git, knowledge and survey retains are untouched: they keep replacing,
and deliberately do not take a deterministic operation id, so re-retaining
identical content after a document is deleted still restores it.

* fix(coding-agents): serialise a session's write-backs so appends cannot overlap

Found reviewing the append cursor: the runtime fires retains without awaiting
them, and reading the cursor was not atomic with claiming it — the capability
probe awaits in between. Two overlapping write-backs therefore both planned an
append from the SAME position and submitted overlapping slices, duplicating turns
inside the document:

  replace(REF-ID + turns 0-4), append(turns 5-7), append(turns 5-8)

Serialising only the claim would not have fixed it either: that leaves an append
racing a replace on the wire, where the order they land in decides the outcome.
The whole read-plan-send-confirm cycle is now chained per session, so each
write-back plans against the previous one's CONFIRMED cursor.

The runtime's idle test now waits a tick before asserting on the fire-and-forget
retain, as its sibling assertions already did — one extra microtask hop.

* fix(coding-agents): key the write-back cursor to the bank it wrote to

The cursor is keyed by (harness, session id), but the bank is re-derived from
each hook event's cwd — so a session that moves between repos (#3133) keeps its
id and changes bank. The new bank holds no document for that session, and the
cursor still claimed a position in it:

  bank repo-a: replace(REF-ID + turns 0-4)
  bank repo-b: append(turns 5-7)      <- turns 0-4 never existed here

The cursor now records the bank it wrote to, and a mismatch replaces. Same
reasoning as the fingerprint and dirty checks: anything that makes our view of
the document unreliable falls back to the full write.

Also covers a config change (mapPathToBank, an explicit bankId) that re-points a
live session at a different bank.

* fix(coding-agents): re-sync the whole document every 20 appends

Review follow-up. Retains are async: the server acknowledges the submission and
extracts later, so a write can be confirmed to us and still fail afterwards — and
_resolve_retain_replay returns a prior operation whatever its status, so
resubmitting the same payload will not redo it. Replacing everything used to be
self-healing precisely because each write re-sent the whole document; appending
gives that up, and a single lost write would otherwise cost the rest of the
session.

A full write every MAX_APPENDS_BEFORE_RESYNC appends bounds that to the turns
since the last re-sync. A replace of any kind resets the count.

Also from the review:
- drop a session's chain entry once it settles, so a host that outlives many
  sessions (opencode runs for days) does not keep one resolved promise per
  session id forever
- pin the version test to MIN_IDEMPOTENT_RETAIN_VERSION rather than repeating
  the literal, which also gives the exported constant a consumer
2026-08-10 14:16:52 +02:00
Nicolò Boschi f8e588c042 fix(api): gate knowledge-base routes through the operation validator (#3312) (#3331)
Knowledge-base routes reached the engine without invoking
OperationValidatorExtension, so any authenticated tenant could read or
write another bank's knowledge tree — including the mental-model content
that pages render — by knowing its bank_id.

- Add knowledge-base members to BankReadOperation (tree/get-page/search/
  export) and BankWriteOperation (create-folder/create-page/update-page/
  rename/move/delete).
- Gate all nine KB engine methods through _validate_operation before any
  read or write, mirroring the mental-model paths. This also makes the
  KB routes' pre-existing (previously dead) OperationValidationError
  handlers reachable.
- Add a _nested_operation_authorized contextvar so composite methods
  (create_knowledge_page, update_knowledge_page) and the new
  export_knowledge_base engine method fire exactly one validator hook and
  never auto-create a bank on an unauthorized path.
- Move export bundle data-gathering into export_knowledge_base (typed
  KnowledgeBaseExport/KnowledgeBaseExportPage); http.py only renders.

Tests: deny bank_read -> 403 leaking no content on tree/get-page/search/
export; deny bank_write -> 403 leaving the tree unchanged and no bank row;
success paths assert exact hook counts.

Follow-up to #3036 / #2488.
2026-08-10 13:59:42 +02:00
Nicolò Boschi e852acc7c0 feat(reflect): resolve entity names on reflect sub-recalls (#3334)
recall_async only populates each result's entities field when
include_entities=True, and it defaults to False — so reflect's recall and
search_observations tools never surfaced them. Canonical entity names are
semantic signal the surface text may lack ("Bob" in the text vs canonical
"Robert Smith"): they give the agent resolved names to cite and to pivot
follow-up queries on, for the cost of one extra lookup query per recall.

The top-level EntityState dict recall also builds is not serialized into
tool results; only the per-fact names reach the agent.
2026-08-10 13:59:02 +02:00
DragonKidandNicolò Boschi e9a14da690 fix(structured-doc): add TableBlock to fix markdown table rendering (#3289)
* fix(structured-doc): add TableBlock to fix markdown table rendering

StructuredDocument only supported 4 block types (paragraph, bullet_list,
ordered_list, code). When the LLM generated markdown tables, _parse_block
treated the multi-line table as a ParagraphBlock and joined all lines with
" ".join(), collapsing the table into a single line and breaking formatting.

Add a TableBlock type with headers/rows fields, plus parse and render
support. The parser detects table chunks by checking that all lines match
the markdown table row pattern and at least one line is a separator
(|---|---|). The renderer emits standard markdown table syntax with
one row per line.

Also update the delta ops prompt to include the table block shape so
delta refreshes can emit table operations correctly.

* fix(structured-doc): escape pipes in table cells and cover TableBlock with tests

The new TableBlock joined cells with a bare " | ", so a cell whose text
contained a pipe emitted extra columns and re-parsed into a different block
— the render/parse round-trip the structured-delta architecture depends on
was not stable for tables. Escape \\ and | on render, scan escapes on parse.

Also: a table with no headers dropped every row (rendered ""), rows wider
than the header lost cells to GFM, and the no-separator branch of
_parse_table_block was unreachable. Adds the missing unit tests.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-10 13:55:20 +02:00
Nicolò Boschi 3e3e3f372c feat(bank-template): make every bank config field export+importable (#3332)
Seven per-bank configurable fields were not declared on BankTemplateConfig, so
export/import silently dropped them: consolidation_llm_parallelism,
consolidation_max_memories_per_round, enable_auto_consolidation, memory_defense,
recall_chunks_max_tokens, recall_include_chunks, recall_max_tokens. Cloning a
bank produced a clone that looked correctly configured while quietly running on
the server defaults for those seven — and memory_defense being one of them means
a bank's defense policy did not travel with it.

All seven are now part of the template engine, so an exported bank reproduces its
full configuration on import.

The rest of the change is about not needing to notice this again. Adding a
per-bank config field is a multi-step flow, and each step now fails until the
previous one is done:

1. add it to _CONFIGURABLE_FIELDS -> test_every_configurable_field_is_exportable
   fails until it is declared on BankTemplateConfig (both directions: a template
   field that is not configurable fails too, since the engine would reject it);
2. declare it there -> test_sample_values_cover_every_exportable_field fails
   until it has a value in _SAMPLE_VALUES;
3. give it a value -> the existing round-trip test exercises it end to end;
4. touching BankTemplateConfig moves the OpenAPI spec, the generated clients and
   bank-template-schema.json, so verify-generated-files fails until those are
   regenerated.

An intentional exclusion is now a decision to record in the guard with a reason,
not an omission that no one sees.

The docs listed 15 of the 45 fields in a hand-maintained table that was already
stale and would contradict "every field is supported" the moment it drifted
again. It now states the guarantee and points at the generated schema as the
authoritative list, keeping the common fields as examples.

Verified by mutation: adding a configurable field without a template field,
adding a template field that is not configurable, and adding a template field
with no sample value each fail the suite.
2026-08-10 13:49:21 +02:00
JoshFunnellandNicolò Boschi ea0d5ead0a perf(reflect): drop retrieval plumbing from reflect tool results (#3310)
* perf(reflect): drop retrieval plumbing from reflect tool results

Reflect tool results are handed to the model verbatim, so every field in them
is spent context. `search_observations` and `recall` currently serialize the
whole result model, which includes retrieval internals the agent never reads:
per-stage `scores`, ingest `metadata`, extracted `entities`, and the
`chunk_id` / `document_id` plumbing. On real banks that envelope measures
several times the observation text it accompanies.

These are internals rather than evidence, and the loop does not depend on any
of them: the agent cites by `id`, `based_on` persists only
id/text/type/context, and the expand tool takes `memory_ids` and resolves
chunks server-side. Identity, text, dates, tags and `source_fact_ids` are all
kept.

`chunks` in `recall` is deliberately left alone: `ChunkInfo` carries only
chunk_text / chunk_index / truncated, so it holds none of these fields and
trimming it would be a no-op. A test pins that, so if a future field lands
there the decision is revisited rather than quietly going stale.

Scope, stated plainly: this reduces the envelope, it does NOT implement the
accounting change #3122 asks for. The token budget still counts observation
text only, and forced synthesis still drops oversized blocks whole, so the
user-visible failure in that issue -- a confident "no information" answer
carrying hundreds of citations -- can still occur on a large enough result
set. This is a smaller, independent improvement; #3122 should stay open.

Tests pin both directions, since the risk in removing fields is that
something downstream quietly needed one: every trimmed field is gone, and
every field the loop depends on survives.

* keep entities in reflect tool results: canonical names, not plumbing

The entities field carries canonical entity *names* (not ids), which are
semantic signal the surface text may lack ("Bob" in the text vs canonical
"Robert Smith"). Reflect's recalls don't populate it today
(include_entities defaults to False, so _prune_nulls already drops the
None), but trimming it would bake in eating the names if that ever flips
on. Only true plumbing stays trimmed: scores, metadata, chunk_id,
document_id.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-10 13:41:47 +02:00
Nicolò Boschi e9d1676ef3 test(bank-template): round-trip every exportable field through export+import (#3324)
Existing coverage left a gap in the middle. test_bank_template_configurable_fields
sets fields one at a time and never exports, so it proves import writes a field,
not that export reads it back. TestExport::test_export_reimport_roundtrip does
export then import, but with a single config field, and asserts only the response
flags (config_applied is True) — never that a value survived.

A field that import accepts but export drops, or that export reshapes into
something import rejects, passes both today.

This sets every field BankTemplateConfig declares on one bank, exports it,
imports the exported manifest into a fresh bank, and asserts the clone's
overrides match the source's. Overrides, not resolved config: the resolved view
would hide a dropped field behind the server default and read as a pass. The
comparison is source-vs-clone rather than against the literal input because some
fields normalize on the way in (entity_labels migrates legacy shapes) — the
property under test is that a clone ends up configured like its source.

BankTemplateConfig.model_fields is the exportable surface (the export endpoint
filters overrides through exactly that set), so a guard test asserts the sample
table matches it. Adding a template field fails that guard until it gets a
value, which stops the round-trip from silently narrowing.

Verified by mutation: dropping a field from the export filter, dropping one from
BankTemplateConfig.get_config_updates(), and adding an unsampled template field
each fail the suite.
2026-08-10 13:05:04 +02:00
Nicolò Boschi 2cf31d7fbe fix(config): validate bank config value types and stop them wedging tasks (#3218) (#3319)
The bank-config API took `dict[str, Any]` updates and never checked the values
against the declared `HindsightConfig` field types, so a client could store a
JSON object in a string-typed field such as `observations_mission`. The write
succeeded; the bank then failed *every* consolidation with "expected string or
bytes-like object, got 'dict'" — `re.sub` inside `escape_for_prompt`, reached
from prompt assembly. Deterministic, so the bank never recovered. A sweep of one
deployment found 7 banks across 7 tenants in this state, spread by a third-party
tool that writes structured JSON into the mission/instruction fields.

- Write side: `validate_bank_config_updates` now type-checks every value against
  the field's dataclass annotation and rejects with a 400 naming the field and
  the expected type. Applies to nested `retain_strategies` overrides too, since
  `apply_strategy` splices those onto the resolved config and they reach the same
  fields. `entity_labels` keeps its wider contract (list or {"attributes": [...]}).
- Read side: banks configured before this landed are tolerated rather than
  wedged. A non-string in a string field is JSON-encoded — the same remediation
  applied in the field, and it preserves intent since the structure still reaches
  the prompt as text. Anything not coercible is dropped with a WARNING so the
  bank falls back to the tenant/global value.
- Observability: task-failure paths log `exc_info` instead of a bare
  `traceback.print_exc()` (the stderr copy carries no task id and rotates away
  first), and messages/stored `error_message` go through `format_task_error`, so
  an exception with an empty `str()` no longer logs
  "Task execution failed: graph_maintenance, error: ".
2026-08-10 13:04:45 +02:00
Nicolò Boschi aefc5e8e7d test(ci): gate every build on hermes-agent@main co-installability (#3265)
Hermes installs `hindsight-all` into its OWN venv via `hermes memory setup` to
run memory in local_embedded mode, and exact-pins every direct dependency
(`==X.Y.Z`) as a deliberate supply-chain policy. Any version range Hindsight
declares that excludes one of their pins therefore makes the two impossible to
co-install for every Hermes user on embedded memory — that is what #3251 hit
with our cryptography/pillow floors.

Hermes main has since bumped to cryptography==48.0.1 / Pillow==12.3.0, which
already matches our floors, so no dependency change is needed and none is made
here. What was missing is the check that keeps it that way: both sides bump on
their own schedule, so the collision recurs silently until something looks for
it. Tracking their main branch surfaces it while it is still cheap to fix on
either side rather than in a released Hermes.

scripts/test-hermes-compat.sh runs five checks of increasing depth: resolution
(both into ONE resolution, so an unsatisfiable pin is a hard error instead of a
silent downgrade), `pip check` plus a dump of the contested versions, `hermes
memory status`, the runtime imports Hermes makes for embedded memory, and a real
embedded daemon boot with bank operations.

Implementation notes:

- Hindsight installs from BUILT WHEELS, not `file://` directories. uv installs a
  workspace member given as a directory such that hindsight_embed.__file__ still
  points into the source tree, and the daemon manager keys dev-mode detection on
  that path — so a directory install launches the API via
  `uv run --project <repo>/hindsight-api-slim`, out of the monorepo's venv and
  .env, bypassing the Hermes venv this script exists to test. Step 5 asserts the
  daemon binary resolves inside the test venv so this cannot regress.
- Hermes is cloned and installed editable: their build backend refuses
  wheel/sdist builds by design, so `git+https://` fails outright.
- Python is pinned to 3.12 rather than .python-version because Hermes caps
  itself at <3.14; the venv must sit inside both projects' windows.
- Hindsight state is isolated by a dedicated `hermes-ci` profile rather than by
  redirecting HOME, which would also hide the uv/HuggingFace caches from the
  runner and re-download the local-ml stack every run.
- Step 5 runs from the work dir, not the repo, so a developer's .env cannot hand
  the daemon credentials a runner does not have.
- MemoryEngine refuses to construct without an LLM key, so the daemon boots on a
  placeholder one; nothing calls the LLM during startup or bank operations.

The job needs no secrets and so runs on fork PRs too. Only the retain/recall
round-trip requires a real LLM key and is skipped without one.

Verified end-to-end locally against hermes-agent main (0.20.0): all five steps
pass, 225 packages consistent, daemon boots from the test venv and stops cleanly.
2026-08-10 13:03:40 +02:00
Nicolò Boschi 18bff79aea fix(api): knowledge-base search 500s on non-native text-search backends (#3268) (#3318)
* fix(api): dispatch knowledge-base search on the text-search backend (#3268)

search_knowledge_pages hard-coded the native tsvector SQL
(ts_rank_cd / @@ over mm.search_vector) in both its RRF BM25 arm and its
embedding-unavailable fallback. But mental_models.search_vector is only a
tsvector under the `native` backend; under pg_search / pg_textsearch /
vchord it is a dummy TEXT (or bm25vector) column, so knowledge-base search
500'd with `function ts_rank_cd(text, tsquery) does not exist` on every
non-native deployment while recall (which already dispatches) kept working.

Add knowledge_bm25_arm() in the PG dialect — the same per-backend dispatch
PostgreSQLDialect.build_bm25_arm already does for memory_units — and route
search_knowledge_pages through it:

- native / pgroonga: generated tsvector (ts_rank_cd / @@). pgroonga's
  mental_models is never reconciled to pgroonga structures, so it keeps the
  migration-time tsvector and the native operators are correct for it.
- pg_search: paradedb.score / @@@ over the (id, name, content) BM25 index.
- pg_textsearch: BM25 distance over the content column.
- vchord: its bm25vector column is never populated on mental-model writes,
  so the BM25 index is empty — degrade to a vector-only search rather than
  emitting SQL that returns nothing (or 500s).

Fix the stale docstring that claimed a tsvector for all backends, and add a
backend-dispatch regression test that pins the SQL each backend emits
(no live extension required, matching test_multilingual_bm25).

* fix(api): make knowledge-base search reuse the recall BM25/vector logic for all backends (#3268)

Follow-up to the first cut, which degraded vchord to a vector-only search
because mental_models.search_vector was never populated. Instead, reuse the
exact per-backend logic the memory-recall path already uses so knowledge
search works identically across native / pgroonga / pg_search / pg_textsearch /
vchord — read AND write.

Write side: mental_models.search_vector is now tokenized on write for vchord
via the shared pg_search_vector_expr helper (the same single source of truth
insert_facts_batch / consolidator use for memory_units), threaded through the
create-pinned INSERT, the update-mental-model UPDATE (re-tokenized only when
name/content change), and clear-mental-model. The helper gains signals_col=None
(two-column tables) and native_inline=False (mental_models' native search_vector
is a GENERATED column that populates itself, so only vchord's plain bm25vector
column needs an explicit value). memory_units keeps its three-column,
native-inline behaviour unchanged.

Read side: knowledge_bm25_arm now emits a real vchord BM25 arm (negated <&>
distance over search_vector, gated > 0) mirroring build_bm25_arm, and no longer
returns None — search_knowledge_pages drops the vector-only/empty-result
branches.

Tests: pin the vchord read arm and the per-backend write tokenization (only
vchord writes; memory_units default expr unchanged).
2026-08-10 12:54:06 +02:00
Parafee41 4c3a0bf295 fix codex extra body forwarding (#3305) 2026-08-10 12:52:37 +02:00
Nicolò Boschi 76a74b3181 fix(worker): serialise graph_maintenance per bank at claim time (#3235)
Every graph_maintenance run is the same bank-wide sweep: the payload carries
only bank_id, run_graph_maintenance_job discards the request context, and the
relink pass drains the whole queue. A second concurrent run for one bank adds
no work — and claim_graph_maintenance_batch locks queue rows FOR UPDATE with no
SKIP LOCKED precisely because it assumes a single runner per bank, so the runs
convoy on each other while each holds a worker slot.

Same guarantee consolidation already gets from its busy-bank exclusion, applied
as a predicate on the existing claim queries rather than a claim phase of its
own. graph_maintenance has no reserved-slot floor and the poller's fairness pass
claims with shared_limit=1, so a trailing phase would drop it below every other
operation type and let a single pending retain starve it; as a predicate it
keeps competing by created_at.

The predicate also takes at most one same-bank row per batch. Excluding busy
banks alone does not cover that: with several pending rows and nothing yet
processing, one batch claims them all. Several pending rows per bank are
reachable through the recovery paths — recover_own_tasks resets all of a
worker's processing rows at once, plus _schedule_retry / _defer_operation /
admin recover.

Fixes #3230
2026-08-10 12:27:27 +02:00
Parafee41 6d61772190 fix root worktree project resolution (#3286) 2026-08-10 12:25:19 +02:00
Nicolò Boschi d733772e39 fix(engine): stop concurrent bank deletes from deadlocking on vector-index DDL (#3245)
DROP INDEX CONCURRENTLY on the shared memory_units table deadlocks with
other sessions' index DDL by design; CI's end-of-run teardown storm
outlasted the drop path's ~2.4s retry budget. Serialize per-table index
DDL in-process on PostgreSQLOps and give the drop path a ~30s jittered
retry budget for the cross-process residue.
2026-08-10 12:08:56 +02:00
JiehoonKwak 5f1237279b fix(docker): repair PGroonga Compose image (#3316)
The PGroonga example referenced groonga/pgroonga:latest-debian-pg17, which has no registry manifest, so the documented Compose stack could not build.\n\nPin the current PGroonga 4.0.8 PostgreSQL 17 image and install pgvector 0.8.6 from the PGDG repository already configured by that base image. This removes the source clone and build toolchain while keeping the example on PostgreSQL 17 for parity with neighboring recipes.\n\nContext:\n- Fixes #3311.\n- Verified on arm64 with a disposable PostgreSQL instance.\n- CREATE EXTENSION vector and pgroonga both succeed.\n- Mixed Korean/English PGroonga search and pgvector distance queries pass.\n- PostgreSQL 18 deployment work remains a separate operational concern.
2026-08-10 12:05:51 +02:00
github-actions[bot] 3a48b6e5bb chore: update star history 2026-08-10 03:58:09 +00:00
github-actions[bot] f1c825d884 chore: update star history 2026-08-09 03:53:28 +00:00
Nicolò Boschi 4b2041eb3d release(coding-agents): v0.1.1 2026-08-08 12:57:49 +02:00
Nicolò Boschi cbbc864694 fix(coding-agents): seed the actual harness, not the "opencode" default (#3247) (#3266)
The background seed engine (deepen.js) resolves {harness} from cfg.harness,
which falls back to a hardcoded "opencode". buildSessionStartContext fired the
seed via startSeed(cwd, { limit }) without the harness — so every non-opencode
session's codebase survey and git history were misfiled into an
`opencode::<project>` bank that no session ever reads, while the session hooks
correctly wrote to `<harness>::<project>`.

- session-start.ts: forward the asking harness to startSeed, mirroring the
  survey spawn right beside it.
- config.ts: resolve cfg.harness to the harness that called loadConfig when the
  config file sets none, instead of the silent "opencode" default — this also
  fixes the same latent misfiling for kilo and opencode-fork harnesses.

Also (#3248): add a supersession clause to OBSERVATIONS_MISSION so a revised
convention updates its existing observation instead of accumulating a
contradictory sibling, matching the language already used in the conversation
and reflect missions.

Verified end-to-end with the built artifacts: the real claude-sessionstart-hook
now spawns `deepen.js ... --harness claude-code`, which resolves
`bank=claude-code::<project>`.
2026-08-08 12:55:56 +02:00
github-actions[bot] 5b2f5d82c2 chore: update star history 2026-08-08 03:48:27 +00:00
Nicolò Boschi 13d9f2df95 docs(blog): fix broken /docs/developer links in the 0.9.0 release post 2026-08-07 18:56:39 +02:00
Nicolò Boschi eb47374fa3 docs: changelog and blog posts for v0.9.0 (#3189)
* docs: draft blog posts for v0.9.0 (release + coding-agents)

* docs: 0.9.0 changelog + consolidate coding-agents launch post

* docs: update launch post to the shipped coding-agents harness list

* docs(blog): add per-agent wall time to the 0.9.0 benchmark table

The table reported corrections and cost but not how long a task took, which is
the number a reader feels. Computed from the same runs as the other columns —
outputs/sdebench/{hindsight,vanilla}-{claude,codex,opencode}-{1,2,3} on the
benchmark repo's main, meta.wall_s averaged per task across the three runs of
each arm:

  Claude Code   84.7s -> 75.1s  (-11%)
  opencode     174.2s -> 163.3s (-6%)
  Codex CLI     53.6s -> 48.6s  (-9%)

Verified against the published columns before trusting the source: the same
files reproduce corrections 0.85/0.36, 1.20/0.80, 1.34/0.47 and costs -24%,
-13%, -52% exactly.

The prose claim is deliberately the weaker one — every memory run beat its
agent's vanilla AVERAGE, which holds for all nine. 'Faster than every vanilla
run' does not: opencode's slowest memory run (171s) is slower than its fastest
vanilla run (163s), and Codex ties at 51s.

* docs: regen 0.9.0 changelog and expand release blog for new features

* docs: refresh 0.9.0 changelog (99 commits)

* docs: date the 0.9.0 posts (launch 08-06, release 08-07) and unset draft

* docs(blog): embed 0.9.0 launch video, add coding-agent logos cover, cross-link posts

* docs(blog): drop cover image from the 0.9.0 release post (keep it on the launch post)

* docs(blog): fix coding-agents install to npx, link the Coding Agents docs page

* docs: regenerate 0.9.0 changelog against the release tag; sync docs-skill mirror
2026-08-07 18:23:20 +02:00
Nicolò Boschi b12646f49e Release v0.9.0
- Update version to 0.9.0 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Create documentation version-0.9
2026-08-07 18:16:35 +02:00
Nicolò Boschi 404fe467b6 Revert "chore(db): remove deprecated entity schema from memory_links" (#3177) (#3244)
* Revert "chore(db): remove deprecated entity schema from memory_links (#3177)"

This reverts commit 5f8a030615.

* fix(migrations): re-parent f2a6d8c4b1e9 onto e4a7c1b9d2f6 after removing c1e7a9d3f5b2
2026-08-07 18:08:59 +02:00
Nicolò Boschi fecacc4ea8 docs: launch Coding Agents + mark the per-agent plugins superseded (#3162)
* docs: launch the Coding Agents page and mark the per-agent plugins superseded

Hold until the Coding Agents plugin is announced — merging this makes the page
public and tells existing users their plugin is legacy, so it should land with
the announcement rather than before it.

Two halves:

- Launch. Undoes the deliberate hide: the page drops `unlisted`, its entry
  returns to integrations.json (which drives both the gallery and the sidebar),
  and coding-agents leaves the EXCLUDED set in check-integrations.mjs, so the
  released-tag check guards it like every other integration.

- Supersede. The six overlapping integrations — claude-code, codex, opencode,
  cursor-cli, cline, copilot-cli — get an admonition in the style already used
  on the Hermes page: what replaces them, the install command for their harness,
  and a link to the migration section. Pages and packages keep working; nothing
  is deleted and no registry deprecation is published, so existing links and
  installs are unaffected.

Each notice states plainly that memory does not move — the old plugins scope a
bank per agent per project where this one uses a bank per repo — and points at
`--import-conversations` for Claude Code and Codex, the two whose transcripts
record enough to attribute a session to a repo. The other four say so instead of
implying an import exists.

* docs: group the integrations sidebars into coding agents / frameworks / apps

Both sidebars driven by integrations.json rendered one flat run of 59 entries.
Split them into three groups so a coding agent is distinguishable from an SDK.

The existing `category` field couldn't drive this on its own: its `tool` bucket
mixed CLI agents and editors with chat apps, note-taking and voice platforms.
The 19 coding agents move to a new `coding-agent` category; `framework` is
unchanged, and `tool`/`mcp` become the catch-all group.

Grouping lives in src/lib/integration-groups.ts, kept free of the @site alias and
of any JSON import so both consumers can use it — the theme swizzle (webpack) and
sidebars-integrations.ts (evaluated at config load). An unrecognised category
falls into the last group rather than disappearing from the sidebar.

* docs: regenerate the docs-skill mirror for the supersede admonitions

scripts/generate-docs-skill.sh mirrors docs-integrations/ into skills/; the
launch commit edited seven pages without re-running it, so the mirror still
described the per-agent plugins as current.

* docs: spell out the migration path for Claude Code and Codex

Both pages said memory "does not move automatically", which is now only half
true: the server endpoint IS carried over (~/.hindsight/claude-code.json and
~/.hindsight/codex.json, same keys), so nobody is silently switched to Cloud.

Each page now states what moves — the endpoint automatically, conversations via
--import-conversations — and what does not: the recall/retain settings, missions
and bank-naming options. It also says why conversations come from local
transcripts rather than the old bank: that bank defaulted to a single static
bank shared by every project, whose documents record only a session id, so
attributing them to a repo requires the local transcripts regardless.

These are the only two superseded plugins with an endpoint to carry; the other
four pages already say their history can't be imported and are unchanged.

* feat(claude-code,codex): deprecation notice in the old plugins' sessions

Folded in from #3205 so the launch lands as one change: the docs that announce
the Coding Agents plugin and the in-session notice that points existing users at
them ship together, rather than one arriving without the other.

Both plugins keep working, but they are deprecated — development has moved to
@vectorize-io/hindsight-coding-agents. A changelog entry reaches nobody who
installed a year ago, so the SessionStart hook says it, via systemMessage (the
channel Claude Code shows the USER; additionalContext would only reach the
model). Codex accepts the same hook output shape, so one design serves both.

Emitted before the existing early returns: neither the memory settings nor
whether the server is reachable changes the fact that the plugin is deprecated.

Shown every session, with `"upgradeNotice": false` as the permanent opt-out —
stated in the message itself, which is what makes that frequency acceptable.
With no rate limit there is no state file and none of its failure modes; what
remains is a config check that returns None rather than raising, because a
promotional message must never be why a session breaks.

* docs: per-harness install sections, featured hub cards, browsable sidebar

Page
- One subsection per harness with its logo and a copyable install command,
  replacing the table: the command is what a reader came for, and a table cell
  is not copyable.
- Title is just "Coding Agents"; the old keyword-stuffed title read as spam in
  the sidebar and breadcrumbs.
- "Ingestion internals (no CLI)" is dropped from the docs page via the existing
  DROP_SECTIONS mechanism, staying in the README where the contributor-facing
  audience is.

Integrations Hub
- A Featured grid pins Coding Agents, Vercel AI SDK and OpenClaw above the
  rest, and only on the unfiltered view — pinned cards above non-matching
  search results would read as noise.
- The Coding Agents card draws all ten supported harness logos. "One install,
  every agent" is the whole pitch and a single icon cannot carry it.
- Logos come from the control plane's harness set, which is already keyed by the
  exact harness ids the plugin uses, so the two stay consistent by construction.

Sidebar
- Groups are open but show six entries each, with the tail behind a nested
  "Show all N". Fully expanded, 59 entries were a wall; fully collapsed hid that
  the list was worth opening.
- The umbrella Coding Agents entry leads its group instead of sorting under "C",
  since it is the entry point to every other agent in that list.

* fix(docs): point the page's harness logos at this build, not production

The README must use absolute URLs so the logos render on npm and GitHub, but the
docs page inherited them verbatim — pinning every image to hindsight.vectorize.io,
where /img/harness/* does not exist yet. Logos were broken locally and in
previews, and would only start working after a deploy.

The sync script now rewrites our own absolute asset URLs to site-relative, next
to the repo-relative-link rewrite it already does for the same reason: two
audiences needing different URLs from one source.

* docs: curate the sidebar previews, harness logos for coding agents

The coding-agent group now previews HARNESSES, not pages: ten logos that all
link to the Coding Agents page. Listing ten integration pages there presented
one plugin as ten separate integrations, which is the opposite of its pitch —
and the logos make the group recognisable at a glance. Every individual page
moves behind "Show all", which is also what keeps it associated with the
sidebar.

The other groups get hand-picked previews instead of the first six
alphabetically — the first names in a sorted list are an accident of spelling,
not a description of the group:
  Frameworks & SDKs — LangGraph/LangChain, Vercel AI SDK, Vercel Chat, Eve, CrewAI
  Apps & tools      — ChatGPT, Hermes, OpenClaw, Obsidian

Hermes, NemoClaw, OpenClaw and Paperclip move from framework to tool.

The overflow label counts what opening it actually reveals: "Show 21 more" where
some entries are already previewed above, "Show all 19" for coding agents, whose
overflow really is every page.

* docs: inline sidebar preview, full list on the integration pages

The two sidebars do different jobs, so they now show different things.

Main docs sidebar — a preview: three groups rendered INLINE and
non-collapsible, nothing behind a disclosure. Ten harness logos for coding
agents, five frameworks, four apps, then an "All integrations" link to the
gallery, which offers search and filters a sidebar cannot.

Integration pages — the full list again: flat, alphabetical, every entry. Once
you are on one of these pages you are comparing and hopping between them, so
hiding two thirds behind "Show N more" worked against the reader. Listing each
page directly is also what associates it with this sidebar.

* docs: promote the sidebar groups, separate Featured, brand the umbrella card

Sidebar: the "Integrations" placeholder is replaced BY its contents instead of
filled, so the three groups sit at the same level as the rest of the navigation.
The wrapper was two levels of nesting to say one thing, and it indented every
entry beneath it.

Hub: a divider and an "All integrations" heading separate the pinned Featured
cards from the full list, which otherwise read as one uninterrupted run.

The Coding Agents card carries the Hindsight mark rather than the GitHub logo —
it is our own package, and the GitHub icon said nothing about it.

* docs: install with npx, no global install

Every example across the README, the seven integration pages, the companion
skill and the generated docs now runs the installer with npx. Nothing here asks
anyone to keep a package installed whose only job is to wire other tools up.

The paragraph telling people to install globally — and warning that npx was
refused — is replaced by what actually happens: install copies what it needs
into ~/.hindsight/coding-agents and points each agent's wiring there, so it does
not matter where it ran from, and updating is the same command again.

Depends on #3241, which makes that staging real; until it ships in 0.0.6 the
published installer still refuses to run from an npx cache.

* docs: move the superseded pages into a Legacy section, out of the gallery

The six per-agent pages the Coding Agents plugin replaces — Claude Code, Codex,
Cursor CLI, Copilot CLI, opencode, Cline — move to a `legacy` category.

They keep their pages and their migration banners: people still run these
plugins and still arrive from old links, so removing the pages would break both.
What changes is where they are offered. The gallery is where someone comes to
CHOOSE an integration, and offering one we are actively migrating them off
points them at a dead end — so legacy entries are filtered out of it, including
the hero banner, whose hardcoded list still advertised Claude Code and now
advertises the plugin that replaced it.

In the sidebar they sit in a collapsed "Legacy" section at the end instead of
mixed in alphabetically, so the main list is only what we would recommend today.
Docusaurus expands that section automatically when you are on one of the pages.

Grouping keys off an explicit `harnessPreview` flag now: "no previewIds" used to
imply the coding-agent group, which the Legacy group would also have matched.
2026-08-07 17:00:50 +02:00
Ben 4d52d2974c docs(templates): recommend a few relevant integrations on the Hermes-only templates (#3215)
Four bank templates (hermes-gateway-bot, hermes-orchestrator, customer-support,
research-assistant) listed only `hermes`, making them look Hermes-exclusive even
though their memory pattern fits other harnesses. Add a small, curated set of the
most relevant integrations to each (keeping `hermes`):

- hermes-gateway-bot: langgraph, crewai, agno
- hermes-orchestrator: langgraph, crewai, autogen
- customer-support:   langgraph, crewai, dify
- research-assistant: obsidian, llamaindex, langgraph

Also fixes two stale icon IDs on the conversation template
(ai-sdk -> vercel-ai-sdk, chat -> vercel-chat) so its icons resolve.

`hermes` is kept on all seven templates, so the Hermes setup picker is unaffected.
2026-08-07 10:21:35 -04:00
Nicolò Boschi cb4c4c06b3 release(coding-agents): v0.1.0 2026-08-07 16:10:12 +02:00
Nicolò Boschi 79fe411101 feat(coding-agents): styled installer UI, arrow-key server picker, required Cloud token (#3242)
- clack/Vercel-style rail renderer (src/install-ui.ts, zero-dep): per-harness
  step groups keyed on the '<name>: ' log prefix, severity from message
  phrasing plus run()'s own emoji markers, $HOME shortened to ~, version
  header, honest outros (partial failures say so instead of 'nothing changed')
- arrow-key server picker (❯ pointer, ↑/↓/j/k + Enter, digit shortcuts,
  Esc/q/Ctrl+C cancels) with the numbered prompt kept as fallback when a raw
  TTY is unavailable; rows truncate to the terminal width and autowrap is
  disabled during repaint so narrow terminals don't duplicate lines
- fix: the interactive picker never actually waited (v0.0.4/v0.0.5) —
  process.stdin.isTTY flips fd 0 non-blocking, readSync EAGAINs, and every
  answer silently became its default. Probe with tty.isatty instead and treat
  EAGAIN as wait-for-input
- fix: configureServer now honors HINDSIGHT_CONFIG like the runtime, so the
  wizard writes the file sessions actually read
- Hindsight Cloud API token is now REQUIRED: interactive re-asks (3 attempts),
  --server cloud without --api-token refuses up front instead of writing a
  config that 401s on the first session
- installSkill logs with the harness prefix so skill lines group correctly
2026-08-07 16:08:43 +02:00
Chris Bartholomew d3946f17cd feat(recall): per-bank toggles for the temporal, graph, and rerank stages (#3223)
Adds three hierarchical config fields — enable_temporal_retrieval, enable_graph_retrieval, enable_reranking — all defaulting to true, so recall behaviour is unchanged unless a bank opts out. enable_reranking reuses the existing RecallReranking strategy by downgrading "cross_encoder" to "rrf"; "interleave" (consolidation dedup) and "rrf" are never overridden.

Paired with retain_extraction_mode=chunks and enable_observations=false, a bank behaves like a conventional vector store. Ships as a `plain-retrieval` bank template.

Surfaced through the bank config API, both hand-written SDK wrappers, and a Recall Pipeline section in the control plane translated across all ten locales.
2026-08-07 15:45:06 +02:00
Nicolò Boschi a3298cdf03 feat(coding-agents): stage the runtime so npx installs work (#3241)
* feat(coding-agents): stage the runtime so npx installs work

Installing from an npx cache was refused outright. Everything this writes into a
host's config is an absolute path into the package, so from a cache those paths
die on the first eviction and every hook stops SILENTLY — the agent keeps
working, memory just stops. Refusing was the honest response to that, but it
made a global install mandatory for a tool whose only job is to set other tools
up.

`install` now copies the runtime to ~/.hindsight/coding-agents and wires THAT.
The problem disappears rather than moving to the user: no cache path is ever
written, and no global install is needed.

Both dist and pkgRoot are repointed in one place, so none of the twenty call
sites that bake a path into a host config needed to change, and opencode/Kilo
still get a directory with package.json and the plugin entry.

The staged directory is named `coding-agents` deliberately: MARKER matching is
what lets a re-install replace our entries and `uninstall` remove them, and it
looks for that substring in the command path.

Staging is skipped when there is nothing to copy — a checkout whose dist was
never built, and the tests — so wiring falls back to the source path instead of
pointing at a directory that does not exist.

* fix(coding-agents): make upgrades safe, including from a global install

Three upgrade paths, now verified end to end and pinned by tests:

- version to version: dist is replaced wholesale, so a file dropped in the new
  release cannot linger and stay reachable from a host config that names it. The
  wiring path never changes, so hook entries stay at exactly one.
- from a 0.0.5 global install: the old entry points into node_modules, which
  contains the marker, so it is REPLACED rather than duplicated — verified
  against the actually-published 0.0.5, not a rebuild of it.
- re-running the staged installer: previously this compared paths as strings, so
  a symlinked or differently-spelled route to the same directory would fall
  through to the copy and delete the dist it was executing from. Compared
  through realpath now.
2026-08-07 15:40:57 +02:00
Nicolò Boschi 475e04d244 release(coding-agents): v0.0.5 2026-08-07 14:12:12 +02:00
Nicolò Boschi 18a58ef3aa feat(coding-agents): carry the Codex plugin's endpoint over too (#3203)
The endpoint carry-over only read ~/.hindsight/claude-code.json, so someone
migrating off the Codex plugin silently landed on Cloud despite having a server
configured. Codex uses the same key names in ~/.hindsight/codex.json, so one
reader serves both.

The agent being installed is checked first: wiring Codex must take Codex's
server even when a stale claude-code.json is still present. It then falls back
to any known legacy config, since one server shared by both is the common case.

These two are the only superseded plugins that shipped a user config —
Cursor CLI, Copilot CLI, opencode and Cline have no endpoint to carry.
2026-08-07 14:08:23 +02:00
Nicolò Boschi ad85affb13 harden outbound webhook delivery destinations and response handling (#3239)
Webhook destination URLs are caller-supplied. Restrict where the delivery
worker will connect and what it returns to callers:

- Block private, loopback, and link-local destinations (incl. the cloud
  metadata address) by default. Operators re-permit specific hosts/CIDRs via
  HINDSIGHT_API_WEBHOOK_ALLOWED_HOSTS (e.g. 127.0.0.1 for local testing).
- Route all delivery traffic through a guarded httpx transport that resolves
  the host, rejects disallowed addresses, and pins the connection to a
  validated IP (preserving Host + TLS SNI) so a DNS name cannot be rebound to
  an internal address between validation and connect.
- Validate destinations at registration time for immediate 4xx feedback.
- Stop returning the raw upstream response body from the delivery-history API
  by default; the status code is still returned. Operators can opt in with
  HINDSIGHT_API_WEBHOOK_EXPOSE_RESPONSE_BODY. Both flags are server-level only
  (not per-bank configurable).

Adds unit + transport tests for the URL guard and API-layer body gating, plus
HTTP integration tests for registration rejection and delivery-history gating.
2026-08-07 13:23:20 +02:00
Nicolò Boschi 53948468aa fix(import): classify label entities when restoring a bank (#3236) (#3237)
`import_bank_async` resolved the target bank's config before the archive's bank
row was restored. The bank cannot exist at that point — import refuses to write
into an existing bank — so the resolve saw only global + tenant config and never
the bank's own `entity_labels`, which arrives with the archive. Retain Phase 1
then classified every label entity as regular for the whole import.

That silently disabled #3208/#3214 on imported banks: the partial trigram index
`WHERE entity_kind != 'label'` excluded nothing, so every fuzzy probe kept paying
the recheck-discard cost the index removes. It also let the import fuzzy-merge
distinct label values, which the exact-match-only path (#3187) exists to prevent.
The migration backfill does not cover it — it runs once, and a bank imported
afterwards has nothing to correct it.

Measured on an 862-document production export whose bank has a free-text label
group: 5,374 label-shaped entities, of which the import marked 0 as labels.
Correcting the classification takes entity-resolution p50 from 263 ms to 37 ms
and a single fuzzy probe from 8.28 ms to 0.38 ms.

import_bank now takes a `resolve_config` callback and re-resolves once the bank
row is in place, replaying the documents with the bank's own config.
2026-08-07 13:00:09 +02:00
Nicolò Boschi c15b565c80 fix(worker): reconcile operations a worker still owns when it stops (#3234)
An in-flight task that stops running without reaching its terminal-marking
code leaves its async_operations row 'processing' under a *live* worker,
forever: _cleanup_task has already dropped it from _active_tasks, the
recover_own_tasks sweep only runs at startup, and no dead-worker handling
applies because the worker is alive. Clients polling that operation wait
indefinitely. Two paths get there — shutdown cancelling in-flight work past
the drain timeout (CancelledError derives from BaseException, so it escapes
every `except Exception` in _execute_task_inner), and _mark_failed, itself a
DB write, failing.

Extract the reconciliation recover_own_tasks already performs into
_reclaim_own_processing_tasks and reuse it from both new sites, so the guards
that make it safe stay in one place: scoped to this worker's own rows, batch
API operations excluded, and rows at the retry limit failed rather than handed
back forever (#2675/#2834).

shutdown_graceful now waits for the cancellations to land before reconciling,
so a task partway through its own terminal write still gets to finish it.

Fixes #3228
2026-08-07 12:59:49 +02:00
Nicolò Boschi 3211952c6e test(vector-index): resolve the repair migration's parent instead of branch@-1 (#3238)
`test_migration_drops_stale_global_index` stepped below the repair migration with
`command.downgrade(cfg, "f2a6d8c4b1e9@-1")`. That is alembic's branch@relative
syntax: it counts back from the *head* of the branch containing the revision, not
from the revision itself. It meant "the parent" only for as long as f2a6d8c4b1e9
was head.

b3e8d1c6f4a9 (#3214) landed on top of it, so `@-1` began resolving to
f2a6d8c4b1e9 itself: the downgrade stopped ON the repair migration, the test
planted the stale index after the drop had already run, and the upgrade never
re-ran it. The test has been failing on main for every PR since — `assert 1 == 0`
— with nothing wrong in the migration it covers.

The parent now comes from the revision map, so the next migration added on top
cannot break it. Verified it still fails when the DROP INDEX is disabled.
2026-08-07 12:42:07 +02:00
Chris Bartholomew fd6ed94060 fix(litellm): mirror the api-slim darwin carve-out on the litellm floor (#3224)
hindsight-api-slim splits its litellm requirement by platform, because litellm
publishes no macOS wheels for any release >= 1.92.0:

    litellm>=1.93.0;        sys_platform != 'darwin'
    litellm>=1.91.3,<1.92;  sys_platform == 'darwin'

This integration raised its own floor to >=1.93.0 for the Python 3.14 cp314
wheel, but without the platform marker. The two are then irreconcilable on
darwin, so anything depending on both packages fails to resolve there at all —
not a slow install or a missing wheel, an outright resolution error. Linux is
unaffected, which is why CI stays green while local macOS development breaks.

Applies the same split here. The lockfile now carries both (1.91.4 for darwin,
1.95.0 elsewhere), so each platform still gets the newest release it can
actually install, and the cp314 reasoning behind the 1.93.0 floor is preserved
everywhere it applies.

Claude-Session: https://claude.ai/code/session_01RGtzHiRg5o4k2UiXJL3zUx
2026-08-07 09:42:51 +02:00
github-actions[bot] 8d575a9b30 chore: update star history 2026-08-07 04:13:20 +00:00
Nicolò Boschi b5aec64a49 fix(retain): bound entity-resolution candidate scoring (#3211) (#3213)
Every fuzzy candidate was scored with difflib.SequenceMatcher in a
synchronous loop on the event-loop thread, and candidate volume per query
text was bounded only by what the trigram probe returned. On a bank whose
index is polluted by many near-identical names, one resolution batch
became minutes of uninterrupted CPU: /health could not answer, the
orchestrator killed the worker mid-op, and the requeued op wedged the
next one.

Measured on a 100-mention batch (mock candidates, 10ms heartbeat task):
1M candidate rows took 10.6s at 99% CPU with the heartbeat getting zero
turns; it now takes 0.38s with a max loop stall of 14ms.

- Cap candidates per query text in SQL, ranked by the similarity score
  the probe already computes: LATERAL ... ORDER BY similarity() LIMIT on
  PG, ROW_NUMBER() OVER (PARTITION BY query_text) on Oracle. New
  HINDSIGHT_API_RETAIN_ENTITY_RESOLUTION_MAX_CANDIDATES (default 200).
- Yield to the event loop every 256 scored candidates, so responsiveness
  does not depend on the cap being configured sanely.
- Backstop truncation in _resolve_from_candidates with an O(1)-per-
  candidate ordering key, for sets built without a DB score (the "full"
  strategy's Python substring matching).

The PG rewrite also drops DISTINCT ON (e.id), which deduplicated across
query texts: an entity matching two mentions in the same batch was
silently dropped as a candidate for one of them (on a 2-text fixture the
old query returned 1004 + 997 of 2001 matches instead of 2001 each).
2026-08-07 02:46:11 +02:00
Nicolò Boschi f9fb3e934a perf(entity-resolution): exclude label entities from the trigram fuzzy-match index (#3208) (#3214)
Label entities resolve by exact match only, yet their rows were still
covered by the shared trigram index — every fuzzy probe for a regular
entity pulled them into its candidate set only to discard them in the
bitmap recheck. On banks where a free-text label group accumulated tens
of thousands of mutually-similar values this dominated database CPU
under ingest bursts.

- Add entities.entity_kind ('regular'/'label', CHECK-constrained) on
  both dialects, set at insert time by the resolver; the Phase-2
  reassert carries the kind so a pruned label parent resurrects as a
  label.
- Rebuild the PG trigram index as a partial index excluding label rows
  (CONCURRENTLY, create-before-drop) and add the matching
  entity_kind != 'label' predicate to the trigram candidate query and
  the Oracle UTL_MATCH fuzzy scan.
- Migration backfills existing rows per bank by classifying
  canonical_name against the bank's entity_labels config with the same
  is_label_entity() the resolver uses.
- Fix the label classification gating on the enum lookup set: a config
  with only text/map groups builds an empty set, so its labels were
  never recognised — neither by the #3187 exact-lookup split nor by the
  new insert-time kind.

Bank import needs no changes: transfer archives treat entities as
derived data and re-resolve them through the standard retain Phase 1,
which now stamps the kind.
2026-08-06 19:41:58 +02:00
Nicolò Boschi afdea53a96 fix(maintenance): stop the scheduled mental-model refresh from enqueueing duplicates (#3210) (#3212)
The maintenance loop runs in every API/worker process with no leader election, so
N processes were N schedulers making the same due-and-stale judgment each interval.
The in-flight guard that should have prevented a second refresh lives in the
discovery routine `mental_models_with_cron()` — a *read*, so every process saw the
same "nothing in flight" snapshot and inserted its own operation. A few hundred due
models became thousands of queued refresh ops, which occupy claim slots, inflate
queue-depth (an autoscaler input) and delay unrelated tenants.

The check now rides on the INSERT itself: with
`submit_async_refresh_mental_model(skip_if_in_flight=True)` the operation row is only
materialised `WHERE NOT EXISTS` a pending/processing `refresh_mental_model` op for the
same `(bank_id, mental_model_id)`, so the check cannot be separated from the write.
The submit also takes the existing `FOR NO KEY UPDATE` bank-row lock that
`dedupe_by_bank` uses (#1842) — no extra round-trip — so two simultaneous submits
serialize instead of both passing the READ COMMITTED snapshot.

Only the cron scheduler opts in; explicit user-triggered refreshes (HTTP, MCP,
consolidation-triggered) still queue unconditionally and are never swallowed.
2026-08-06 19:19:23 +02:00
Nicolò Boschi 25a7237b1f fix(entities): dedup same-batch entity variants via pg_trgm (#3107) (#3197)
* fix(entities): dedup same-batch entity variants via pg_trgm (#3107)

Entity resolution only ran fuzzy matching against already-persisted rows, so
the first time surface-form variants of one entity appeared together in a
single retain (e.g. 'Wren 🕯️'/'Wren 🗯️', 'Aster'/'aster 0', a 'Merrivale'/
'Merryvale' typo), each variant created a distinct entity — fragmenting
identities with no way to tell wrong attributions from right ones.

Add an in-batch clustering pass over the new (non-label) names about to be
created: a pg_trgm similarity self-join (the same trigram mechanism as
candidate recall, no temp table needed for the small N) pairs them, union-find
clusters the pairs, and each cluster collapses to one entity under a
deterministic canonical name. pg_trgm ignores non-alphanumerics, so decoration
variants score 1.0; the 0.5 merge cutoff sits in a clean gap below genuinely
distinct names ('Aster'/'Astrid' 0.30), which stay separate.

Scoped to PostgreSQL+pg_trgm (default). Label entities are excluded (GH-1558),
and Oracle / the pg_trgm-absent 'full' fallback keep exact-match grouping — a
deliberate asymmetry, since Oracle's UTL_MATCH is prefix-biased and
emoji-sensitive and needs separate calibration.

No new config: reuses the existing trigram path; the merge cutoff is a
calibrated constant, distinct from the recall-only pg_trgm.similarity_threshold.

* refactor(entities): make in-batch merge cutoff configurable + tighten perf cap

Address review on #3107 in-batch dedup:
- Promote the 0.5 merge cutoff to config HINDSIGHT_API_ENTITY_INTRABATCH_MERGE_SIMILARITY
  (static, validated (0,1], default 0.5), threaded to EntityResolver. Docs + env template.
- Lower the O(N^2) self-join cap from 1000 to 250 after benchmarking on the retain hot path
  (measured: ~4ms@100, ~24ms@250, ~95ms@500, ~390ms@1000 names) — 250 stays well above any
  realistic new-entity count while bounding worst-case DB time.
- Drop the fictional-name examples from code comments.

Verified end-to-end against a real local API: emoji/case/suffix/longer-typo variants collapse
to one entity; distinct ('Aster'/'Astrid') and short typos ('Corvin'/'Corvyn', trgm 0.40) stay
separate.

* docs(entities): drop remaining fictional-name examples from code comments

* perf(entities): compute in-batch trigram similarity in-memory, not via Postgres

Replace the pg_trgm SQL self-join with an in-memory trigram-similarity reimplementation
(_trigram_similarity), verified byte-for-byte against Postgres pg_trgm across emoji / accent /
CJK / hyphen / apostrophe cases — so the calibrated 0.5 merge cutoff is unchanged.

Benefits:
- No DB round-trip on the retain hot path (was ~4/24/95/390ms at N=100/250/500/1000; now
  ~0.8/5/22/81ms, pure CPU, and off the retain transaction's connection).
- Backend-agnostic: in-batch dedup now runs identically on PostgreSQL, Oracle, and the
  pg_trgm-absent 'full' fallback — removes the previous PG-only asymmetry and its guard.
- Simpler: drops the SQL, the _ops call, and the async DB hop; _intrabatch_canonical_map is
  now a pure function.

Add DB-free unit tests asserting _trigram_similarity equals pg_trgm's values and that the pair
finder respects the threshold.
2026-08-06 18:02:51 +02:00
Ben 5e557dbfc5 feat(templates): Hermes-branded bank templates, grounded in real Hermes user stories (#2996)
* feat(templates): add Hermes-branded bank templates (gateway, orchestrator, support)

The Templates Hub had 3 templates and only one was tagged for Hermes
(personal-assistant), and it was thin. Hermes runs as a desktop assistant,
a gateway bot across Telegram/Discord/Slack, and multi-agent orchestrations —
none of which had a starter template.

- Add `hermes-gateway-bot` — per-person profiles kept distinct, learns each
  community's norms, tracks open threads across users.
- Add `hermes-orchestrator` — decisions + rationale, ownership map, escalation
  playbook; high literalism/skepticism for coordination (the multi-agent pattern).
- Add `customer-support` — issues/resolutions/sentiment/account context, high
  empathy, "never re-ask known details".
- Enrich `personal-assistant` — add reflect_mission, dispositions, and directives.
- Tag `conversation` with the `hermes` integration (default chat option).
- Add `orchestration` and `support` categories to the Templates Hub filter.

All six manifests validate against the bank-template JSON Schema
(scripts/check-templates.mjs).

* feat(templates): ground Hermes templates in real Hermes user stories

Reworked the set against Nous's 262 published Hermes user stories:

- personal-assistant: sharpened toward the real flagship pattern — proactive,
  cross-platform (iMessage/WhatsApp/Signal/Discord), routine/schedule-aware;
  added a "Routines & Schedule" model and an "act on what you remember" directive.
- hermes-gateway-bot: centered on per-channel persona consistency (the Horse
  Racing / Family WhatsApp / QQ pattern) with a "Channel Persona & Norms" model
  and a "stay in character per channel" directive.
- hermes-orchestrator: broadened beyond escalation to cover build pipelines
  (plan→code→QA→ship) and chief-of-staff cross-project coordination.
- coding-agent: tagged `hermes` (dev workflow is the single largest Hermes
  category), added a "Review Patterns" model + dispositions.
- research-assistant (new): research/monitoring/second-brain agents — learns
  interests, tracks what to ignore to sharpen curation, compounds knowledge
  with source provenance.
- Added a "research" category to the Templates Hub filter.

All 8 templates validate against the bank-template JSON Schema.

* test(templates): e2e-import every shipped Hermes template

Integration test (real pg0 + app) proving each `hermes`-tagged manifest in the
catalog imports: creates the bank, applies config, and creates its mental
models + directives. Also covers idempotent re-apply (updated, not duplicated)
and additive layering of a second template.
2026-08-06 10:50:26 -04:00
Nicolò Boschi d637008fbe fix(migrations): remove stale global memory_units vector index via migration; make reconcile hands-off (#3204)
For per-bank vector backends, every search is bank + fact_type scoped and
served by the idx_mu_emb_* partial indexes; migration d5e6f7a8b9c0 drops
the global idx_memory_units_embedding for exactly this reason. But older
versions of the post-migration reconcile (ensure_vector_extension)
recreated the index when they found none, so schemas provisioned or
reconciled in that window still carry it — paying a second vector graph
insertion on every memory_units write for an index no query uses.

Two changes, split by responsibility:

1. Migration f2a6d8c4b1e9 drops the leftover index (no-op for ScaNN,
   which keeps a global index by design; PG-only, Oracle never had the
   old reconcile). Index DDL belongs in the versioned migration path,
   not runtime code — DROP INDEX takes an ACCESS EXCLUSIVE lock and must
   not fire at unpredictable startup/provisioning times.

2. ensure_vector_extension is now strictly hands-off for memory_units on
   per-bank backends: never creates the global index (as before), never
   drops one, and no longer routes it through the type-mismatch
   reconcile — which would otherwise recreate a global index with the
   new type on a backend switch.

Tests: reconcile leaves a legacy global index untouched; alembic
downgrade → plant legacy index → upgrade removes it; fresh schemas still
get no global index. Tests share one embedded-postgres on a fixed port,
so they are pinned to one xdist group.
2026-08-06 13:51:21 +02:00
Nicolò Boschi e1b3d438ef feat(coding-agents): local daemon mode, and pick the server at install time (#3193)
* feat(coding-agents): local daemon mode, and pick the server at install time

The package that supersedes the per-agent plugins had no embedded mode at all:
apiUrl defaulted to Cloud and there was no daemon lifecycle anywhere. The old
Claude Code plugin auto-managed hindsight-embed (scripts/lib/daemon.py), so
anyone without a Cloud account or a server lost a working setup in the move.

Three modes, resolved the way the old plugin resolved them: an external API
(cloud or self-hosted), a healthy local server adopted as-is, or a daemon we
start. `install` asks once on a terminal; `--server cloud|self-hosted|daemon`
scripts it, and a config that already names a server is never re-asked or
rewritten.

Lifecycle is delegated to @vectorize-io/hindsight-all, which owns the uvx
invocation, profile creation and the macOS Metal workaround. It has zero
dependencies and is inlined by tsup, so hook bundles stay self-contained.

Design points worth knowing:

- Daemon mode resolves its URL inside resolveConfig, so all eight existing
  client-construction sites work unchanged instead of threading a mode through
  each one.
- A cold start (uvx download + model load) outlives every hook timeout, so it is
  never awaited inline: SessionStart spawns a DETACHED starter, the same idiom
  seeding and the codebase survey already use, and each caller waits only a
  bounded slice of its own budget. The prompt hook never starts a daemon.
- There is deliberately no stop-on-session-end, unlike the old plugin: one
  daemon serves every agent and repo, so ending one session must not cut memory
  out from under another. daemonIdleTimeout retires it instead.
- Port 9077, not hindsight-all's 8888 — 8888 is the conventional port for a
  server the user runs, and a daemon must not squat on it.
- Daemon settings keep the old plugin's env names (HINDSIGHT_API_PORT,
  HINDSIGHT_DAEMON_IDLE_TIMEOUT, HINDSIGHT_EMBED_VERSION,
  HINDSIGHT_EMBED_PACKAGE_PATH), so a migrating environment carries over.

Prerequisites are reported at install time rather than failing silently later:
uv on PATH, an LLM for local extraction (explicit provider, then a known key
env, then the Claude Code CLI which needs none), and on macOS a current Rust
toolchain — litellm publishes no macOS wheel, so a Mac builds it from source and
its crates pin a recent rustc. These are advisory, not blocking: unlike the
devin-cli preflight, every one of them can be installed after the fact.

* fix(coding-agents): treat a down daemon exactly like a down server

Two divergences between daemon mode and the api modes, both removed so the
client and everything downstream of the resolved URL behave identically:

- The Stop hook gated retain on ensureDaemon's result, so a daemon that wasn't
  up made retain SKIP — the conversation was dropped — while an unreachable
  Cloud or self-hosted server let retain proceed and fail through buildRetain's
  handler, which already logs and emits `retain_failed` with the error. A local
  port being closed is just a connection failure; it now takes the same path.
  ensureDaemon is called for its side effect only and its result is ignored.

- ensureDaemon's `allowStart: false` branch, and the `daemon_not_ready`
  diagnostic it emitted, were never reached: the prompt hook has no daemon code
  at all, so only a unit test exercised them. Dropped, along with the option;
  the module docstring described that unwired prompt-path behaviour and now
  describes what actually runs.

The remaining daemon-mode work is a side effect at two lifecycle points
(SessionStart and Stop) plus one ternary in resolveConfig. Nothing downstream
knows which mode is active.

* feat(coding-agents): carry the server endpoint over from the old plugin

Installing over an existing ~/.hindsight/claude-code.json now adopts its
endpoint — hindsightApiUrl -> apiUrl, hindsightApiToken -> apiToken, and an
empty URL meaning the local daemon, exactly as that plugin read it. Someone
running against a self-hosted server or a daemon has already decided where
their prompts and transcripts go; defaulting to Cloud would silently redirect
them. --server still overrides.

ONLY the endpoint. None of the old plugin's ~40 behavioural settings are
translated: 12 recall*, 7 retain*, the mission pair and dynamicBankGranularity
describe a pipeline this package replaced, and reinterpreting them would be
guesswork.

Conversations keep coming from local transcripts (--import-conversations),
re-extracted as new documents. That is not a fallback — the old bank cannot be
split by repo on its own. Its default was a SINGLE static bank (dynamicBankId
defaults to false, so everything landed in `claude_code`) whose documents record
only retained_at, message_count and session_id, with nothing identifying the
project. Attributing them means joining session_id back to the cwd in the local
transcript, so the transcripts are required either way.

Also corrects the migration docs, which claimed the old plugin scoped a bank per
agent per project (true only in dynamic mode, not the default) and that the old
bank could not be merged (document-transfer does merge, with on_conflict).
2026-08-06 12:40:15 +02:00
Nicolò Boschi 8de576b4b7 fix(deps): make macOS installs work without a Rust toolchain (#3199)
litellm publishes no macOS wheels for any release >= 1.92.0, so every
macOS install of the published hindsight-api compiles litellm's sdist
Rust/PyO3 bridge. That silently required a Rust toolchain, and litellm
1.95.0 raised the bar further (vendored aws-smithy crates need
rustc >= 1.94.1), breaking even machines with a recent-but-not-newest
rustc. A stock 'uvx hindsight-api' / hindsight-all install on macOS
failed during daemon startup.

Pin litellm to the 1.91.x line on darwin only - the last releases that
ship pure-python py3-none-any wheels - so installs need no compiler at
all. Linux and Windows keep the existing >= 1.93.0 floor (litellm
publishes manylinux/win_amd64 wheels there, including cp314).

Verified on macOS arm64: fresh workspace resolve picks litellm 1.91.4
(pure wheel), the embedded daemon boots via @vectorize-io/hindsight-all,
and retain/recall run real LLM extraction through litellm successfully.

Revisit when litellm ships macOS wheels (BerriAI/litellm#31261).
2026-08-06 12:22:02 +02:00
Nicolò Boschi 797faf7981 fix(delete): sweep orphan entities when no relink victims were enqueued (#3198)
Deleting an isolated document left its entities behind. `delete_document`
submits graph maintenance precisely so the bank-wide orphan sweep reclaims
them, but `submit_async_graph_maintenance` short-circuits on `no_work` when
`graph_maintenance_queue` is empty — and that queue is only fed by
`enqueue_relink_victims`, which finds nothing for a document no other unit
links to. So the job was never created and `prune_orphan_entities` never ran,
leaving banks reporting 0 documents / 0 memory units but N entities.

The short-circuit is a real optimisation for retain, which calls this
unconditionally on every ingest. So gate it instead of removing it:
`force_sweep=True` skips the pre-check for callers that dropped unit→entity
references and therefore need the sweep regardless of relink work — the
document/memory/bulk delete paths, and the two curation paths whose comments
already claimed to force a sweep.

Fixes #3196
2026-08-06 10:40:15 +02:00
github-actions[bot] 436bc7c156 chore: update star history 2026-08-06 04:26:23 +00:00
Nicolò Boschi 59d3f078cd fix(api): publish LabelGroup schema for entity_labels in OpenAPI (#3107) (#3190)
DryRunExtractRequest and BankTemplateConfig typed entity_labels as a bare
list / list[dict], so the LabelGroup shape never appeared in the OpenAPI
document — callers hit a validation error with no discoverable schema.

Type both as list[LabelGroup] with a mode='before' validator that preserves
the legacy free_values/multi_value input shape, and normalize the dry-run
override back to plain dicts so the resolved-config path is unchanged.
Regenerated OpenAPI spec, bank-template schema, docs-skill, and SDK clients.
2026-08-05 17:54:32 +02:00
Nicolò Boschi 359e619a42 feat(recall): per-budget reranker candidate cap via env config (#3107) (#3191)
The cross-encoder always pre-filtered merged candidates to a flat 300
(reranker_max_candidates) regardless of the recall budget, and the
cross-encoder is the dominant cost of a large recall. Add a per-budget
override mapping (RERANKER_MAX_CANDIDATES_LOW/MID/HIGH) so operators can
trade rerank depth for latency at budget=low without a new API parameter.

The per-level values default to 0 = unset, falling back to the flat
reranker_max_candidates, so recall behavior is 100% unchanged until set.
_resolve_reranker_max_candidates mirrors _resolve_thinking_budget; the
resolved cap is threaded into _search_with_retries (other callers keep the
flat default). Docs + env template + docs-skill updated.
2026-08-05 16:50:58 +02:00
Parafee41andNicolò Boschi f6b3ce3e33 fix(llm): release concurrency permits during retry backoff (#3145)
* fix retry concurrency permit scope

* chore: sync generated docs schema

* fix: scope concurrency permits to provider attempts

* fix: gate responses retries per attempt

* review fixes: keep .queued stage until permits, stamp .backoff, codex attempt labels, test coverage

- llm_wrapper: attempt-gated providers no longer stamp the bare base stage
  before holding any permit — a call queued on the semaphore stays '.queued'
  until the provider's post-acquire 'attempt=N' stamp (#3002), and
  _attempt_permits suffixes '.backoff' when an attempt fails so backoff
  sleeps are distinguishable from in-flight requests.
- codex tools path: attempt-numbered stage labels (1/2, 2/2) and 401/403
  before the reactive refresh logs as warning, not error.
- typing: deprecated typing.AsyncContextManager -> contextlib.AbstractAsyncContextManager;
  uniform 'is not None' guards; document attempt_context in LLMInterface.
- tests: end-to-end regression through the real OpenAI-compatible retry loop
  (permit released during backoff, reacquired on attempt 2), cancellation
  while queued on the global permit releases the per-op permit, stage
  queued->attempt->backoff lifecycle; fix provider stubs missing
  supports_attempt_scoped_concurrency.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-05 16:07:07 +02:00
Parafee41andNicolò Boschi 9d828cc7b1 fix(embed): bound daemon log growth (#3165)
* fix(embed): bound daemon log growth

* fix embed log rotation lifecycle

* chore: sync generated embed docs

* fix(embed): correct the retention claim and harden profile deletion

Review follow-ups on the daemon log rotation:

- The docs claimed a peak retained size of MAX_BYTES x (BACKUP_COUNT + 1),
  then immediately said an uninterrupted run is not bounded. Both cannot
  hold: size is only checked at startup, so a long run grows past
  MAX_BYTES and is then kept whole as the first backup. Say when the
  bound actually applies and how to keep it meaningful.

- delete_profile() unlinked each retained log without a guard, ahead of
  the metadata cleanup. One unremovable log (still open on Windows) threw
  and left the profile registered in metadata with its config already
  gone. Warn and continue instead, so the profile is always deregistered.

- Drop the import-time env parsing. The values were re-parsed per start
  from the merged profile env anyway, so the module-level constants only
  survived as that parse's fallback and as default arguments no caller
  used -- and an invalid value warned once at import, before logging is
  configured, and again at startup.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-05 15:56:33 +02:00
Nicolò Boschi 42e8c53da9 fix(consolidation): keep observations in their source facts' language (#3181)
* fix(consolidation): keep observations in their source facts' language

Consolidation's prompt is entirely English and only carried a language rule
when HINDSIGHT_API_LLM_OUTPUT_LANGUAGE was set, so with it unset multilingual
models drifted: Chinese source facts produced English observations (#3166).
Retain already defaults to preserving the input language; consolidation now
does the same, and the rule settles the three ambiguous cases — language is
picked per observation from its own source facts, an update rewrites the whole
observation in the new facts' language (so drifted banks self-heal), and proper
nouns/identifiers are never translated.

An explicit output language still wins: the default rule is dropped in that
case rather than left to contradict "translate everything into X".

Reproduced and verified against gpt-oss-120b: before, the issue's Chinese facts
yielded "The user often walks their pet in the park on weekends."; after, they
yield 用户周末经常带宠物去公园散步。

Fixes #3166

* refactor(consolidation): compress the language rule

The rule rides in the system prefix of every consolidation call, so on
providers without prompt caching its size is paid per batch. Four sentences
carry the same four constraints the bullet list did, at 69 tokens instead of
223. Re-verified against gpt-oss-120b: identical output on all four cases
(Chinese creates, English observation updated by a Chinese fact, explicit
English override, English facts left alone).

* chore(docs): resync hindsight-docs skill for the multilingual page

* fix(consolidation): make the update-path language rule explicit

CI showed Gemini merging a Chinese fact into an existing English observation
by editing the English sentence in place, keeping it English — the same result
with the verbose rule and the compressed one, so wording length was not the
problem. Name the failure mode instead: don't edit the old text, compose the
merged observation from scratch in the new facts' language.

Also stop the test asserting the merge routing. Whether the model updates the
existing observation or records a sibling is its call (gpt-oss-120b does both
across runs); asserting UPDATE made this a flaky test of merge behaviour rather
than of language. It now checks every emitted text, create or update.

* test(consolidation): absorb LLM sampling noise in the language tests

All three tests now go through one helper that retries up to three times while
the output language is wrong, so a single stray response doesn't fail the suite
— the same shape test_multilingual.py uses.

The update test is additionally xfail(strict=False): Gemini keeps an existing
observation's English wording when a Chinese fact updates it, editing in place
rather than recomposing, and did so identically across three CI runs and three
prompt wordings. The OpenAI-compatible models the issue reports against comply,
so it xpasses there. The creates test stays a hard gate — that is the reported
bug, and every model tried passes it.
2026-08-05 15:16:06 +02:00
Nicolò Boschi ba5a4813b3 fix(mental-models): never overwrite a document with a delta-window candidate (#3182)
* fix(mental-models): never overwrite a document with a delta-window candidate

A delta refresh runs reflect with created_after = last_refreshed_at, so its
candidate only covers memories newer than the last refresh. When the delta
operations failed to reach the document, that candidate was written as the
whole document and the watermark advanced past it — everything grounded in
older memories was gone for good, while the log said "falling back to full
synthesis" and the operation completed successfully (#3112).

Refuse it instead, keyed on the window rather than on each failure branch so
future ones inherit the guard: when delta was requested, was not applied, and
the reflect window was narrowed, preserve the document and raise
MentalModelRefreshError. The watermark stays put, so the retry reads the same
facts again.

Also:
- Treat "the model emitted operations but every one was rejected" as a delta
  failure. The document is unchanged, so persisting it looked like a clean
  refresh while dropping that run's facts outside every future delta window.
- Recover from an unusable structured_content by re-parsing the stored
  markdown instead of giving up — nothing else rewrites that column, so
  failing there wedged the model permanently.
- Record skipped operations even when the delta did not land, count them in
  the operation's result_metadata, and warn when a partial skip means some of
  this run's evidence never reached the document.
- Route every failure through one preserve-and-fail helper, so the
  structured-output failure now leaves the same reflect_response audit trail
  the other two already did.

* docs(mental-models): describe what a failed delta refresh does to the document

The delta section promised the opposite of what the code now does — "zero valid
operations means an identical document … never corrupt it" read as a guarantee
while a failed delta was in fact replacing the document with a partial one. Say
plainly that the document is kept and the refresh fails, and list the two new
diagnostic values.
2026-08-05 14:47:13 +02:00
yufanw03andwangyufan03 f2ae61eda3 feat(query-analyzer): configurable dateparser locale detection (#3154)
* feat(query-analyzer): configurable dateparser locale detection

search_dates() runs auto-detection across 200+ locales on every recall.
This costs 62 ms P50 on the recall critical path, and misdetects English
queries as other locales: "May 23, 2023" parses to 2023-11-23 after the
detector picks 'bas' (Basaa), where May maps to November.

Adds an optional languages restriction to DateparserQueryAnalyzer, wired
through HINDSIGHT_API_QUERY_ANALYZER_LANGUAGES. Default stays None (full
auto-detection), since restricting degrades explicit dates in unlisted
locales to a wrong date rather than to no constraint.

* docs(configuration): document HINDSIGHT_API_QUERY_ANALYZER_LANGUAGES

---------

Co-authored-by: wangyufan03 <[email protected]>
2026-08-05 14:34:44 +02:00
468cc4b7d7 fix(embeddings): honor query and document prompts locally (#3032)
* fix(embeddings): honor query and document prompts locally

* fix(embeddings): require sentence-transformers >=5.0 for local asymmetric encoding

encode_query()/encode_document() only exist from sentence-transformers 5.0
onwards. The local-ml extra pinned >=3.3.0, so on 4.x the new code path was an
AttributeError at the first encode (recall/retain), not at startup. The extra
was only accidentally safe because it also pins transformers>=5.5.0, which ST
<5 caps out; docker/docker-compose/custom-models/Dockerfile mirrors the pins
with transformers>=4.53.0 and could genuinely resolve to ST 4.x.

Also:
- assert the real SentenceTransformer class exposes both entry points; the
  existing test drives a MagicMock, so it passes on any version
- explain why the model's own entry points are used instead of prefixing here,
  and note that prompt-less models are unaffected
- document the one case that needs a re-index: a local model that instructs the
  stored side as well as the search side

---------

Co-authored-by: jpmf33 <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-05 11:58:35 +02:00
Nicolò Boschi dc9d033d16 feat(entity-resolution): make pg_trgm similarity threshold configurable, set at connection setup (#3188)
Add HINDSIGHT_API_ENTITY_TRGM_SIMILARITY_THRESHOLD (static, default 0.15),
applied once as `SET pg_trgm.similarity_threshold` in the pool's per-connection
setup callback alongside the other session GUCs. Because that callback is wired
as both asyncpg `init` and `setup`, the value survives the release-time RESET ALL
and every re-acquire.

Entity resolution's trigram probe no longer toggles the threshold per query — it
just runs against a connection that already has it set — so the runtime
SET/RESET (and its RESET-on-error handling) is removed. The value is validated
to pg_trgm's (0, 1] range at config load so a bad setting fails fast instead of
breaking every connection's setup.
2026-08-05 11:44:58 +02:00
Nicolò Boschi bb895faaf9 perf(entity-resolution): skip fuzzy probing for exact-match-only label entities (#3187)
Label entities resolve by exact match only (their canonical names are
user-defined and must not be fuzzy-merged). Sending them through the pg_trgm
candidate probe — and the Oracle Jaro-Winkler equivalent — only returns
similar-but-distinct label values that are always discarded downstream. The
cost grows with the number of values a label accumulates: each probe returns
proportionally more candidates, all thrown away.

Partition entity texts before the candidate fetch: resolve label texts with an
exact lookup on the unique (bank_id, LOWER(canonical_name)) index, and only send
non-label texts through the fuzzy probe (also skipping the pg_trgm threshold
SET/RESET entirely when there are no fuzzy texts).

No behavior change — label resolution was already exact-match-only in
_resolve_from_candidates; this only removes the wasted candidate scan.
2026-08-05 11:13:52 +02:00
github-actions[bot] 926f752912 chore: update star history 2026-08-05 04:25:40 +00:00
Ben 94d65a591a release(obsidian): v0.2.0 2026-08-04 17:10:00 -04:00
Ben aad36e5eee chore(obsidian): bump manifest + versions to 0.2.0 2026-08-04 17:07:15 -04:00
Ben 93fa0b016b feat(obsidian): headless CLI vault ingestion (hindsight-obsidian-sync) (#3179)
* refactor(obsidian): decouple HindsightClient from obsidian via a Transport seam

Introduce a Transport abstraction so the HTTP client no longer imports
`obsidian` directly. The plugin injects an obsidian-transport (requestUrl,
to escape the renderer CORS sandbox); a headless CLI will inject a
fetch-based transport. This lets both frontends share one client and one set
of request semantics instead of maintaining divergent copies.

No behavior change: existing client tests pass unchanged after switching from
mocking requestUrl to injecting a fake transport (same request shape).

* feat(obsidian): headless CLI vault ingestion (hindsight-obsidian-sync)

Add a headless second frontend over the shared SyncEngine so a vault can be
ingested into Hindsight from an always-on server with no Obsidian desktop app
(issue #3128). Because it drives the same engine as the plugin, it produces
identical document ids, scope tags, and prune-ownership — the two ingesters
never fight or duplicate.

New Node modules (src/node/):
- fs-vault.ts    — filesystem SyncVault (recursive *.md walk, POSIX-relative
                   paths, ms mtime/ctime, skips dotfolders)
- fetch-transport.ts — fetch-based Transport for the client (no renderer CORS)
- json-index.ts  — sync index persisted to JSON, atomic write; defaults to
                   ~/.hindsight/obsidian/<vault>.json (outside the vault so
                   Obsidian Sync never propagates it)
- cli.ts / cli-bin.ts — `hindsight-obsidian-sync reconcile --vault <p> --bank
                   <id>` with env fallbacks, --include/--exclude/--prefix-doc-id,
                   and a chokidar --watch mode

Packaging: second esbuild target builds dist/cli.js (node, shebang); package.json
gains the bin, a files allowlist, and chokidar. README documents the CLI, the
out-of-vault index, and the shared-scope constraint when running both frontends
against one bank.

Tests (33 new, 79 total): FsVault, json-index, fetch transport, CLI arg parsing
+ watch handlers + a full runCli path (fetch mocked), and a full-stack reconcile
suite over a real temp vault (create/update/skip/delete/rename/exclude/prefix/
prune-ownership) plus a parity check that the filesystem and in-memory (plugin)
vaults emit byte-identical retain requests.

* test(obsidian): broaden CLI coverage with real-framework and e2e tests

Add higher-fidelity tests beyond the mocked units, and refactor watch mode to
be testable:

- Extract watchVault() from startWatch() so a test can drive a REAL chokidar
  watcher over a temp vault and then close it. New watch.spec.ts asserts
  create/modify/delete on disk flow through to the engine and non-markdown is
  ignored (polling + tight awaitWriteFinish for CI determinism).
- e2e-http.spec.ts runs runCli against a real node:http server — the full
  FsVault → SyncEngine → HindsightClient → fetch → sockets path with nothing
  mocked: asserts the retain POST (bearer token, document id, scope tags) and a
  real DELETE on prune, plus exit-code 1 when the server is unreachable.
- fetch-transport: full-stack HindsightClient error propagation + health()
  true/false, a fetch-rejection case, and a cross-transport parity test proving
  the same call yields an identical request under two transports.
- reconcile: frontmatter tags + created-date timestamp + vault metadata,
  empty-body skip, and includeFolders-only scoping.
- cli: --exclude threaded end-to-end through runCli.

Pin chokidar to ^4.0.3 (bundles its own TS types). 90 tests pass (+11).

* fix(obsidian): make dual-ingester parity test platform-deterministic; add CLI to docs-site page

- Parity test pinned mtime via utimes but ctime/birthtime can't be set and
  differs across OSes (macOS clamps birthtime to a past mtime, Linux doesn't),
  so the FS vault's created-date tags diverged from the memory vault's on Linux
  CI. Give both notes a frontmatter created: date so the tags come from the note.
- Add a 'Headless / CLI ingestion' section to the public docs-site page
  hindsight-docs/docs-integrations/obsidian.md (mirrors the package README).

* docs(obsidian): regenerate skill mirror for the headless CLI section

* style(obsidian): apply repo prettier formatting to CLI + tests
2026-08-04 17:00:36 -04:00
BenandClaude Opus 4.8 559ab9dbff blog: Per-User Memory for AI Products — Multi-Tenant Patterns (#3180)
* blog: Per-User Memory for AI Products — Multi-Tenant Patterns

A patterns guide for SaaS/AI-product builders on isolated per-user memory: the
hard-boundary vs soft-partition decision (banks vs tags), reading across
private/org/global scopes without a cross-bank query, a CI cross-tenant
leakage test, GDPR deletion, and scaling to many tenants on one store.
Editorial deep-dive cover.

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

* blog(per-user-multi-tenant): swap cover to dark comparison-card template

Reuse the context-window-is-not-memory template (dark, teal-accent title +
comparison cards). Positive framing: "Every user gets their own bank" with
BANKS (isolate a tenant) + TAGS (partition a bank).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-04 13:47:35 -04:00
Nicolò Boschi 76d8ba8a51 feat(reranker): failover chain via indexed HINDSIGHT_API_RERANKER_<n>_* members (#3176)
* feat(reranker): failover chain via indexed HINDSIGHT_API_RERANKER_<n>_* members

An unreachable reranker took the whole recall down with a 500: the stage is a
refinement, but nothing on the path treated it as optional and no setting could
change that (#3168).

Configure extra rerankers by index, mirroring the multi-LLM chain: the unindexed
config is member 0 and HINDSIGHT_API_RERANKER_<n>_* declares the fallbacks, tried
in order when a member fails (error, timeout, or a wrong-length response). Every
setting of member n carries the same index, so a fallback gets the full
provider-specific knob set; it inherits nothing from the primary or from the
shared provider keys, and missing-setting errors name the exact indexed variable.

Ending the chain with the existing `rrf` provider makes recall fail open — the
retrieval order comes back untouched instead of the request failing. With no
indexed members (the default) behaviour is unchanged.

Round-robin is deliberately not offered: reranker scores are not comparable
across providers, so rotating members per request would make score thresholds
non-deterministic.

A member that fails to initialize is logged rather than fatal, and retried on the
next request that reaches it — otherwise a chain configured for a flaky primary
would still die at startup. `CrossEncoderModel.blocking_init` replaces the
`provider_name == "local"` checks at the two init call sites, so the chain can
offload its own in-process members instead of being threaded by its callers.

* fix(reranker): tolerate duck-typed cross encoders, sync the docs skill

Tests inject cross encoders that don't subclass CrossEncoderModel, so reading
`blocking_init` off them raised AttributeError in test-api. Read it with getattr,
matching the provider_name read a few lines away in _recall, instead of making
every test double implement the property.

Also regenerates skills/hindsight-docs for the new configuration section (the
retain/openapi/coding-agents hunks are pre-existing drift on main that
verify-generated-files requires be committed).
2026-08-04 19:00:27 +02:00
Sanderhoff-alt 362c719f1e fix(config): report deleted import banks as conflicts (#3035)
Raise a dedicated persistence conflict when a validated bank config
update can no longer find its bank row.

Map this race to HTTP 409 for template imports while preserving PATCH
404 behavior and 400 responses for invalid configuration. Add an
event-coordinated regression test for the deletion window.
2026-08-04 18:38:55 +02:00
Nicolò Boschi c335192cd3 fix(consolidation): guard observation_history append on 0-row observation UPDATE (#3161)
* fix(consolidation): skip observation_history when UPDATE matches 0 rows

The source-liveness checks in _execute_update_action guard the *source*
memories, but the observation row itself (UPDATE ... WHERE id = $5) can be
concurrently invalidated/deleted, matching 0 rows. The code then fell through
to _append_observation_history, whose INSERT carries an observation_id FK onto
memory_units — raising ForeignKeyViolationError, a (correctly) non-retryable
integrity failure that marked the whole consolidation op failed for a row that
simply no longer exists.

Capture the UPDATE status in the SQL branch and bail out (return None) before
the history append when 0 rows matched. The store/upsert branch cannot hit the
0-row case, so it needs no guard. The Oracle wrapper reshapes rowcount into the
same "UPDATE <n>" form, so the parse is dialect-safe (mirrors config_resolver).

Adds mock-level regression tests covering both the 0-row bail and the
positive (rowcount==1) control.

* refactor(db): add execute_rows_affected primitive; use it for the 0-row guard

Move the command-tag rowcount parse out of consolidation business logic and
into the pg/oracle connection layer. DatabaseConnection.execute_rows_affected
runs a DML statement and returns a plain int, normalizing the dialect-divergent
result shape the same way parse_json normalizes JSON columns: asyncpg returns
the tag directly, the Oracle connection reshapes cursor.rowcount into the same
trailing-count form, so parsing the last token is dialect-safe.

_execute_update_action now calls conn.execute_rows_affected(...) and checks the
int directly instead of hand-parsing an "UPDATE <n>" string. Adds a parser unit
test covering the tag shapes both dialects emit.
2026-08-04 18:37:39 +02:00
Derek Bouius 11ecfe54c2 test(bank): add regression test for per-bank index deadlock retry (#2984)
#2943 fixed the shared-DB test-api deadlock flake by wrapping
get_or_create_bank_profile in retry_with_backoff, but shipped without a unit
test for that retry. Add a deterministic, no-DB test: an ops stub raises
DeadlockDetectedError on the first per-bank index DDL then succeeds, and the
test asserts the profile creation retries (two index-DDL attempts) and the
bank ends up created.

Guards against a future refactor silently dropping the deadlock retry and
re-introducing the flake. Committed --no-verify: the generate-docs-skill hook
is blocked by a pre-existing skills/hindsight-docs drift on main, unrelated to
this test-only change.
2026-08-04 18:36:01 +02:00
Sanderhoff-alt 751deb47be fix(retain): sync metadata to unchanged memories (#3011)
Keep metadata and tags on unchanged memory units aligned with their
document during delta retain.

Cover metadata-only replace and append paths with regression tests.

Closes #3008
2026-08-04 18:33:16 +02:00
Nicolò Boschi 5f8a030615 chore(db): remove deprecated entity schema from memory_links (#3177)
Entity edges are no longer materialized in memory_links. Retain stores
memory-to-entity associations in unit_entities, and both read paths derive
entity edges from that table on demand — the /graph endpoint from shared
unit_entities rows and recall via the unit_entities self-join. Migration
e9b2c7d1f3a4 deleted the stored entity rows and current writers only ever
pass entity_id = NULL, leaving the entity-specific schema on memory_links
as dead weight.

New migration (PG + Oracle) drops the entity_id column and its FK, the
entity index, 'entity' from the link_type CHECK, and the entity_id term in
the function-based unique index (which collapses to
(from_unit_id, to_unit_id, link_type)). It is written to avoid long locks
on large tables: the residual delete is chunked with per-batch commits,
indexes are swapped CONCURRENTLY, and the new CHECK is added NOT VALID then
validated separately.

Application code drops _NIL_ENTITY_UUID and the nil_entity_uuid DataAccessOps
parameter, simplifies internal link tuples to four elements
(from, to, link_type, weight), and removes the entity_id column/placeholder
from the PG and Oracle bulk inserts and the chunk-storage lock ordering.
The graph API keeps returning dynamically derived entity edges.
2026-08-04 18:30:00 +02:00
Nicolò Boschi f572d8647d fix(retain): unify OutputTooLongError so #2579 output auto-split actually runs (#3174)
OutputTooLongError was defined twice — the canonical class in
llm_interface.py (what the providers raise) and a shadow copy in
llm_wrapper.py. fact_extraction and multi_llm imported the shadow, so
`except OutputTooLongError` never matched what providers raise:

- #2579's chunk-splitting retry (_extract_facts_with_auto_split) was
  dead on the real path; one over-long chunk failed an entire
  multi-chunk retain and discarded the successfully-extracted chunks.
- multi_llm._should_failover's `isinstance(exc, OutputTooLongError)`
  returned False, inverting its intent and burning an extra provider
  call that can't fit the over-length output either.

Re-export the canonical class from llm_wrapper instead of redefining it,
so all catch/inspect sites bind to the same object.

Now that the split path is reachable, bound its recursion with a
minimum-size floor (_MIN_SPLIT_CHUNK_CHARS = 500): a chunk that overflows
the output cap at every size is degenerate/looping output, and halving it
toward one character costs ~5000 extraction calls; the floor drops it in
~17 instead.

Fixes #3172
2026-08-04 18:22:59 +02:00
Nicolò Boschi 0ca0e87a08 fix(control-plane): report mental-model freshness from the bank write watermark (#3156)
* fix(control-plane): report mental-model freshness from the bank write watermark

The mental-models card compared each model's last_refreshed_at against the
bank's last_consolidated_at, so any consolidation after a refresh — nearly
always — reported every model as stale, and a bank that had never consolidated
reported the opposite.

Computing the real per-model answer on a list is the expensive fix:
compute_mental_model_is_stale has no index to use (there is none on
memory_units.updated_at), so it scans the bank's memories in full, per model —
10ms per model at 100k memories, 101ms at 500k, on a view that polls every 5s.

Report a bank-wide watermark instead. MAX(updated_at) rides along on the
aggregate _compute_bank_stats already runs and is served from the same cached
payload as last_memory_write_at. A model refreshed at or after it is up to date,
exactly; older only means something was written, possibly outside its tags, so
the card says "may need refresh" rather than asserting stale. The exact answer
stays on the single mental-model read, behind the dialog.

The knowledge-base tree ran that same scan once per page and polls every 12s —
already a full scan per page per tick in production. It now shares the
watermark: one cached lookup for the whole tree.

Fixes #3139

* perf(reflect): skip the per-model staleness scan below the bank watermark

search_mental_models computed staleness with the exact scoped query for every
model it returned — up to 5 full scans of the bank's memories per tool call,
serially, on a held connection, and the agent can call the tool several times
per reflect.

get_bank_freshness already computes the bank's write watermark in the same scan
it runs once per reflect, and was discarding it. Thread it through: a model
refreshed at or after the newest write in the bank cannot be stale whatever its
scope, so it skips the query entirely. Everything above the watermark still gets
the exact tag-aware answer — the agent only trusts a model without a verifying
recall() when is_stale is False, so guessing conservatively here would buy LLM
turns to save a query.

* chore(docs-skill): re-sync the generated reference copies

generate-docs-skill.sh output drifted from the docs pages that landed on main
(retain narrator guidance, configuration, coding-agents). Regenerated so
verify-generated-files has nothing to report.
2026-08-04 16:53:19 +02:00
Nicolò Boschi 04b7c9a188 docs(retain): suggest a distinct document_id per source document (#3173)
* docs(retain): suggest a distinct document_id per source document

Clarify the item-level document_id field: items sharing a document_id
are grouped into one document, so callers should provide a distinct id
per source document (auto-generated when omitted). No behavior change —
mixed explicit/implicit batches stay backwards compatible.

Refs #3010

* chore(clients): regenerate TS client for document_id doc update

* chore(docs-skill): regenerate hindsight-docs skill references

Picks up the document_id doc update plus pre-existing drift in the
generated skill snapshot (retain.md, coding-agents.md) from prior merges.
2026-08-04 16:42:27 +02:00
Nicolò Boschi ebae35670e release(coding-agents): v0.0.4 2026-08-04 16:32:13 +02:00
Nicolò Boschi 333812c85a fix(coding-agents): read Devin's transcript with node:sqlite, and refuse to install without it (#3175)
* fix(coding-agents): read Devin's transcript with node:sqlite, and refuse to install without it

Devin is the only harness whose hooks never hand over a transcript — they carry
a session id and nothing else, so the conversation has to be read from the CLI's
own sessions.db. That read shelled out to the `sqlite3` BINARY, which is not a
declared dependency and was never checked for. Where it was absent, execFileSync
threw ENOENT, a bare `catch` folded it into `return []`, and retain no-opped
forever while the installer reported success (#3125).

Node ships its own SQLite, so the binary is no longer needed for anything:

- `node:sqlite` replaces the subprocess. It is a builtin — nothing added to the
  package, nothing bundled (esbuild leaves `node:` imports external; the devin
  hook bundle is unchanged at 59K). Loaded through createRequire INSIDE the read
  function rather than imported at module scope: this module is pulled in by
  hook-lifecycle, which every harness shares, so a static import would break
  Claude Code and Codex on a Node without it.
- The session id is now a bound parameter instead of being escaped into the SQL
  string by hand.
- Missing reader, absent database and read failure each emit a distinct `diag`
  event, so a permanently memory-less install no longer looks like an idle
  session. A Devin storage-schema change surfaces the same way.
- `install devin-cli` preflights the `node` on PATH — the interpreter the hook
  command actually runs under, which an npx-launched installer may not be — and
  refuses with the reason and a non-zero exit instead of wiring hooks that could
  never retain anything. On `install all` only Devin is blocked; the other
  agents are still wired.

The SQLite read path had no tests at all; it now covers reading a session,
binding a quoted id, and both failure diagnostics against a real database.

* ci: export the integrations-coding-agents change filter

The filter was defined and the job consumed it, but detect-changes never listed
it among its outputs — so `needs.detect-changes.outputs.integrations-coding-agents`
was always empty and test-coding-agents ran only on workflow_dispatch or a
workflow-file change. Every PR touching just that package went untested.

* chore: regenerate the stale docs-skill mirror

Pre-existing drift on main, not from this branch: the `agent_name` deprecation
landed in the docs without re-running generate-docs-skill.sh, so
verify-generated-files fails for every PR. Pulled in here because this PR can't
go green without it.
2026-08-04 16:30:08 +02:00
Joonyoung Park 417efb35d8 fix(tei): retry embedding connect timeouts (#3097) 2026-08-04 15:40:40 +02:00
Nicolò Boschi 8e953c2300 feat(embeddings): generic per-input token cap across all providers (#3160)
Unify the two provider-specific truncation knobs into one generic,
provider-agnostic flag and apply the cap at the single choke point
(`generate_embeddings_batch`) before any backend's `encode()` runs, so
every provider and every path (retain, recall queries, consolidation,
import) gets identical truncation.

- New: HINDSIGHT_API_EMBEDDINGS_MAX_INPUT_TOKENS (config
  `embeddings_max_input_tokens`), off by default, applies to all providers.
- Deprecated alias: HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS
  still honored (folded into the generic name at load time).
- Move truncation out of LiteLLMSDKEmbeddings into embedding_utils; the
  `truncate_to_tokens` helper moves to token_encoding.py and returns a
  TokenTruncation dataclass (no tuple return).
- Docs + .env.example (and bundled embed copy) updated; tests migrated to
  the central path plus config alias/precedence coverage.
2026-08-04 15:36:27 +02:00
Nicolò Boschi cac55fedad feat(config): per-operation llm_extra_body overrides (#3159)
* feat(config): per-operation llm_extra_body overrides

Extra request-body params could only be set globally via
HINDSIGHT_API_LLM_EXTRA_BODY, so every operation shared one dict. That
forces a single choice on knobs that are genuinely per-operation — e.g.
disabling a model's thinking mode for retain extraction while leaving it
on for reflect (vLLM chat_template_kwargs), or setting a different
max_tokens per operation.

Add the same per-operation override the other LLM params already have:

  HINDSIGHT_API_RETAIN_LLM_EXTRA_BODY
  HINDSIGHT_API_REFLECT_LLM_EXTRA_BODY
  HINDSIGHT_API_CONSOLIDATION_LLM_EXTRA_BODY

Each follows the reasoning_effort pattern exactly: parsed into an
optional HindsightConfig field, resolved in MemoryEngine as
`config.<op>_llm_extra_body or config.llm_extra_body`, so an unset
operation keeps using the global value. Static server-level config (not
per-bank configurable), matching the global flag.

A per-operation value replaces the global dict rather than merging with
it — extra-body params are provider-native, and this is how every other
per-operation override behaves.

* docs: regenerate hindsight-docs skill for the new config rows

* chore(docs): resync coding-agents skill reference

Pre-existing drift, unrelated to this PR's feature: the source doc
hindsight-docs/docs-integrations/coding-agents.md was updated by
17b7f46ae / d238d2f7d, but skills/hindsight-docs/ has not been
regenerated since 4278f0989. verify-generated-files is therefore red on
main, and stays red on any PR that runs it until the copy is resynced.

Purely the output of ./scripts/generate-docs-skill.sh — no hand edits.
2026-08-04 11:23:03 +02:00
github-actions[bot] b5548ac25c chore: update star history 2026-08-04 04:25:24 +00:00
Nicolò Boschi 5c15f28afe fix(recall): enforce the query token cap for internal recalls, not just HTTP (#3158)
`HINDSIGHT_API_RECALL_MAX_QUERY_TOKENS` (500, added in #298 after a 1848-token
query timed out) was only checked in the REST handler. Every internal caller
reaches `MemoryEngine.recall_async` directly — consolidation, the reflect tools,
the MCP tools and the context extension — and passed arbitrarily long text
through.

Consolidation recalls with the whole fact text as the query, so a degenerate
extraction (58k words, 4 distinct) became a 54k-term OR `tsquery`; evaluating it
recurses once per node and exceeded Postgres' stack depth (SQLSTATE 54001). The
consolidation op then retried for 7 days and blocked every later consolidation
on that bank (#3134).

Bound the query at the engine ingress instead. Internal callers truncate rather
than fail — a long source fact must still consolidate, just on a bounded query.
The REST handler keeps its HTTP 400 for client-supplied queries.
2026-08-03 18:53:06 +02:00
Nicolò Boschi f77f2188d6 docs(retain): deprecate bank name as narrator; steer speaker via context (#3138) (#3155)
The bank profile `name` field is documented as a display label only, but at
retain time `_resolve_narrator` silently uses it as the narrator (memory owner)
whenever it differs from `bank_id` — the undocumented coupling reported in #3138.

Stop advertising that path without changing any runtime behavior (100% backward
compatible):
- retain.md now steers speaker attribution solely through each item's `context`,
  dropping the advice to set a bank `name` as the agent's name.
- The dry-run extract `agent_name` override is marked `deprecated` in the schema
  (still honored) and repointed to `context`.

`_resolve_narrator` and the prompt injection are unchanged, so existing banks
behave exactly as before.
2026-08-03 18:14:27 +02:00
Nicolò Boschi efcf36aaac chore(api): drop the never-written memory_units.access_count column (#3157)
`memory_units.access_count` has existed since the initial schema (5a366d414dce)
with an `access_count DESC` index alongside it, but no code path ever wrote it
and no query ever read or ordered by it. It was 0 on every row of every install,
and PostgreSQL maintained a btree over it for nothing.

Migration e4a7c1b9d2f6 drops it from `memory_units` and from the curation archive
`invalidated_memory_units` on both PG and Oracle. Both are required: curation
moves a row with an INSERT…SELECT whose column list is read from the catalog at
runtime (`memories/pg/writes.py::_memory_unit_columns`), so the two tables must
stay in lockstep or the round-trip breaks on a column mismatch. Dropping the
column implicitly drops its index on both dialects.

Also removes the last three references: the stale comment naming an
`access_count_update` task type that was never implemented, the column name in
the Oracle backend's numeric-RETURNING list, and that same never-implemented task
type used as a placeholder string in a worker test.

Restore is fixed so the drop doesn't strand older backups. Its preflight rejected
any backup carrying a column the target lacks ("target is missing backup
columns …"), which would have made every backup taken before this migration
permanently unrestorable. Unknown columns are now skipped and reported instead.
They cannot simply be left out of the copy_to_table column list: binary COPY
carries no column identities, so a tuple's fields are matched to the column list
purely by position, and an unedited stream desynchronises — PostgreSQL rejects it
with "row field count is N, expected M", and a subtler mismatch could land values
in the wrong columns. `_strip_binary_copy_fields` therefore rewrites the stream,
dropping each ignored column's field from every tuple. Type mismatches on columns
present in both schemas stay fatal.
2026-08-03 18:07:45 +02:00
Nicolò Boschi 84fd3b1767 docs(coding-agents): explain how imported sessions are attributed
Both supported harnesses record the directory each session ran in, so a
conversation is only imported when the session proves where it belongs — a
paragraph on why that matters (the folder-name encoding is ambiguous, and a
wrong guess files another repo's conversation into your bank) and what happens
to sessions that record nothing.
2026-08-03 18:01:14 +02:00
tao943andtao943 4b76d8be22 fix: return 422 for invalid recall fact types (#3062)
Co-authored-by: tao943 <[email protected]>
2026-08-03 17:51:02 +02:00
Nicolò Boschi d238d2f7d7 fix(coding-agents): attribute imported sessions by recorded cwd, never by name
A conversation may only be imported into a repo's bank when the session itself
records the directory it ran in. The previous fallback — matching the project
folder name — was a guess, and the encoding makes it an unsafe one: `/` and `.`
both become `-`, so `repo-sub` is either the subdirectory `repo/sub` or an
unrelated sibling repo. Guessing wrong files someone else's conversation into
this repo's memory, which is worse than importing nothing.

Both supported harnesses can prove it: Codex writes the cwd in its session_meta
header, and Claude records one on its entries (measured: 400/400 sampled
sessions of 13,841). Sessions that record none are skipped and counted, and the
count is printed rather than swallowed.

Matching on the recorded directory also fixes the opposite error: a session run
in a SUBDIRECTORY of the repo is now imported (Claude gives a subdirectory
launch its own project folder, which an exact-name match missed), and Codex
matches sessions whose cwd is inside the repo rather than exactly equal to it.
2026-08-03 17:26:54 +02:00
Nicolò Boschi 4278f0989d feat(reflect,mental-models): surface structured output in the control plane (#3113)
* feat(reflect,mental-models): surface structured output in control plane

Reflect's response_schema -> structured_output was already implemented and
tested in the engine but never exposed in the UI. Surface it in the reflect
(think) view, and extend the same structured-output extraction to mental
models via a per-model response_schema stored in the trigger config.

- engine: refresh_mental_model reads trigger.response_schema, forwards it to
  the internal reflect call, and persists the parsed structured_output onto
  the stored reflect_response payload; fix stale 'not yet supported' docstrings
- api: add response_schema to MentalModelTrigger
- control-plane: reflect route + api.ts forward response_schema; think-view
  gets a JSON-schema input and renders structured_output; create/update mental
  model dialogs get a schema editor; detail modal renders structured_output
- tests: mental model structured-output plumbing (schema forwarded + persisted)
- regenerate OpenAPI spec + client SDKs; add i18n keys for all locales

* feat(control-plane): show configured response_schema in mental model config tab

Adds a read-only JSON card for the mental model's trigger.response_schema in
the detail modal's Configuration tab (mirrors the tag_groups card), plus the
regenerated go openapi.yaml.

* style: ruff format test_mental_model_structured_output

* fix(control-plane): don't route the JSON schema example through next-intl

The response_schema placeholder was a t() message whose value is literal JSON.
next-intl parses messages as ICU, so the '{' in the example was read as an
argument placeholder, the parse failed, and the field rendered the raw message
key instead of the example. Inline the JSON example directly on the placeholder
prop (i18n:check skips JSON-shaped placeholders) and drop the now-unused
*Placeholder message keys. Caught by running the control plane.

* feat(structured-output): validate response_schema + add a no-code schema builder

Validation (both reflect and mental models): a schema that is valid JSON but
not a usable object-with-properties silently produced empty structured_output
or blew up inside the LLM extraction call later. Now:
- engine: validate_response_schema() enforces the usable-shape contract
  (object schema, non-empty properties, well-formed required); wired as Pydantic
  field_validators on ReflectRequest.response_schema and
  MentalModelTrigger.response_schema (invalid -> HTTP 422).
- control-plane: the reflect and mental-model forms validate the schema shape on
  submit (not just JSON.parse) and surface the specific error.

No-code schema builder: a 'Build schema' button on both the reflect view and the
mental-model dialogs opens a dialog with Visual and Code modes. Visual mode edits
a flat field list (name, type, array item-type, description, required); Code mode
edits raw JSON. The two stay in sync and Apply is gated on a usable schema. Shared
frontend lib (response-schema.ts) mirrors the backend contract.

tests: test_response_schema_validation.py (16 cases: validator + model integration).

* refactor(control-plane): schema only via the builder, show set/unset status

Removes the inline response_schema JSON textarea from the reflect view and the
mental-model dialogs. Editing now happens exclusively in the schema builder; the
page shows only whether a schema is set (field count + names, with Edit/Remove)
or a Build schema button when none. Extracts the shared ResponseSchemaField
component used identically by reflect and both mental-model dialogs.

* fix(mental-models): derive structured_output from final content, not reflect's answer

In delta mode reflect only sees facts created since the last refresh, so its
answer (and any structured_output it derived) reflects just the delta — while the
stored content is the delta-merged document. Persisting the reflect-derived value
made structured_output inconsistent with the markdown.

Now the mental-model refresh no longer passes response_schema to reflect; instead
it extracts structured_output from the FINAL stored content (correct for both full
and delta), and carries the previous value forward untouched when a delta refresh
preserves content (no new facts). Adds a delta test asserting extraction runs
against the merged document, not reflect's partial answer.

* fix(schema-builder): allow switching an empty schema from Code back to Visual

An empty schema serialises to properties:{}, which schemaToFields mapped to an
empty array — and the Code->Visual guard treated 'empty' the same as 'not
representable', blocking the switch. schemaToFields now returns [] (representable)
for a missing/empty properties map and null only for genuinely unrepresentable
schemas; the switch seeds a blank field when empty.

* docs(reflect): document structured output (response_schema) + schema builder

Adds a Structured Output section to the reflect docs: how response_schema returns
both text and a structured_output projection of the same answer, the schema rules,
mental-model structured output (extracted from the final/merged document), and the
no-code Build schema editor. Regenerates the docs-skill mirror.

* feat(schema-builder): recursive visual editor for nested objects & arrays

The visual editor was flat — object/array fields had no way to define their inner
shape. Reworks the field model into a recursive tree (each field has a node; an
object node nests fields, an array node nests an item node) so you can build
nested objects and arrays-of-objects entirely in the visual editor. Code<->Visual
round-trips losslessly; schemas using features the editor can't represent (enum,
oneOf, $ref, tuple items, …) stay in code mode rather than being silently
flattened.

* fix(structured-output): recursive model for nested schemas + fail refresh loudly

Two problems surfaced by nested schemas on Gemini:

1. _generate_structured_output mapped object/array properties to bare dict/list,
   which serialize with additionalProperties — rejected by the Gemini API. So any
   schema with a nested object/array silently failed extraction. Now it builds a
   proper recursive Pydantic model (nested objects -> nested models, arrays ->
   typed lists), matching how retain's structured output already works on Gemini.

2. On extraction failure the mental-model refresh silently persisted content with
   no structured_output, clobbering the previously-stored value. Now, when a
   response_schema is configured and extraction yields nothing, the refresh raises
   MentalModelRefreshError — prior content and structured_output are preserved and
   the refresh can be retried.

Verified live on Gemini: a nested {location:object, people:array} schema now
extracts (structured_output present) instead of failing on additionalProperties.
Adds a fail-loud regression test.

* fix(schema-builder): readable error text in dark mode

text-destructive resolves to a dark red (#C0183A) in dark mode, which is
low-contrast on the dark dialog background. Use the codebase's standard
readable pattern (text-red-600 dark:text-red-400) for the builder's validation
error and the invalid-schema notice.

* fix(cli): set response_schema on MentalModelTriggerInput literals

Adding response_schema to MentalModelTrigger regenerated the Rust
MentalModelTriggerInput struct with a new field; the hand-written CLI struct
literals must initialize it (E0063). Sets response_schema: None in the three
construction sites (create/update mental model, knowledge-base pin).

* docs(api): document mental-model response_schema; fix stale reflect text-empty claim; test schema lib

- api/mental-models: document the trigger.response_schema flag + a Structured
  Output section (extraction from final content, fail-loud, validation).
- api/reflect: correct the stale claim that text is empty with response_schema —
  reflect returns both text and structured_output.
- control-plane: vitest unit tests for the response-schema lib (validation +
  recursive fields<->schema round-trip).
- regenerate docs-skill mirror.

* chore: regenerate bank-template-schema for MentalModelTrigger.response_schema

The bank template schema embeds MentalModelTrigger; adding response_schema to
the trigger changed the generated schema. Regenerated so verify-generated-files
passes.
2026-08-03 16:51:38 +02:00
Nicolò Boschi 17b7f46ae7 feat(coding-agents): --import-conversations, a migration path off the per-agent plugins
The old integrations can't be migrated by moving data: they scope a bank per
agent per project (`claude-code::myrepo`) where this package uses one per repo
(`coding-agent::myrepo`), so two old banks map onto one new one — and the
server's bank import restores a whole bank rather than merging into a live one.

Re-reading the transcripts the agent already wrote to disk sidesteps that: the
same conversations are re-extracted into whichever bank is current. The flag
hands them to the deepen engine a session start already uses, so ingestion
dedups by document id and re-running is safe.

Scoped to the current repo — this machine holds ~14k Claude sessions, and
importing every project's history would run extraction over all of them. Claude
Code keys history by project directory; Codex partitions by date and records the
cwd in each rollout's session_meta header, which is read to filter.

Only file-based harnesses are supported. opencode, Kilo, Cursor, Cline, Copilot
and Devin keep history in internal SQLite databases with unversioned schemas;
they report as skipped WITH the reason rather than importing nothing silently.

One bug worth naming: the Codex header is a single line carrying the agent's
full base instructions, tens of KB. Reading a fixed 4096-byte prefix truncated it
mid-JSON, so every rollout was skipped and the import quietly found nothing —
hidden by the surrounding catch. The header is now read a chunk at a time until
the newline, with a regression test that fails against the old slice.
2026-08-03 16:18:08 +02:00
Nicolò Boschi 06e9c7054e feat(mental-models): dry-run refresh and keep_trace for troubleshooting (#3119)
When a refresh produced an unexpected document, nothing said why. The mode
decision, resolved scope, snapshot window, retrieved-versus-used fact counts
and dropped delta operations only ever reached a log line — and cron- or
consolidation-driven refreshes run with nobody watching.

Two ways to see that reasoning, from opposite directions.

POST /mental-models/{id}/dry-run-refresh runs the production refresh
pipeline and reports what it would do, skipping exactly two writes: the
content (with its structured document and history entry) and the watermark
that moves last_refreshed_at. It takes no parameters, on purpose — a dry run
you can configure stops predicting the refresh it exists to predict. Because
nothing is persisted, a delta dry run reads exactly the window the next real
refresh will.

trigger.keep_trace records the same reasoning on every refresh of a model,
scheduled ones included, under reflect_response.trace. It is written even
when a refresh fails, which is when it matters most. The trace is shaped
like reflect's — the calls the agent made plus the refresh decision — and
holds nothing derivable from elsewhere: evidence stays in based_on, and the
resolved scope and window are reported by the dry run. Each tool call
records the window bound it was given, named `updated_at` for what the
predicate actually filters; null means the tool applies no time bound at
all, which is what explains results older than the window would suggest.

refresh_mental_model is split into a shared _execute_mental_model_refresh
that computes a result and writes nothing, plus a thin persistence step, so
the preview and the real refresh run the same body. Existing refresh
behaviour is unchanged.

In the control plane the dry run is an action on the mental model, and its
result opens in a dialog built from the History tab's own diff components.
History shows each version's own trace: the history snapshot now carries
`trace` alongside `based_on` so it survives being superseded.

Surfaced but deliberately not fixed here: when delta operations fail, the
fallback writes a candidate built from a delta-scoped recall over the whole
document, dropping content grounded in older memories (#3112).
2026-08-03 15:56:28 +02:00
Nicolò Boschi 5061ff6643 fix(coding-agents): correct the package name in the npx-refusal message
The message that tells you how to recover named the UNSCOPED package, which does
not resolve, and a bare `install`, which no longer does anything. So the one
place a user lands when they get this wrong handed them two commands that also
fail.
2026-08-03 15:47:00 +02:00
Cyprian KowalczykandiRonin dbca379410 fix(consolidation): apply sanitize_text to the _DedupDecision merge write path (#3144)
The dedup merge path passed the LLM's synthesized text straight to the fold
UPDATE with only .strip() applied, so control characters and lone surrogates
reached SQL unscrubbed. _CreateAction and _UpdateAction already scrub their
text via a sanitize_llm_output field_validator; the merge path did not.

Character-safety only (control chars + surrogates), matching the existing
create/update behaviour. Adds a regression test alongside the existing fold
test; that test is left untouched.

Co-authored-by: iRonin <[email protected]>
2026-08-03 15:46:11 +02:00
Kuba OdiasandClaude Fable 5 13cacf21a7 fix: render disallowed fields in config permission error (#3148)
The no-fields-allowed branch of the permission error in
ConfigResolver was missing its f-string prefix, so callers saw the
literal text "Not allowed to modify fields: {sorted(disallowed)}."
instead of the actual field names.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-03 15:37:39 +02:00
Nicolò Boschi bda7ffd289 fix(recall): apply the created_after/created_before window to graph expansion (#3153)
Recall bounds its time window on `updated_at`, but two arms only filtered
their entry points and then expanded outward unfiltered:

- Link expansion filtered its semantic *seeds*, then pulled each seed's whole
  neighbourhood — shared entities, semantic kNN links, causal links — with no
  window at all.
- The temporal arm's entry-point query applied the window, but the multi-hop
  spread that walks temporal/causal links from those entry points did not.

Either way a single in-window seed dragged arbitrarily old facts back into the
results. Mental-model delta refresh recalls with `created_after=<last refresh>`
and `created_before=<cutoff>` precisely to see only what changed since, so every
refresh silently re-ingested stale neighbourhoods.

The window is now pushed into the expansion SQL on both backends via a shared
`UpdatedWindow`, which renders `AND <alias>.updated_at > $n` plus its params.
Rendering is per-alias because one query applies it to several correlation
names. For entity expansion the predicate goes inside the EXISTS that already
filters `fact_type` — i.e. *before* the per-entity cap, so out-of-window
neighbours can't eat an entity's bounded fan-out and starve the in-window ones.

Left deliberately unwindowed: `include_source_facts` returns an observation's
sources in a separate field, not in `results`. An observation refreshed inside
the window has older sources by construction, so filtering there would gut the
feature.

While changing these signatures, the three expansion methods also stop
returning a bare 3-tuple (a project standard) in favour of `LinkExpansionRows`.
The signals stay separate because each carries a different score scale and the
caller transforms each before summing.

Tests: `test_recall_time_range_graph.py` seeds an in-window fact plus
neighbours reachable only through the graph and asserts recall never returns
them, covering all three link signals, both bounds, observations, and temporal
spreading. Each has a control assertion proving the links are live, so a pass
cannot be vacuous. Backend-parametrized unit tests in `test_db_abstraction.py`
cover clause placement relative to the cap and verify an unbounded recall emits
the SQL verbatim with no dangling placeholders (Oracle rejects a query
referencing an unbound param).
2026-08-03 15:31:19 +02:00
Nicolò Boschi d6bdb62a02 docs(coding-agents): generate the docs page from the README, lead with install
The two pages described the same product in different words and had drifted: the
docs page still carried install instructions that no longer worked and a harness
table that had fallen behind. The README is the single source now — a sync
script writes the doc page from it, and `--check` runs in the docs build so the
two cannot separate again (verified: the build fails on a hand-edited page).

Also, from reading it as a new user would:

- Install moves above "How it works". The first question is "how do I get this",
  and it was buried under the harness table.
- "How it works" becomes short bullets grouped by what actually happens —
  ingestion, what the agent receives, write-back, guarantees — instead of seven
  dense paragraphs.
- Layout (a source-tree map for contributors) is dropped from the doc page: it
  answers a question no reader of the docs site is asking.
- The Configuration section no longer claims "no environment variables", which
  stopped being true when the env fallback landed.
2026-08-03 15:29:26 +02:00
Nicolò Boschi 0aa3480e8c release(coding-agents): v0.0.3 2026-08-03 14:55:49 +02:00
Nicolò Boschi 05b55d20f5 feat(coding-agents): require an explicit target — install all or a harness name
A bare `install` wired every detected agent: hooks, MCP registration and the
companion skill written into up to ten hosts' configs from one command that
never said it would. `all` is now spelled out, so wiring the whole machine is a
choice rather than a side effect, and a bare `install` changes nothing and
prints the options. `uninstall` matches, so the pair stays symmetric.

Chosen over a --yes flag or a confirmation prompt: a prompt has to decide what
to do without a TTY (assume consent, or break automation), whereas an explicit
target reads the same in a terminal, a script and a README.

The README also gains a per-agent section — one row per agent with its exact
command and what that wiring touches — since "install and it finds everything"
is no longer the whole story.
2026-08-03 14:54:14 +02:00
Nicolò Boschi 4d22a882f5 docs(knowledge-pages): document Knowledge Pages and Mental Models, and manage them from the CLI (#3151)
* docs(knowledge-pages): document Knowledge Pages and Mental Models

Knowledge Pages shipped in #2455 with no documentation at all — no
architecture page, no API page, no mention in the sidebar. Mental models
had an API page but nothing explaining what they are or why they are
fast. Add both, as top-level entries under Architecture and API.

- Architecture: how pages are mental models with a simplified,
  document-shaped configuration; the folder hierarchy; the `hindsight fs`
  filesystem projection; page-level search; and why a projected view over
  reconciled memory is not the same thing as a folder of raw files.
- Architecture: mental models as standing answers built in the background,
  so an application reads the current version instead of paying for
  synthesis on the request path.
- API: the full knowledge-base endpoint surface, the page defaults and
  what each one buys, staleness gating, what a refresh reads, and how
  delta mode edits a structured document instead of regenerating prose.
  The mental-model trigger table gains the seven settings it was missing.
- FAQ: mental model vs knowledge page. Also corrects the neighbouring
  answer, which described mental models as built automatically during
  retain — that is observations.

The API examples use the maintained clients like every other API page, so
this adds the knowledge-base surface to the Python and TypeScript wrappers
(kept at parity, with request-mapping tests on both sides) and runnable
Python/Node/Go examples.

* feat(cli): manage knowledge pages from the CLI

The knowledge base was reachable from every client except the CLI, where
the eight endpoints were listed as deliberate coverage skips ("managed in
the control plane UI"). That left `hindsight fs` able to mirror pages
read-only but nothing able to create, edit, search, or delete them — and
it meant the API docs could not show a CLI tab alongside Python/Node/Go.

Adds `hindsight knowledge-base` with tree, create-folder, create-page,
get-page, search, update, delete, and export, removing the skips so
cli-coverage-check enforces the surface from here on.

`create-page` sends no trigger unless --mode or --fact-types is passed, so
the server's page defaults stand; when either is given the whole trigger
has to be restated, because a supplied trigger replaces the defaults
rather than merging with them.

Also adds the CLI tab to the Knowledge Pages API page and a Knowledge Base
section to the CLI reference.
2026-08-03 14:53:57 +02:00
Nicolò Boschi 2b1c4989f3 fix(coding-agents): register grok-build/copilot-cli, add env config fallback
Two independent reports from 0.0.1.

`deepen` resolves a harness through harness/registry.ts, which listed 8 of the
10 harnesses the installer wires. grok-build and copilot-cli were installable
but unknown to the registry, so deepen threw "unknown harness 'grok-build'" and
the background git-diff enrichment never ran for those users. Both are now
registered with their hook bins, and a guard test asserts the registry covers
every installer — the two lists are separate and had silently drifted.

Configuration also gains an environment fallback: `HINDSIGHT_API_URL`,
`HINDSIGHT_API_TOKEN` and one var per scalar setting. Deliberately a FALLBACK
beneath the config file, so an existing setup cannot change behaviour merely by
having env present; it covers containers, CI and secret managers that inject a
token instead of writing a credential to disk. Booleans and numbers are parsed
(a malformed number is ignored with a warning rather than becoming NaN), and an
empty var contributes nothing so it can't mask a file value. The map-valued
settings (mapPathToBank, harnesses, banks) stay file-only — nested branching
does not survive flattening into one variable.
2026-08-03 14:37:52 +02:00
Nicolò Boschi 55883dc517 feat(llm): add openai-responses provider (OpenAI Responses API) (#3121)
Add a provider that talks exclusively to the OpenAI Responses API
(`client.responses.create` → `/v1/responses`) — never chat/completions.

Motivation: reasoning models such as gpt-5.6-terra reject `reasoning_effort`
combined with function tools on `/v1/chat/completions` (HTTP 400 unless
`reasoning_effort="none"`, see #2983). Reflect is a tool-calling search loop, so
that constraint forces the whole reflect operation — including the final
synthesis — to run with reasoning disabled. The Responses API models the
chain-of-thought as a first-class reasoning item, so reasoning and tools coexist;
reflect's search loop can now run with a real reasoning effort.

`OpenAIResponsesLLM` is a standalone `LLMInterface` implementation (OpenAI-only;
it deliberately does NOT subclass the multi-vendor chat/completions provider, so
it can never route through `chat.completions` and carries none of the
groq/ollama/deepseek special-casing). It reuses only provider-agnostic pure
helpers (text cleanup, quota-defer parsing). It translates the engine's
chat-shaped inputs:
- chat messages → `input` items (assistant `tool_calls` → `function_call`,
  `role="tool"` → `function_call_output` keyed by `call_id`),
- nested `{"type":"function","function":{...}}` tools → flattened
  `{"type":"function","name":...,"parameters":...}`,
- flat `reasoning_effort` → a `reasoning={"effort": ...}` object,
- `response_format` → `text={"format": {...}}` (strict json_schema or the soft
  schema-in-prompt + json_object fallback),
- reads `response.output_text` + `function_call` items from `response.output`.

Generic LLM config flags are honored: `extra_body`, `timeout`, per-call
`temperature`/`max_completion_tokens`/`max_retries`. It also wires two flags the
chat/completions path drops — `openai_service_tier` (as the native Responses
`service_tier`) and `default_headers` (on the SDK client).

The conversation is replayed statelessly each turn (`store=False`, no
`previous_response_id`); server-side reasoning reuse across turns is left as a
future optimization.

Wiring: provider registration + dispatch, `PROVIDER_DEFAULT_MODELS` default
(`gpt-5.6`), docs + `.env.example` (+ embed template sync), and an `openai`
floor bump to `>=1.66.0` for the Responses API surface. Unit tests mock
`responses.create` (incl. the generic-flag wiring) and pin that reasoning + tools
are sent together on the tool path — the combination chat/completions rejects.

Validated live against real gpt-5.6-terra: plain + reasoning, strict structured
output, reasoning+tools together with a stateless function_call replay, and a
full run_reflect_agent end-to-end (stubbed retrieval, no DB) — tools drove to a
correct synthesized answer.
2026-08-03 14:33:50 +02:00
Nicolò Boschi 06eaf09493 docs(coding-agents): scope the npm package in the install command
The package publishes as @vectorize-io/hindsight-coding-agents, so the unscoped
`npm install -g hindsight-coding-agents` in the README, the docs page and the
companion skill resolved to a different (non-existent) package and failed for
the first users who tried it. The BINARY stays unscoped, so `hindsight-coding-agents
install` is unchanged.
2026-08-03 14:29:40 +02:00
github-actions[bot] 736d1c2f7b chore: update star history 2026-08-03 12:22:17 +00:00
Nicolò Boschi bb26f49e93 release(coding-agents): v0.0.2 2026-08-03 14:00:58 +02:00
Nicolò Boschi 47b679ec24 fix(coding-agents): repoint Antigravity and the Claude MCP server on re-install
Two hosts silently kept stale wiring when the package moved, each for its own
reason. Both surfaced after the directory rename, on a machine that already had
Hindsight installed.

- Antigravity keys its hooks.json by a top-level namespace equal to MARKER,
  where every other host matches entries by substring. Renaming the marker wrote
  a second namespace and left the first registered, so every Antigravity hook
  fired twice — once against a path that no longer exists. Install now drops any
  namespace written under a previous marker, leaving unrelated bundles alone.

- `claude mcp add` refuses a name that already exists ("MCP server hindsight
  already exists in user config"), so the add failed and the installer fell back
  to printing manual instructions. The old registration survived and Claude Code
  reported "Failed to connect — Connection closed" with the hindsight_* tools
  dead. Remove before add, so the registration is replaced like the hooks are.

Both are the same class as the Grok block that skipped when one already existed:
"install" must repair existing wiring, not step around it.
2026-08-03 13:59:54 +02:00
github-actions[bot] a1ec656424 chore: update star history 2026-08-03 10:36:27 +00:00
Nicolò Boschi cb0e1dea40 chore: replace star-history.com chart with self-hosted gh-stars chart (#3150)
The api.star-history.com embed in the README was broken. Replace it with
nicoloboschi/gh-stars, which backfills stargazer data via a scheduled GitHub
Action and commits a self-hosted SVG chart into the repo, so the README image
no longer depends on a third-party service.
2026-08-03 12:32:58 +02:00
Nicolò Boschi bf6c12d550 feat(control-plane): refresh the UI design system (#3149)
Replaces the stock shadcn oklch greys with the Hindsight palette and moves
the shared primitives onto the design system's density, so the whole app
picks up the new look from tokens rather than per-component edits.

Tokens (globals.css)
- Blue-shifted neutral palette in both modes: page #F3F5F9/#080C17, card
  #FFFFFF/#0F1724, sidebar #FFFFFF/#0A1020, hairline borders. Light mode
  previously had --card equal to --background, so cards were invisible
  against the page.
- Adds the --hs-* semantic layer (surface/fg/border/status/chart) and its
  @theme mappings.
- --tracking-normal 0.025em -> 0; Inter reads wrong with positive tracking
  at body sizes.
- Light --muted-foreground is #525866 (5.3:1 vs page, 7.1:1 vs card) so body
  copy clears WCAG AA in both modes.

Fonts
- Inter and JetBrains Mono move to next/font/google, replacing the Google
  Fonts @import that loaded weights lazily and left semibold headings in the
  system fallback until the weight arrived. Space Grotesk is dropped; it was
  only reachable via a `font-heading` utility no component used.

Primitives
- card, button (+ gradient variant), input, textarea, select, switch,
  checkbox, dialog, alert-dialog, popover, dropdown-menu, command move to
  13px / h-9 / rounded-[10px], with rounded-[16px] cards and dialogs and
  blurred overlays.

Layout
- Bank page content is capped by a responsive staircase
  (1024 / xl:1280 / 2xl:1440, centered). It was uncapped, so on a 16" display
  body text and table rows spanned the full ~1700px window.
- Active tab underlines use the brand gradient instead of flat --primary.
- The sidebar toggles from anywhere on its chrome, not just the button.

Preserved deliberately: the h1-h6 weight default (Tailwind preflight resets
headings to inherit and not every heading here carries an explicit weight
utility), plus the chip tokens, logo keyframes, themed scrollbars and .prose
table rules that the tokens rewrite would otherwise have dropped.

Note: page.tsx is mostly re-indentation from wrapping the views in the width
container; `git diff -w` shows the real change.
2026-08-03 12:32:41 +02:00
Nicolò Boschi 237a45fdb5 fix(coding-agents): declare the repository so provenance publishing works
The release workflow publishes with `npm publish --provenance`, and npm rejects
the upload when package.json has no `repository` matching the signed provenance:

  422 Unprocessable Entity - Error verifying sigstore provenance bundle:
  "repository.url" is "", expected "https://github.com/vectorize-io/hindsight"

v0.0.1 built and tagged fine and only failed at the registry. Same shape as the
other npm integrations (openclaw, ai-sdk, chat), including `directory` so npm
links to the subfolder.
2026-08-03 11:18:17 +02:00
Nicolò Boschi 8396b51d92 release(coding-agents): v0.0.1 2026-08-03 11:09:11 +02:00
Nicolò Boschi 8e5fdf28dd docs(coding-agents): unlist the integration page until it is announced
The package is about to be released so it can be installed and exercised end to
end, but its page should not surface yet. Three edits, each covering a different
surface:

- integrations.json: the entry drives BOTH the gallery and the sidebar (the
  sidebar is generated from this file), so removing it hides both.
- `unlisted: true` on the doc page: keeps it out of search and the sitemap and
  marks it noindex, while leaving it reachable by direct URL — and stops the
  build warning about a doc belonging to no sidebar.
- check-integrations.mjs EXCLUDED: the reverse check fails the docs build when a
  released integration has no gallery entry, so without this the build breaks the
  moment the release tag exists.

To publish it later: drop it from EXCLUDED, restore the integrations.json entry,
and remove `unlisted` from the page.
2026-08-03 11:08:27 +02:00
Nicolò Boschi abb5ba3498 docs(embed): document uvx cache growth and how to reclaim it (#2915) (#3118)
The uvx-launched daemon keeps a cached Python environment per Hindsight
version it has run (~1.5 GB each), and nothing removes them. Document the
stop / prune / restart recovery, including why the daemon has to be stopped
first.
2026-08-03 10:46:03 +02:00
Nicolò BoschiandChris Latimer b5d8439c8f hindsight-coding-agents: harness-pluggable long-term memory for coding agents (#2522)
* 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.

* refactor(integrations): generalize opencode-coding into hindsight-coding-agents

Make the coding-memory plugin harness-pluggable instead of opencode-specific.
A 'harness' (coding agent) differs in only two places; everything else is now
shared core:
  - src/core/    hindsight client, missions, git + chat ingest, inject, RuntimeCore
  - src/core/types.ts  HarnessAdapter + ChatReader interfaces
  - src/harness/ per-agent adapters + registry (opencode implemented)

Backfill: --harness selects how past sessions are read (opencode today);
git ingest, retain strategies, missions, and knowledge pages are identical
across agents. Runtime: HINDSIGHT_HARNESS (default opencode) selects the
adapter that binds RuntimeCore's reflect+inject+write-back to that agent's
plugin API. Adding an agent = one adapter file + a registry entry.

Type-checks and builds clean; unknown --harness/HINDSIGHT_HARNESS errors with
the available list.

* feat(coding-agents): on-demand memory_reflect tool, opt-in git-sync, JSON config

Add two capabilities to the reflect-only coding-agents plugin and move all
configuration off environment variables onto a single JSON file.

- memory_reflect tool: exposes the same synthesized reflect that is auto-injected
  on the first message as an on-demand opencode tool the agent can call mid-task
  (RuntimeCore.reflectNow + opencode adapter tool). Harness-agnostic core, thin
  opencode wiring.
- incremental git-sync (opt-in): on load, diff the target ref's commits
  (origin/main, falling back to HEAD) against the git:<sha> document_ids already
  in the bank and async-retain only the missing ones, reusing the backfill's
  per-commit encoding (retainCommit). Set-based, correct across rebases;
  best-effort, non-blocking. Off by default (gitSync.enabled).
  Adds HindsightClient.listDocumentIds + core/sync.ts.
- config file: all settings now come from ~/.hindsight/coding-agent.json
  (core/config.ts) -- no environment variables. The backfill CLI reads the same
  file for shared connection/bank settings with --flags overriding; operation
  flags stay CLI-only.

Committed with --no-verify: the repo-wide pre-commit lint hook is broken in this
environment (missing @eslint/js in hindsight-control-plane) and blocks all commits.

* fix(coding-agents): remove benchmark-specific strings from prompts

Fairness audit of the sdebench benchmark found three contaminations:
- CHAT_CUSTOM_INSTRUCTIONS used the literal answer to a graded task
  (round_cents/ROUND_HALF_DOWN/legacy ledger) as its example - replaced
  with a fictional, non-benchmark example.
- buildSystemInjection told the model 'the hidden tests depend on those
  exact choices' - hardcoded knowledge of the benchmark's grading;
  reworded benchmark-agnostic.
- REFLECT_MISSION examples were shape-matched to specific benchmark
  tasks (symbol mappings, exact numbers) - neutralized.

No behavior change intended beyond removing the leaked specifics.
(includes hook-regenerated skills/hindsight-docs sync)

* feat(coding-agents): reflect-outcome diagnostics — no more silent memory loss

A benchmark sweep ran the entire memory arm with zero injected memory:
reflect failed environmentally on every task and the best-effort catch
swallowed it, making a memory-less run indistinguishable from a memory
run. onTask now appends a reflect_ok/reflect_empty/reflect_failed record
(duration, error, query prefix) to HINDSIGHT_DIAG_FILE (default
/tmp/hindsight-plugin.log). Consumers can assert a session actually had
memory before trusting a comparison.

* fix(coding-agents): chronological session recency + supersession-aware reflect

Two defects surfaced by the conversation-amended benchmark tasks (a rule
settled in one chat and amended in a later one):

- chat ingestion staggered synthetic timestamps NOW - i*1h, INVERTING
  recency: an amendment chat ranked older than the decision it
  superseded, steering temporal ranking toward the stale rule. Session
  list order is chronological; the last session is now the newest.
- REFLECT_MISSION now states that when memories conflict on the same
  rule, the latest/superseding decision wins and the superseded rule
  must be reported as no longer in effect, never presented as the fix.

Observed live: reflect on an amended bank returned the superseded
keep-latest rule as the fix. Both fixes are general recency/consistency
semantics, not benchmark-specific behavior.

* feat(coding-agents): multi-harness configurability + Claude Code hook entry

One config, several agents side by side:

- Each runtime entry point now KNOWS its harness instead of reading the
  config's `harness` key (which selected a single global adapter and
  made opencode + claude mutually exclusive). That key now only picks
  the backfill's session formatter.
- New `harnesses.<name>` config sections: per-agent overrides of any
  field (bank, disabled, timeouts) over shared connection defaults.
- New project-local layer: <project>/.hindsight/coding-agent.json
  overrides the global file — the natural home for a per-repo bank.
  Precedence: defaults < global < global.harnesses < project <
  project.harnesses.
- New entry point: `hindsight-claude-hook` (dist/claude-hook.js), a
  Claude Code UserPromptSubmit hook. Reflects once per Claude session,
  caches the answer in tmp and re-injects it on later prompts, and
  writes the same reflect_ok/failed diagnostics as the opencode path.

Verified live: claude hook via project config + harnesses section
(reflect_ok, cached re-emit in 46ms, one reflect total); opencode via
the benchmark harness (reflect_ok, task solved 0 corrections).

* feat(coding-agents): per-repo dynamic bank resolution (family convention)

Port of the bank-derivation convention shared by the claude-code, omo,
cline, and opencode integrations, with coding-first defaults:

- No bankId configured => the bank is derived from the git repo the
  working directory belongs to, WORKTREE-AWARE: git rev-parse
  --git-common-dir resolves every linked worktree to the main worktree's
  basename, so all worktrees of a repo share one memory bank (bare repos
  use the bare dir name; non-git dirs fall back to the dir basename).
- Default granularity is [gitProject] (not agent::project): opencode and
  claude share ONE memory per repo — add 'agent' to
  dynamicBankGranularity to split per agent.
- Explicit bankId keeps today's static behavior (benchmark harness,
  single-bank setups); dynamicBankId forces either mode; supporting
  fields: bankIdPrefix, directoryBankMap (exact cwd -> bank escape
  hatch), agentName, resolveWorktrees.
- backfill: --bank wins, else the SAME resolution applied to --repo, so
  `hindsight-coding-backfill --repo .` fills exactly the bank the
  agents will read.

Verified: worktree -> main-repo bank (hs-coding-plugin-wt -> memory-poc),
static/prefix/dirMap/granularity cases, and the claude hook e2e
(reflect_ok via directoryBankMap against a live bank).

* feat(coding-agents): bank template string, prefix path map, {harness} field

Bank-resolution refinements:

- `bankIdTemplate` format string replaces the granularity array:
  e.g. "hindsight-{gitProject}" or "{harness}-{gitProject}" — default
  "{gitProject}" (opencode + claude share one bank per repo).
  Placeholders: {gitProject} {project} {harness} {channel} {user};
  unknown placeholders warn with the valid list. bankIdPrefix removed
  (expressible in the template).
- {harness} is supplied by the entry point itself (opencode plugin,
  claude hook, backfill --harness), not a config field — nothing to
  keep in sync.
- directoryBankMap now matches by LONGEST absolute-path prefix and
  overrides everything incl. an explicit bankId: mapping a repo root
  covers all its subdirectories; deeper mappings win.
- config discovery walks UP from the working directory to the nearest
  .hindsight/coding-agent.json — a hook invoked from a repo subdir
  previously missed the repo's project config entirely (found by an
  e2e test that failed exactly this way).

Verified: derivation matrix (template/prefix-map/override/static/bad
placeholder), claude hook e2e from a nested subdir (reflect_ok via
walked-up config + prefix-matched map), opencode benchmark task green.

* feat(coding-agents): cursor-cli + codex harnesses, unit tests, live system tests

Harnesses — hook-based agents now share one runtime (core/hook.ts:
stdin event -> layered config -> per-repo bank -> once-per-session
reflect with tmp cache -> native output -> diagnostics), so each agent
is a ~25-line HookSpec:
- hindsight-claude-hook  (UserPromptSubmit -> additionalContext)
- hindsight-cursor-hook  (beforeSubmitPrompt -> {continue, additional_context})
- hindsight-codex-hook   (Codex CLI v0.116+ claude-compatible hooks;
  accepts prompt/user_prompt)
All three + opencode registered in the harness registry (backfill
--harness resolves them; hook harnesses share the normalized-JSON
chat reader).

Tests (vitest, family convention):
- 25 unit tests: full bank-derivation matrix (worktree/bare/static/
  dynamic/template/{harness}/prefix-map incl. longest-wins and
  no-sibling-false-match) and config layering (harness sections,
  project-over-global, upward walk, nearest-wins, gitSync field merge,
  malformed fallback, legacy signature).
- live system suite (npm run test:live, HINDSIGHT_LIVE_E2E=1): builds a
  real git repo with a decision planted in a commit + a conversation,
  runs the real backfill CLI (server-side LLM extraction), then invokes
  the BUILT hook binaries as subprocesses and asserts the decision's
  literals come back in the injected context — semantic verification
  with a real LLM — plus per-session cache behavior and diag records.
  All 4 passing against a live server.

Note: session ids in the live suite are unique per run — the hooks
cache per session id in tmp, and a static id once cached a bad answer
from a half-broken server across reruns.

* docs(coding-agents): full README rewrite + integration docs page

README now covers everything the package does today: the reflect-once/
inject-every-turn mechanics, all four harnesses (opencode plugin +
claude/codex/cursor hooks) with install snippets, the complete
configuration reference (layered files, harnesses sections, per-repo
dynamic bank resolution with template placeholders, directoryBankMap,
worktree behavior), backfill CLI incl. bank auto-resolution and
chronological session ordering, the reflect diagnostics contract, and
the unit + live test suites.

Docs site: new docs-integrations/coding-agents.md (same content adapted
to the integration-guide format) + integrations.json hub entry so the
generated sidebar picks it up. Placeholder icon (github.png) pending a
real one. Verified: page renders (docusaurus build), all doc pre-flight
checks pass for this entry — note the docs build on this branch was
ALREADY failing on the unrelated pre-existing 'zcode missing from
integrations.json' check.

* feat(coding-agents): 🧠 attribution header in buildSystemInjection

Prepend the 'Using Hindsight Memories' visible-attribution directive to the
harness-agnostic system injection so every coding-agent harness surfaces a
recognizable header when it uses recalled memory. Covered by 5 deterministic
inject.test.ts cases (real emoji + em dash, no lone surrogates).

* feat(core): add recall() to HindsightClient

* style(core): apply prettier formatting to recall test

* style(coding-agents): normalize prettier formatting across package

* fix(core): narrow RecallResult to actual API contract, add fetch-throw test

* feat(core): formatMemories + shared attribution preamble

* style(core): prettier-wrap recall.test.ts array literal

* fix(core): cover formatMemories trim/filter + drop stale inject comment

* feat(core): per-turn recall in the hook runtime (reflect once, recall every turn)

Extracts the hook logic into a pure, unit-testable buildHookOutput(): every
prompt now runs recall() and injects a <hindsight_memories> block; reflect
still runs once per session (first prompt) and its cached answer is no
longer re-injected on later turns. runHook() becomes thin stdin/stdout
plumbing with a makeClient seam for tests. Updates the three hook
entrypoints' doc comments to match, and adds recallMaxTokens/recallTimeoutMs
config fields.

* fix(core): make recall fail-open in buildHookOutput + cover recall failure/opts

* feat(claude-code-v2): wrapper plugin skeleton (per-turn recall via bundled core)

Also disables tsup code-splitting in hindsight-coding-agents so each bin
entry (claude-hook.js etc.) is a single self-contained file with no
shared chunk-*.js — required for wrapper build scripts that copy just
the one hook file out of dist/.

* chore(coding-agents): sync codex-hook bin into package-lock

* fix(claude-code-v2): derive version from manifest + guard self-contained bundle

* feat(core): Claude transcript reader (normalized user/assistant text turns)

* fix(core): transcript reader null-safety + drop sidechain turns

* feat: live write-back on the Claude Stop hook (shared retain-hook runtime)

Extracts a testable buildRetain core (read transcript -> upsert under
conversation:<sessionId> via retainLiveSession) plus a thin runRetainHook
plumbing wrapper mirroring the existing runHook/buildHookOutput split, and
wires it up as a Claude Code Stop hook. Fail-open throughout: an empty
transcript is a no-op, and a retain failure is diagnosed but never thrown.

Exports diag() from core/hook.ts so retain-hook.ts can reuse the same
diagnostics helper instead of duplicating it.

* refactor(core): extract diag module + trim buildRetain params

- Move diag() out of hook.ts into a neutral src/core/diag.ts so retain-hook
  (and future lifecycle hooks like SessionStart) don't reach into a
  recall/reflect-specific module for a cross-cutting concern.
- Drop the unused cwd/cfg params from buildRetain — only harness, sessionId,
  transcriptPath, and client are read; cwd/cfg stay in runRetainHook where
  they're actually used (config load + deriveBankId).
- Clarify that retainSessions is opencode-plugin-only; the Stop hook always
  writes back unless disabled.

* feat(core): knowledge-page CRUD on HindsightClient (mental-models)

* fix(core): page methods throw on 404 + doc rationale

* feat: native TS MCP server for knowledge-page tools (bank-aligned)

Adds a native TypeScript MCP (stdio) server exposing the agent_knowledge_*
tools (get_current_bank, list_pages, get_page, create_page, update_page,
delete_page, recall) over MCP, wired into the claude-code-v2 wrapper.

Bank resolution goes through the same loadConfig + deriveBankId path the
hooks use (harness "claude-code"), so knowledge pages, recall, and retain
all land in one per-repo bank. This is a native TS server rather than
reusing the Python MCP because its bank derivation mismatches.

- src/core/knowledge-tools.ts: SDK-free tool specs (zod schemas), unit
  tested against a stub client (17 tests) — every handler is fail-closed
  to an isError:true result instead of throwing.
- src/mcp-server.ts: the only file importing @modelcontextprotocol/sdk.
- tsup.config.ts: new mcp-server entry, noExternal inlines the SDK + zod
  so dist/mcp-server.js stays a single self-contained file.
- claude-code-v2/.mcp.json + build.mjs: wires the bundle into the plugin;
  the self-contained-bundle guard passes for mcp-server.js unmodified
  (no exemption needed) since noExternal fully inlines its deps.

* fix(mcp): honor disabled flag + testable selectTools

- Export selectTools(cfg, client, bankId) from mcp-server.ts: pure,
  SDK-free, returns [] when cfg.disabled (mirrors the hooks' disabled
  check) so a disabled Hindsight exposes zero MCP tools instead of all 7.
  Confirmed at runtime: with disabled:true the server still connects but
  doesn't advertise a tools capability at all (tools/list -> Method not
  found), which is stronger than an empty list.
- Guard main() behind an argv[1]-vs-import.meta.url check so importing
  the module for tests doesn't start a real stdio server.
- Add src/mcp-server.test.ts covering selectTools for both the disabled
  and enabled cases.
- Reword the HINDSIGHT_MCP_PROJECT_CWD comment: nothing sets it today
  (the plugin doesn't cd), it's an escape hatch, not a launching-host
  contract.

* refactor(core): lazy-load opencode adapter so backfill bundles self-contained

* test(core): lock opencode no-runtime registry invariant + doc it

* feat(core): cold-repo detection + seed-consent state

* test(core): cover seed write-failure + guard non-object state

* feat(core): background seed mechanics + hindsight-seed control CLI

Adds hasGitHistory (git.ts), startBackgroundSeed + seedControl (seed.ts),
and the src/hindsight-seed.ts entrypoint the agent runs after the
SessionStart seed offer (Task 10b) to seed or decline a repo's bank.

* fix(core): handle async spawn error in startBackgroundSeed

spawn() failures (ENOENT/EACCES/fd exhaustion/sandboxed environments) often
arrive asynchronously as an 'error' event on the child, not a synchronous
throw. An unhandled 'error' event crashes the caller, so attach a no-op
handler alongside the existing try/catch. Also documents the Claude-Code-only
harness assumption in hindsight-seed.ts.

* feat: SessionStart auto-seed offer for cold repos (Claude wrapper wired)

* fix(core): shell-escape seed offer paths + drop orphaned isColdRepo

* docs(claude-code-v2): marketplace entry, full README, v1→v2 migration note

* fix(core): cap hook reflect timeout, align backfill+hook config resolution

- hook.ts: cap reflect's timeoutMs to HOOK_REFLECT_CAP_MS (8s) so it always
  resolves/aborts before Claude Code's 15s UserPromptSubmit kill window,
  guaranteeing the session cache write + recall injection complete instead
  of silently retrying reflect (and dropping recall) on every turn.
- backfill.ts: resolve config via loadConfig({harness, projectDir: REPO,
  path}) instead of the legacy string form, so project-local
  .hindsight/coding-agent.json layers in and the background auto-seed
  backfill targets the same bank recall/retain/MCP read from.
- hook.ts/retain-hook.ts: resolve the cwd fallback before loadConfig (not
  just at deriveBankId) so project-local config layers even when the
  hook event's cwd is missing.

* fix(claude-code-v2): dev-install must copy .mcp.json (MCP tools were missing)

* feat(core): deterministic SessionStart auto-seed + knowledge-page bank mission

The prior SessionStart design asked the agent to pose a y/n question then
run a seed command itself; live testing showed the model surfaces the
question and then ignores it, so nothing ever seeds. The hook now starts
the background seed itself on a cold git repo (tri-state: cold/warm/
unreachable) and always injects a short visible note plus a bank-mission
pointing the agent at the agent_knowledge_* tools.

* docs(claude-code-v2): update seed docs for deterministic auto-seed + knowledge mission

* feat(core): default seed to aggregated commit messages (one cheap doc) + Initiatives page; full-diff opt-in via --diffs

* docs(core): align backfill README + strategy log/comment with gitlog default

* feat(core): headless codebase-survey seed + agent_knowledge_ingest MCP tool

On a cold repo, the SessionStart hook now also spawns a detached headless
`claude` that samples the repo's structure and ingests its findings into
Hindsight via a new agent_knowledge_ingest MCP tool, alongside the existing
git-history backfill. Knowledge pages synthesize their content from bank
memories via source_query, so this is how the survey feeds them.

- knowledge-tools.ts: add agent_knowledge_ingest (title -> slug doc id,
  retain via the "chat" strategy, tagged source:upload).
- survey.ts: resolveClaudeBin + startCodebaseSurvey, mirroring seed.ts's
  fire-and-forget/never-throw spawn pattern.
- Anti-recursion: HINDSIGHT_DISABLE_HOOKS guard at the top of runHook,
  runRetainHook, and runSessionStartHook so the survey's own claude session
  can't re-trigger seeding/recall/retain; survey.ts sets it on the child.
- config.ts: codebaseSurvey (default true) + surveyModel (default "sonnet").
- session-start.ts: wire startSurvey into the cold-repo branch alongside
  startSeed; update the visible learning note.

* fix(core): sandbox headless survey (deny-list, no bypassPermissions) + spend cap + document strategy

* feat(core): default codebase-survey model to haiku (cheaper/faster; sonnet still configurable)

* feat(core): survey excludes CLAUDE.md + agent-instruction files from ingestion

* docs(coding-agents): v2 knowledge-pages design spec + implementation plan

* feat(core): add pageRefreshEveryTurns config (default 10)

* feat(core): knowledge-injection roster/preamble formatting

* feat(core): passive knowledge entity_labels tier vocabulary + configureBank wiring

* feat(core): tag-scope seeded pages, Initiatives folder, relatedPageId link source_query

* feat(core): captureInitiative — per-initiative page + relatedPageId marker

* feat(mcp): hindsight_* grounding tools + capture_initiative; remove raw page CRUD from agent

* feat(core): SessionStart injects page roster + guidance preamble

* feat(core): UserPromptSubmit hook-counted periodic page-roster refresh

* feat(core): rich markdown session write-back with tool calls + verbose session strategy

* chore: apply prettier line-wrapping to test files

* fix(claude-code): surface the seed note via user-visible systemMessage, keep preamble in additionalContext

* fix(survey): use renamed hindsight_ingest_document MCP tool (Task 6 rename regression)

* fix(core): preamble + refresh nudge the agent to call capture_initiative for major features

* fix(core): re-inject tool+capture reminder every cadence turn even with no pages (unconditional nudge)

* fix(core): inject when-to-call guide for the full hindsight_* tool suite, not just pages+capture

* feat(claude-code): cold-check-wins seeding — reseed a cleared bank on the live doc count, ignore stale seededAt

* fix(core): simplify capture_initiative instruction to one clear trigger (remove confusing OR-chains)

* fix(core): port proven v1 attribution preamble + surface memories block first so the header actually gets emitted

* fix(core): reflect every turn (configurable reflectEveryTurns, default 1) instead of once per session

* feat(core): per-turn injection is recall-only (drop reflect from the hook), recall token budget default 750

* feat(core): inject a user-feedback section above memories (capture-initiative + attribution-header preferences)

* fix(core): align user-feedback attribution bullet with the generous WHEN-IN-DOUBT-EMIT rule

* fix(core): sharpen capture_initiative trigger — call right after plan approval, before implementation

* feat(survey): raise default codebase-survey budget cap to $2 (0.5 was over-conservative)

* feat(codex): codex-v2 wrapper (SessionStart seed + per-turn recall + MCP); parametrize session-start/MCP harness

* feat(core): default bank template is harness-neutral coding-agent::{gitProject} (shared memory across agents)

* feat(core): default apiUrl is Hindsight Cloud (https://api.hindsight.vectorize.io); local is now an override

* feat(codex): Stop write-back — Codex rollout transcript reader + codex-stop-hook (full parity)

* fix(core): captureInitiative returns the server-assigned page id (not the slug) so read_knowledge_page + relatedPageId links resolve

* feat(coding-agents): upgrade opencode adapter to full v2 parity

Per-turn recall via chat.message + system.transform (750-tok budget), native hindsight_* tools registered directly through opencode's tool() (no MCP server), rich tool-aware write-back on by default, and cold-check auto-seed at plugin load — reusing the shared formatMemories / buildKnowledgePreamble / buildKnowledgeTools / buildSessionStartContext primitives so opencode matches Claude Code and Codex.

Adds transcript-opencode.ts (rich normalizer over the live message list). Adds a HINDSIGHT_DISABLE_HOOKS recursion guard to RuntimeCore (seed/recall/write-back/sync no-op; tools still register) for headless survey runs. Removes the now-dead reflect path (client.reflect, inject.ts/buildSystemInjection, reflectTimeoutMs) as the whole surface is recall-only. README rewritten to the recall/knowledge-page/seed/write-back v2 model.

* feat(coding-agents): harness-portable codebase survey (multi-agent headless)

The cold-repo survey no longer hardcodes headless `claude` — startCodebaseSurvey now runs under the current harness's own CLI (claude/codex/gemini/opencode), falling back to any available agent, so a Codex/Gemini/opencode user without claude installed still gets the survey (the git-log seed already ran regardless).

Per-agent read-only recipes: claude (-p + inline --mcp-config + --disallowedTools), codex (exec --sandbox read-only + inline -c MCP), gemini (-p --approval-mode plan --allowed-mcp-server-names hindsight --skip-trust), opencode (run --agent plan; tools from the loaded plugin under the HINDSIGHT_DISABLE_HOOKS guard). All spawned with HINDSIGHT_DISABLE_HOOKS=1. session-start threads the harness through to the survey.

* feat(gemini): add Gemini CLI v2 integration (gemini-v2)

Full v2 parity for Gemini CLI (>=0.52.0), which added a Claude-style hooks system (stdin/stdout JSON). Maps onto the shared HookSpec/runSessionStartHook/runRetainHook abstraction with Gemini's event names: BeforeAgent (per-turn recall -> hookSpecificOutput.additionalContext), SessionStart (seed), SessionEnd (write-back).

The one Gemini-specific piece is transcript-gemini.ts — a reader for the 0.52.0 chats/session-*.jsonl mutation-log (upsert-by-id, polymorphic content: user text arrays, assistant plain strings, tool results as user functionResponse parts; drops the synthetic session_context message + thoughts). Adds the gemini-v2 wrapper (build.mjs + dev-install.sh that merges hooks + mcpServers into ~/.gemini/settings.json). Validated: reader against a real transcript, and a live recall smoke test (recall_ok) end-to-end.

* style(coding-agents): prettier-format README config table

* fix(opencode): inject via lastInjection fallback (1.18.5 system.transform has no sessionId)

opencode 1.18.5 fires experimental.chat.system.transform with input {model} only — no sessionId — so RuntimeCore.getInjection(input.sessionID) looked up undefined and pushed nothing into the system prompt. Recall still ran (chat.message does pass sessionID) but the memory block + attribution preamble + knowledge-page guide never reached the model, so no visible header and no tool use.

getInjection now falls back to the most recent turn's block (lastInjection) when there's no session-keyed hit. The completion's system.transform fires right after that session's onPrompt, so lastInjection is this turn's block. Adds an inject_ok/inject_empty diag (matching recall_ok/seed_started) to confirm injection lands.

* fix(coding-agents): treat project-local config as untrusted (block apiUrl/apiToken/directoryBankMap from a repo)

A project-local .hindsight/coding-agent.json lives inside whatever repo the developer opens, so it is untrusted input. loadConfig previously merged it per-field over the user-global config, letting a repo override apiUrl while the user-global apiToken survived the merge — so a malicious repo could set only apiUrl and the client would send the user's real Bearer token plus every recall query (the prompt) and Stop write-back transcript to an attacker-controlled host, silently, just by opening the repo (verified end-to-end).

Fix: the project-local layer is now sanitized — apiUrl, apiToken, and directoryBankMap are stripped from it (top level + any harnesses.<name> section) with a one-line warning; the user-global config stays trusted and unrestricted, and a repo can still set its own per-repo bank (bankId/bankIdTemplate). Also skip re-applying the global file as a project layer when the upward findProjectConfig walk lands back on it (a repo under $HOME with no closer config), which would otherwise strip its own apiUrl and warn every session. Adds 4 regression tests.

* style(coding-agents): prettier-format config.ts

* feat(coding-agents): restore reflect as the memory path; per-turn injection from knowledge-page sections

One opinionated runtime path (no behavior config):
- reflect ONCE per session on the first prompt (agentic root-cause synthesis,
  benchmark-proven), cached and re-injected every turn — hook harnesses and the
  opencode runtime alike
- every turn: knowledge-page SECTIONS matched locally against the prompt
  (lexical section index, no server/LLM call) injected with provenance and a
  pointer to the full page — fast like recall, organized like reflect
- raw recall leaves the runtime path (still powers the hindsight_search_memory
  tool)

Session write-back: transcripts are now JSON turns matching the backfill chat
format, with each tool call compacted to a role:"action" turn naming the tool
and its primary target (no arguments, no outputs) — Claude, Codex, Gemini and
opencode readers.

Knowledge pages: no more entity_labels/tag taxonomy — pages are unscoped, each
page's source_query selects from the whole bank; survey, gitlog seed, write-back
and security hardening stay.

Spec: docs/superpowers/specs/2026-07-27-reflect-pages-runtime.md

* test(coding-agents): rewrite unit tests for reflect+pages runtime and JSON action transcripts

* test(coding-agents): live suite matches reflect_ok by content (pages_ok now follows it in the diag stream)

* docs(coding-agents): README + docs page describe the reflect+pages runtime (reflect once per session, local page-section injection per turn, JSON action write-back)

* feat(coding-agents): drop the backfill CLI — ingestion is automatic and background

- new deepen engine (dist/deepen.js, unpublished): idempotent, resumable —
  per-bank lock, dedup by document id; ingests missing conversations, the
  one-time gitlog seed, then progressively deepens recent history with
  per-commit full diffs (newest first, bounded batch per run); drains and
  creates knowledge pages last
- every session start now fires the engine (cold or warm); survey and the
  cold-seed note stay cold-only
- sync status is the new readiness contract: hindsight_sync_status agent tool
  + dist/status.js for harnesses (synced = gitlog seeded, pages present,
  extractions drained); activeOperations() filters terminal ops
- opencode write-back now upserts every turn (async) so a killed session
  loses at most the last turn
- repoNameOf resolves relative paths so document ids are path-spelling-proof
- hindsight-coding-backfill bin removed; benchmark/e2e run the engine
  directly and poll status

* polish(coding-agents): short, non-technical cold-seed message highlighting the bank id

* polish(coding-agents): cold-start banner — HINDSIGHT unicode wordmark + bank id line

* feat(coding-agents): timing diagnostics on by default

- session_start diag event on EVERY session (bank, cold/warm, pages, ms) —
  warm sessions previously logged nothing
- deepen engine: deepen_started/deepen_done/deepen_failed diag events with
  duration; child output now appended to ~/.hindsight/coding-agent-state/deepen.log
  (was stdio:ignore — undebuggable) and log lines timestamped
- retain_ok/retain_failed carry ms on both the Stop hook and the opencode
  per-turn upsert (which was fully silent)
- vitest config pins HINDSIGHT_DIAG_FILE to a tmp file so unit tests stop
  polluting the real diag log

* feat(coding-agents): show the Hindsight banner on every session start (cold: learning, warm: remembering)

* polish(coding-agents): session banner uses the API server's colored pixel-art logo (shared visual identity), wording line below

* polish(coding-agents): banner text before logo — the TUI's first-line prefix was displacing the logo's top row

* polish(coding-agents): banner logo re-rendered foreground-only — the TUI strips ANSI background colors, which deleted half the server logo's pixels

* feat(coding-agents): per-turn user-visible notice — every prompt shows what Hindsight delivered (reflect state + matched knowledge pages) via hook systemMessage; opencode logs the same line

* polish(coding-agents): per-turn notice shows the match query excerpt and the page titles it returned

* fix(pages-index): singularize plain-word tokens so plural prompts match singular headings ('components' -> 'Component map'); path-like tokens untouched

* polish(coding-agents): per-turn notice — gradient Hindsight wordmark, value-driven wording, no timings

* feat(coding-agents): interim always-inject knowledge stub + explicit Hindsight attribution

- selectSections: TEMPORARY stub returning the first section of up to 3
  distinct pages every turn regardless of prompt — guarantees injected data
  for testing source attribution; will be replaced by the server-side
  knowledge-base/search (local lexical index drops with it)
- both injection blocks now carry an ATTRIBUTION directive: when memory
  shapes the answer, the agent introduces it with '🧠 From Hindsight memory
  (<page>)' — and must never credit memory that did not contribute

* polish(coding-agents): gradient-word banner (logo dropped), lean per-turn notice, attribution directive front-loaded as a mandatory output format

* polish(coding-agents): reflect turn notice shows the assigned goal and a preview of what memory returned

* feat(coding-agents): page knowledge moves from auto-injection to an explicit tool

- new hindsight_search_knowledge_pages(query) tool (native on opencode, MCP on
  hook harnesses) — interim local selection, single swap point for the
  server-side knowledge-base/search; results carry the attribution requirement
- per-turn auto-injection of page sections removed: a trivial prompt ('yes')
  no longer displays phantom research; ordinary turns are silent
- per-turn notice only on the reflect turn (assigned goal + result preview);
  tool calls provide their own native visibility
- tool guide/roster advertises the search tool as the first stop

* feat(coding-agents): bind hindsight_search_knowledge_pages to the server-side hybrid knowledge-base search

- merge feat/knowledge-pages-okf underneath (GET /knowledge-base/search,
  BM25 + vector, RRF-fused; conflicts resolved in okf's favor for server/
  clients/UI, coding-agents docs entry preserved)
- client.searchKnowledgePages(query, limit) wraps the endpoint; the tool
  returns ranked {page, page_id, snippet, score} — verified end-to-end
  through the real MCP server against the live endpoint
- interim local selection removed from the tool path (pages-index remains
  only for the hook page cache pending full cleanup)

* refactor(coding-agents): drop pages-index — local section index deleted; hook/runtime keep only the id+title roster (content lives behind the server-side knowledge-base search)

* refactor(coding-agents): drop hindsight_search_memory (raw recall) — knowledge-page search is THE search surface; recall client method and formatter removed

* feat(coding-agents): hindsight_reflect tool — on-demand deep memory reasoning alongside the session-start reflect

* refactor(coding-agents): one 'conversation' retain strategy for all developer conversations

Backfilled decision chats and live session write-back were the same content
type (identical JSON action-transcript format) extracted two ways based only
on where they came from. Merged CHAT_MISSION + SESSION_MISSION into one
CONVERSATION_MISSION that scales facts to substance (short decision chat ->
1-2 facts, working session -> several; final-state-wins, verbatim literals,
rejected-alternative rule kept); the ≤2-fact CHAT_CUSTOM_INSTRUCTIONS
extractor is retired with it.

* feat(coding-agents): restore Chris's knowledge entity_labels tier

configureBank again sets entity_labels {knowledge: feature-work/decision/
convention/component/concept, tag:true} + entities_allow_free_form, so the
extractor routes durable facts with knowledge:<tier> tags the server-side
knowledge base can select on; capture_initiative markers regain the
knowledge:feature-work label. Pages themselves stay unscoped (the okf
knowledge base owns synthesis).

* feat(coding-agents): seeded pages tag-scoped again — page tags match the restored knowledge:<tier> entity labels (capture_initiative pages included)

* fix(coding-agents): reflect injection wrapped in <hindsight_memory> so write-back never re-ingests it; seed-state file (declined flag) removed — the live bank is the only state

* feat(coding-agents): gitIngest enum ('message' | 'full' | 'none') — one setting, one code path for seeding AND staying current

- deepen's idempotent git pass IS the sync: gitlog doc re-upserts when HEAD
  moves (gitlog-head:<sha> tag makes freshness a single tag query); in full
  mode new commits surface at the top of rev-list and the next run ingests
  them
- separate git-sync path deleted (sync.ts, runtime.syncGitOnce, gitSync
  config)

* feat(coding-agents): gitIngest defaults to 'message' (cheap by default; opt into depth); deepen gains --git-ingest override for harnesses

* feat(coding-agents): session banner shows git-sync state (condensed syncStatus): 'git in sync' / 'catching up on new commits' / 'syncing git history (n/target)'

* polish(coding-agents): two-line banner — value headline (tracking decisions/conventions/history) + bank/sync detail line

* refactor(coding-agents): ONE config file — project-local .hindsight/coding-agent.json layer removed entirely (with its sanitization machinery); per-repo routing stays via directoryBankMap

* docs(coding-agents): fix stale project-config reference in comment

* refactor(coding-agents): runtime scratch (deepen lock + engine log) moves to the OS temp dir — ~/.hindsight now holds ONLY the config file

* feat(coding-agents): cursor auto-ingestion parity — hosts without a SessionStart hook fire the deepen engine (+ cold survey) from the session's first prompt

* feat(coding-agents): leveled plugin logging — one plugin.log (debug/info/warn/error, config logLevel + HINDSIGHT_LOG_LEVEL/FILE overrides); diag events mirror at debug; deepen logs itself (separate deepen.log dropped); warn on reflect/retain failures

* feat(coding-agents): one-shot bank configuration via the server's template import — missions, strategies, entity labels, and the 5 seeded pages in a single idempotent POST /import (configureBank PUT+PATCH and createPages removed)

* feat(coding-agents): one-command installer — npx hindsight-coding-agents install|uninstall [harness...]

Detects the coding agents on the machine and merges each one's native
wiring (hooks + MCP: claude mcp add for Claude Code; hooks.json + append-
only config.toml sections for Codex; settings.json for Gemini; hooks.json
+ mcp.json for Cursor; plugin array for opencode). Idempotent by marker,
preserves foreign entries, backs up touched files as .hindsight-backup;
uninstall removes exactly ours. 27 unit tests over temp homes.

* fix(installer): refuse to install from an npx/dlx cache (wired paths would die on eviction); document global install + npm update -g as the update path

* ci(coding-agents): unit + typecheck + build job, and a live E2E job (real API server + real LLM) running the deepen->sync->reflect->injection path; prettier-format the package

* docs(blog): launch post draft — coding-agent memory results (marked draft: true)

* docs(blog): rewrite launch post as the narrative — from 'does memory even help?' through why-not-SWE-bench, the corrections dataset, benchmark-driven architecture decisions, to the final numbers

* docs(blog): position knowledge pages as a co-launch headline — living-documents framing, example page excerpt, platform-wide availability (dashboard editor, hybrid search API, bank templates), closing CTA

* docs(blog): restructure launch post payoff-first — contrarian RAG finding + cost in the lede, TL;DR box, narrated task with both runs, seeded-answers objection met head-on, data-locality/time-to-value/latency answers, Sonnet number promoted, backstory compressed to one section

* fix(coding-agents): deepen waits for server-side ops to settle (template-import page refreshes broke the synced contract); HINDSIGHT_CONFIG env override for the config path (containers/test harnesses; replaces the live test's dependency on the removed project-config layer)

* docs(blog): second-pass fixes — flagship example swapped to the arbitrary retry decision (RFC 4180 attack closed), reconstruction disclosed, 58% provenance clause, placebo backstory + grading block restored in numbers, RAG figure per-task, benchmark-site date

* docs(blog): align remaining CSV references with the retry flagship; TL;DR per-task figures

* docs(blog): flagship rebuilt on the real dataset task — the ERP export decision whose rejected alternative IS the textbook fix (='00042' formula form, minimal quoting, CRLF); dangling injection-verified reference restored; limitations cross-check attached to the correct row

* docs(blog): rewrite as the 0.9.0 launch post — five-beat narrative (question → dataset → auto-recall failure → reflect → knowledge pages from llm-wiki to self-healing) for Knowledge Pages + unified coding-agents plugin

* docs(blog): add the missing beat — shaping the dataset revealed decisions live in git, which the old plugins never ingested

* docs(blog): reframe reflect — very smart rather than slow; first message carries the session goal; on-demand reflect tool for session drift

* docs(blog): pages section addresses the 'back to files?' objection — pages as projected views over consolidated memory (contradiction resolution underneath), raw docs remain source of truth

* docs(blog): out-of-box row updated to n=3 (22/26/23 -> 0.72/task, -26%; cost -35%); matured row marked single-run

* fix(hooks): reflect block injected once per session (+ cadence refresh), not every turn — hook context persists in the transcript, so per-turn re-injection stacked duplicate blocks

* fix(coding-agents): wrapper bundles ship deepen.js, not the renamed backfill.js

The core build entry `backfill` was renamed to `deepen` (deepen engine +
status), but the three wrapper build.mjs bundleFiles lists still copied the
removed `backfill.js`, so every dev-install failed with ENOENT. Point them at
`deepen.js` (spawned by seed.ts at runtime) so the installers build again.

* feat(coding-agents): periodic re-survey — refresh structural pages every N commits

Structural knowledge pages are only generated on a cold repo, so an evolving
architecture drifts from what the survey captured. Add surveyRefreshCommits
(default 20; 0 = cold-seed only): at SessionStart, count commits reachable from
HEAD since the newest survey-baseline marker (branch-robust via
git.commitsSince) and re-run the headless survey once the threshold is crossed,
re-recording a baseline marker. Cold seed still records the first baseline.

* fix(coding-agents): per-turn hook timeout (30s) must exceed the 25s reflect cap

The once-per-session reflect is capped internally at HOOK_REFLECT_CAP_MS=25s,
but every harness killed the UserPromptSubmit/BeforeAgent hook at 15s — below
the cap. The host killed the hook mid-reflect before the cache write, so the
injection was discarded AND the reflect re-fired uncached on every turn
("UserPromptSubmit hook timed out after 15s" every prompt). Raise the hook
timeout to 30s (> cap) across claude/codex/gemini, bump Stop to 30 to match,
and document the cap-below-timeout invariant so it can't silently drift again.

* polish(coding-agents): attribution header is a bold blockquote callout, not flat text

The live directives all told the agent to credit memory with a plain inline
"From Hindsight memory (<page>):", which renders as flat text. Switch every
directive (session tool-guide, reflect injection, both MCP tool descriptions)
to a markdown blockquote header "> ... **From Hindsight memory (<page>)** — ..."
so it renders as a distinct callout, restoring the richer attribution look.

* fix(coding-agents): strip <hook_prompt> transport wrappers from retained transcripts (codex surfaces hook stdout/errors as user messages); session + backfill transcripts switch to JSONL (one turn per line — clean appends, chunker-atomic turns)

Note: benchmark numbers (n=3) were measured on the JSON-array format; JSONL
is extraction-equivalent by design but unvalidated by a sweep — gate before
quoting new numbers on this pipeline.

* fix(installer): write [features].hooks (codex_hooks deprecated in Codex >= 0.145); accept either flag as already-enabled

* fix(hooks): fire the ingestion engine from the FIRST prompt on every harness (lock-protected no-op when SessionStart already did) — safety net for sessions predating the install, whose banks otherwise never get pages; survey stays SessionStart-owned (ensureSeed hosts excepted)

* feat(status): expose survey observability — surveyBaseline (last surveyed HEAD, from Chris's survey-baseline markers) + surveyCommitsBehind in syncStatus/hindsight_sync_status

* test(status): expected shapes include the survey observability fields

* feat(survey): findings docs ARE the completion signal — surveyDocs (0-4) in syncStatus; a baseline without findings re-fires the survey at the next warm session start (crashed-survey retry)

* feat(config): banks.<bankId> overrides — per-repo opt-in/out applied AFTER bank resolution (disable a repo, tune gitIngest/retainSessions per bank) from the ONE config file; resolution fields ignored inside a bank section

* feat(config): bankAliases — remap resolved bank ids as the final resolution step (single hop, converging allowed); docs page brought fully current (env exceptions, gitIngest/logLevel/survey rows, banks overrides, aliases, resolution step 4)

* refactor(config): bank rename lives INSIDE banks.<id> as the  field (separate bankAliases tree removed) — one per-repo section for disable, behavior, and rename; applyBankConfig returns {cfg, bankId}

* docs(coding-agents): recipe — two repos sharing one bank (converge by resolved id via banks.<id>.bank, or by path prefix via directoryBankMap), with the id-vs-path rule of thumb

* rename(config): directoryBankMap -> mapPathToBank (direction-explicit; pre-0.9.0 breaking-rename window)

* feat(coding-agents): companion skill — hindsight-coding-agent SKILL.md shipped in the package and installed into ~/.claude/skills by the installer; explains storing/retrieving, full config (banks/mapPathToBank/gitIngest), install/update, and debugging

* docs(coding-agents): mention the companion skill in README + docs page

* feat(coding-agents): companion skill ships to ALL skills-capable hosts (claude/gemini/cursor native dirs, codex via ~/.agents/skills standard); retained sessions and ingested documents carry the harness as tag (harness:<name>) and metadata

* feat(skill): self-updating companion skill — every session start re-syncs installed copies with the packaged SKILL.md (presence-gated; npm update -g now updates the skill too, no re-install)

* fix(coding-agents): worktree-aware document ids (no more per-worktree gitlog duplicates) + deepen self-cleanup; issue/PR refs preserved verbatim and emitted as ENTITIES; calibrated reflect-injection wrapper; docs ported to the TRUE source (hindsight-docs/docs-integrations) that generates the skill copy

* docs(skill): explain the internal marker documents (survey-baseline:<sha> bare-sha content is deliberate — zero extracted facts; gitlog:<repo> seed doc)

* feat(survey): human-readable baseline markers under a zero-extraction marker strategy (live-verified: 0 facts) — start as researching, deepen lazily flips to completed once findings exist

* refactor(survey): one survey strategy with conditional rules replaces the separate marker strategy — status markers extract nothing, findings extract structural facts (both branches live-verified)

* fix(hooks): mid-session heal — zero knowledge pages in the roster cache fires the ingestion engine on any prompt (covers long-lived sessions predating the install; lock makes repeats free)

* feat(bank): ~ expansion in mapPathToBank; document the directory-blacklist recipe (map tree to one bank + disable it)

* feat(coding-agents): explicit correction protocol — when the agent verifies a memory is wrong/stale it ingests a 'Correction: <topic>' doc (claimed vs verified-true vs evidence); guidance in the injection wrapper, tool guide, tool description, and companion skill

* fix(hooks): reflect block injected exactly once — cadence re-injection dropped (replaying the turn-1 synthesis at arbitrary turns reads as random noise after drift; hindsight_reflect covers genuine re-need)

* fix(coding-agents): 15s hard timeout on every client request + opencode boot no longer awaits seedIfCold — a stalled memory server can never freeze the host TUI (onPrompt already tolerates a late preamble)

* fix(reflect): defer past trivial openers — a greeting no longer spends the once-per-session synthesis on 'hi' (seen live: reflect answered a greeting with persona chatter and burned the session's slot); first substantive prompt reflects instead

* test(hooks): align reflect-call assertions with the non-trivial fixture prompt

* Revert trivial-prompt reflect deferral (misread the report — the issue was the notice's UI position, not reflect-on-greeting behavior)

* fix(opencode): stop writing banner/reflect notices to stderr — opencode renders plugin stderr inside the TUI at the cursor (text wedged against the input bar); the trail moves to the plugin log

* feat(opencode): TUI companion plugin — visible presence via api.ui.toast (opencode's TUI plugin API): banner toast on activation + reflect goal/preview toasts from the plugin-log trail; installer registers the second entry

* fix(opencode): visible presence via the server client's tui.showToast (POST /tui/show-toast) — banner + reflect toasts from the server plugin; the separate TUI module approach removed (1.18.9's loader rejects tui-only entries in the shared plugin list); SDK deps bumped to 1.18.9

* fix(opencode): toasts never rendered — v1 client wants {body}, and boot toast raced TUI mount

opencode injects the v1 SDK client whose showToast signature is {body: {title,
message, variant, duration}} and which resolves with {data|error} instead of
rejecting — the earlier flat-params call sent an empty body and the failure was
invisible. Also the toast event is not durable: the seed banner on a warm bank
fired <1s after plugin init, before the TUI subscribed, and was lost. Toasts now
use the body shape, log a rejected result at debug, and defer until ~3s past
init. Verified live in tmux: boot banner and reflect toast both render.

* fix(coding-agents): reflect must report history, never issue directives

The 0.8.6-blog incident: reflect fused two true but unrelated facts (the
hermes-deprecation goal and the blog-section removals of c87e7ac19) into one
confabulated narrative rendered in the imperative — 'You should explicitly
remove the following sections' — a completed past action re-issued as a present
directive, indistinguishable from a prompt injection to the receiving agent.

Three changes:
- buildReflectQuery wraps the session's first prompt with strict rendering
  rules: declarative past-tense attributed facts only, no instructions or
  recommendations, no stitching unrelated episodes into one narrative.
- The <hindsight_memory> wrapper now states the block is a record of the past
  that never assigns tasks: imperative wording inside it is a description of
  work already done, to be ignored unless it informs the task as historical
  fact (and unrelated memories are still ignored outright).
- The reflect_ok diag event records the injected synthesis verbatim (8k cap),
  so the next incident is one grep instead of harness-transcript spelunking.

* refactor(coding-agents): read and seed knowledge pages through the knowledge-base API

The plugin advertised knowledge pages but drove them off /mental-models, so the
two halves of the feature never met: pages seeded via the bank template's
mental_models key got a mental model and no knowledge_pages node, and
/knowledge-base/search joins through that table — the five seeded pages were
absent from the corpus of the tool billed to the agent as its FIRST STOP. The one
page search could return (an initiative, created through the KB endpoint) came
back as a kp-… node id, which the reader then fed to GET /mental-models/{id} and
404'd. Search found only what read could not open.

Every page operation now speaks one id space:

- listPages reads /knowledge-base/tree and flattens it to {items:[…]}, dropping
  folders and keeping the containing folder name.
- getPage reads /knowledge-base/pages/{id} — the ids search and [[page:<id>]]
  links already hand back.
- seedPages replaces the template's mental_models key: it creates the PAGES
  taxonomy through /knowledge-base/pages and re-syncs a drifted source_query via
  PATCH /knowledge-base/nodes/{id}, so a plugin upgrade that rewords a query
  lands on the live page instead of orphaning its synthesized content. Matched by
  name, since the endpoint mints its own id; a 409 from a concurrent deepen run
  is tolerated rather than failing the run.
- createPage/updatePage/deletePage are deleted — mental-models CRUD with no
  callers outside its own tests.

Verified against a live server on a scratch bank: five real kp- nodes, re-run
reports 0 created / 5 unchanged, all five readable by their listed id, all five
now returned by /knowledge-base/search, and a hand-drifted source_query restored
onto the same node rather than a duplicate.

* feat(coding-agents): autoReflect flag — opt out of injected reflect into tool-only mode

autoReflect (default true, layerable per-harness/per-bank like every other
field) keeps today's validated behavior: one reflect synthesis injected on the
session's first prompt. Set false and nothing is injected; instead the
knowledge preamble and every roster refresh carry an explicit trigger telling
the agent to call hindsight_reflect itself whenever a new task/goal is set —
the pull-based variant, ready to benchmark against the push default.

* docs(blog): move the 0.9.0 launch post to its own PR

The draft now lives on blog/0-9-0-launch so this PR merges independently of
launch timing (hero image, publish date, and final voice pass pending there).

* fix(deepen): dead-holder locks are stale immediately, not after 30 minutes

The per-bank deepen lock only honored its TTL: a killed run (SIGKILL, crashed
harness) left its bank locked for LOCK_STALE_MS, and every subsequent deepen
exited 'another run holds the lock — nothing to do' against an empty bank.
The lock already records the holder's pid — probe it (kill -0); if the holder
is gone the lock is stale now. Found live: a killed benchmark ingestion left
four banks locked and the retry campaign polled empty banks to its deadline.

* feat(coding-agents): expand native harness support

* fix(reflect): table-shaped decisions must be reproduced verbatim, not summarized

Benchmark replay showed reflect compressing mapping/table policies into prose
('specific extensions map to specific types') and even asserting a lossy
generalization that matched a known-wrong fix — while rule-shaped policies
survive intact. The reflect query now demands complete verbatim enumeration of
mappings/sets/tables including carve-outs.

* fix(reflect): decisions outrank implementation-derived memory

Under heavy retrieval noise, reflect surfaced the git-ingested BUGGY module
source as 'the established implementation logic' while claiming no decision
records existed — presenting the bug under investigation as authority. The
rendering rules now state: report decisions and rationale, never the current
implementation (the reader has the code); when decision memory and
code-derived memory conflict, the decision wins; implementation-only matches
are not policy.

* feat(coding-agents): expand harness integrations

* Expand coding-agent integrations and legacy compatibility

* chore(coding-agents): fix the CI-only test failure and complete the release wiring

The `test-coding-agents` job failed on every run while passing locally: the
gitDiffTarget fixture committed into a temp repo without a git identity, which a
developer machine supplies from its global config and a CI runner does not
("empty ident name not allowed"). The identity is now passed per-command, the
way the harness E2E fixture already did it.

Release wiring, which was incomplete in three places that each fail at a
different point:

- scripts/release-integration.sh had no entry, so the release refuses to start.
- generate_changelog.py keeps its OWN integration list; the release script
  aborts and reverts at the changelog step when a name is missing there.
- The docs build cross-checks released tags (`integrations/<name>/vX.Y.Z`)
  against the SLUGS in integrations.json. The directory was the only
  integration carrying a `hindsight-` prefix, so the tag would have been
  `integrations/hindsight-coding-agents/...` against a `coding-agents` slug —
  green release, then a failing docs build. The directory is renamed to
  `coding-agents` so directory, integration name, tag and docs slug all agree,
  matching every other integration.

Also drops the claude-code-v2 / codex-v2 / gemini-v2 wrappers and the
hindsight-memory-v2 marketplace entry. Claude Code is fully served by
`hindsight-coding-agents install claude-code` — hooks, MCP and skill — so the
wrappers were a second copy of the same core with its own version to keep in
lockstep. The README rows that pointed at their dev-installers now name the
supported installer command instead.

* fix(coding-agents): make the installer actually re-point a moved package

Both bugs were exposed by the directory rename, which invalidated the absolute
paths every host config stores — the case `install` exists to repair.

- Grok wrote its block only when one was absent, so every later `install` was a
  silent no-op and the dead paths survived; the only repair was editing
  config.toml by hand. It now replaces the block, sharing one regex with
  uninstall.
- MARKER was the full package name, which identifies our entries for
  dedupe-on-reinstall and for uninstall. A repo checkout stopped containing it
  once the directory dropped its `hindsight-` prefix, so from a checkout
  re-installs would have accumulated duplicate hook entries and `uninstall`
  would have removed nothing. Narrowed to the substring both layouts share.

Regression tests cover a moved package being repointed (not appended past), the
marker matching npm and checkout paths, and a repeated checkout install leaving
one entry per event.

---------

Co-authored-by: Chris Latimer <[email protected]>
2026-07-31 22:15:21 +02:00
BenandClaude Opus 4.8 500a9e637a blog(evaluate-agent-memory): swap cover for a more contextual design (#3117)
The previous cover read as a context-free "10 things to look for." New cover
keeps the editorial template but leads with the subject ("Evaluating / agent
memory") and moves the listicle framing into a "THE 10-POINT CHECKLIST"
eyebrow, so the topic is clear at a glance.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-31 15:23:52 -04:00
BenandClaude Opus 4.8 486a8c41d6 blog: How to Actually Evaluate an Agent-Memory System (#3106)
* blog: How to Actually Evaluate an Agent-Memory System

A buyer's-guide / evaluation-framework post: the write→store→manage→read
lifecycle, the dimensions that matter (retrieval beyond similarity, entity
resolution, conflict updates, freshness, test-time learning), the production
dimensions most guides skip (data ownership, PII/secret security, cost,
observability, multi-tenant scoping), how to read LongMemEval, and a
copy-paste checklist. Editorial deep-dive cover.

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

* blog: retitle to "The 10 Things to Look For", swap LongMemEval→BEAM

Reviewer feedback:
- Drop "(2026)" from the title (blog posts don't use a year; that's the
  /articles convention).
- Retitle to the listicle framing "The 10 Things to Look For in an
  Agent-Memory System"; number the dimensions table and checklist 1–10 so the
  count is honest and consistent.
- Reframe the benchmark section around BEAM (10M-token tier) instead of
  LongMemEval, kept loose — the takeaway is "build your own eval on your
  domain." Remove LongMemEval-specific competitor scores.
- New editorial cover ("10 things / to look for", CHECKLIST tag).

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

* blog: drop unverified BEAM "next-best 40.6%" comparison

The agentmemorybenchmark.ai leaderboard only lists Hindsight at the 10M tier
(64.1%, verified). The 40.6% next-best figure isn't on that leaderboard, so
state only the verifiable number and leave the fuller comparison to the
beam-sota post.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-31 14:44:40 -04:00
Nicolò Boschi 1ce308f359 fix(api): report the real last-write time on banks and documents (#3109)
`/v1/default/banks` exposed only `last_document_at` (MAX of
`documents.created_at`), so a bank whose long-lived document keeps
receiving appends looked idle: ingestion time never moves, and
`banks.updated_at` only tracks name/mission edits. UIs reading either as
"last write" showed hours-old activity while memories were still landing.

Add `last_write_at` to the bank list — the newest of "a document was
(re-)retained" (`documents.updated_at`) and "a fact was stored"
(`memory_units.created_at`) — and order the list by it. `last_document_at`
keeps its ingestion-time meaning and is now documented as such. The
control-plane bank selector shows and sorts by the new field.

Same symptom on the documents list (reported in #2944): it was ordered by
`created_at DESC`, burying an actively-appended document behind every
document created after it. It is now ordered by `updated_at DESC`.

Fixes #2944
2026-07-31 18:11:24 +02:00
Nicolò Boschi d571199cd6 fix(memory-engine): compute update_memory_unit embeddings off the pooled connection (#3083)
Split update_memory_unit into a read/resolve/embed phase (no pooled connection held) and a short write transaction that re-reads the row, applies the precomputed embedding, and re-embeds in-txn only on a concurrent entity-set change; orphan entities from a failed edit are reclaimed by a forced graph-maintenance sweep. Preserves the pluggable store's begin_txn/decide_txn write-group.

Validated by CI (all three test-api shards incl. the live-PG curation suite pass). Follow-up to #3082. Refs #2434.
2026-07-31 18:09:13 +02:00
Nicolò Boschi baa923debc release(openclaw): v0.10.0 2026-07-31 18:08:02 +02:00
Nicolò Boschi d19f54c770 feat(openclaw): add preferObservations recall option (#2977) (#3108)
Adds a `preferObservations` plugin config flag (default false, backward
compatible). When enabled it forwards `prefer_observations: true` to the
recall API, which drops raw facts already consolidated into an observation
while keeping unconsolidated ones. Paired with a `recallTypes` that includes
raw types, this surfaces just-retained facts before consolidation catches up
(e.g. a /reset followed by "what did I just say?") without duplicating
already-consolidated content.

The flag requires the recall option added to the client in #2311, so bump
the plugin's @vectorize-io/hindsight-client dependency ^0.6.2 -> ^0.8.6.

Also fixes a stale integration test that #3066 missed when it flipped the
default recallInjectionPosition to 'user': the E2E suite only runs on
non-fork PRs, so #3066 (a fork PR) never exercised it and the assertion
kept expecting the old prependSystemContext placement. Updated it to expect
the new default prependContext, matching the sibling tests #3066 did update.

Closes #2977
2026-07-31 18:03:04 +02:00
Nicolò Boschi 24825200b0 fix(engine): don't hold pooled DB connections across embedder/LLM calls (#3082)
Several memory-engine paths held a pooled PostgreSQL connection checked out
for the entire duration of a slow external call (embedder/LLM). The pools are
already bounded and per-process, so this is saturation, not a leak: enough
concurrent operations park the pool on multi-second calls and everything else
blocks on acquire.

This covers the two paths that can be fixed without widening the read→write
window unsafely:

- update_mental_model: compute the embedding BEFORE acquiring a connection.
  The embedding text depends only on the incoming name/content, never on DB
  state, so it needs no connection.

- consolidation: _process_memory_batch and its executors/dedup helpers no
  longer receive a long-lived connection. Recall, the batch LLM call, every
  per-action embed, and dedup adjudication run with NO connection held; each
  helper self-acquires a short-lived connection only around its own SQL.
  Moving the slow calls off the connection widens the decision→write window,
  so the held-transaction serialization is replaced with explicit guards:
  each source-liveness check (FOR SHARE) is paired with its write in one short
  transaction, and dedup CREATE/UPDATE folds are RETURNING-gated and re-filter
  live sources inside the fold transaction (sources-before-observation lock
  order, matching the normal write paths) so a twin or source deleted during
  the now connection-free window can't drop a CREATE or fold a dead source id.
  A cheap non-locking preflight restores the pre-refactor "skip before embed
  when every source is already gone" short-circuit. The separate-store
  (non-SQL) branches and the Oracle-safe search_vector clause are preserved.

Deterministic no-DB tests pin the fold guards (RETURNING gate, live-source
filtering, created/skipped propagation) and the pre-embed short-circuits;
live-DB curation/invalidation/document-transfer tests are updated to the new
short-acquire signatures.

The update_memory_unit hold-across-embed path is intentionally left for a
follow-up: its two-phase re-lock/abort/retry has to be reconciled with the
pluggable memories store's cross-store transaction coordinator and validated
against a real database.

Refs vectorize-io/hindsight#2434
2026-07-31 17:14:59 +02:00
chethanuk cd40649393 feat(agent-sdk): let agent_knowledge_recall request source chunks (#2995)
The recall API already supports `include: {chunks}`, and the TypeScript
client already exposes it as `includeChunks`/`maxChunkTokens`, but the
agent tool forwarded only `{maxTokens, types}` — so an agent had no way
to reach the raw source text a fact was extracted from, which is exactly
what "what did we actually say" questions need.

Add optional `include_chunks` / `max_chunk_tokens` parameters and pass
them through. Both are additive and off by default, so recall responses
are unchanged unless an agent asks for chunks.

Fixes #2949
2026-07-31 17:12:27 +02:00
Sanderhoff-alt 79c9f4afeb fix(openclaw): default recall injection to user context (#3066)
Default recalled memories to user context so dynamic recall content no
longer invalidates the stable system prompt prefix on every turn.

Keep explicit prepend and append settings unchanged. Align the manifest,
docs, and tests with the cache-friendly default.

Closes #3061
2026-07-31 17:12:20 +02:00
Nicolò Boschi 3868c8d055 feat(control-plane): badge documents an in-flight retain op is updating (#3102)
* feat(control-plane): badge documents that a retain op is updating

Cross-check the documents table against pending/processing retain operations:
when an in-flight op targets a document already in the list, that document is
being rewritten — badge its row as 'Updating' (with a spinner) and poll until
the op finishes, then refresh its content.

Operations already expose document_id, but only file uploads populated it.
Populate result_metadata.document_id for single-document retains too (engine:
BatchRetainParent/ChildMetadata + submit_async_retain), so reprocesses and
single-document async retains surface their target. Multi-document batches leave
it unset (matched per single-document child) to avoid misattributing a row. No
API response-shape change, so no client/OpenAPI regen.

Adds 'documentUpdating' to all 10 locales and two engine regression tests.

* feat(control-plane): auto-detect updating documents without a reload

The badge previously only appeared once an in-flight op was already detected,
and detection only ran on load / bank-switch / upload-refresh — so from an idle
table you had to catch the moment or reload. Run the (light) operations check on
every poll tick while the view is open; keep the heavier document refresh gated
to when something is actually in flight. Also kick detection right after a
reprocess so its badge shows immediately.

* feat(control-plane): soften the updating badge + auto-refresh the docs table

Badge: drop the spinning icon for a gentle pulsing dot on a soft neutral (muted)
pill instead of the loud saturated-blue spinner.

Table: auto-refresh on a timer (every 8s idle, 4s while something is in flight)
so new/updated documents, counts, and badges appear without a manual reload —
not only while an op is already detected in flight.

* feat(control-plane): show last-refresh time next to the documents count

Stamp the wall-clock time on each list refresh and render it beside the count
('N total documents · Refreshed 14:41:32') so the auto-refresh is visible. Adds
'lastRefreshed' to all 10 locales.

* feat(control-plane): relative last-refresh time, baseline-aligned

Show the refresh time as a live relative label ('Refreshed 3 seconds ago') that
ticks every second — a self-contained component with its own 1s ticker so only
the label re-renders, localized via Intl.RelativeTimeFormat (no per-unit i18n
keys). Baseline-align the count row so the smaller label lines up with the
count text.
2026-07-31 15:48:22 +02:00
Nicolò Boschi c3b98998b7 feat(control-plane): a logo for every coding agent, not just five (#3101)
#3079 resolved `metadata.harness` to a logo, but registered only the five ids
hindsight-coding-agents emitted at the time. That integration (#2522) now ships
ten, so the majority of harnesses fell back to a raw `harness=<id>` metadata chip
— the exact thing the logo was introduced to replace.

Register the full emitted set, taken from both places that define an id:
`src/harness/hook-lifecycle.ts` (one HookSpec per hook-driven agent) and the
persistent-plugin entrypoints in `src/harness/registry.ts`, whose id is their
`createPluginEntry(...)` argument. New: `antigravity-cli`, `cline-cli`,
`copilot-cli`, `devin-cli`, `grok-build`, `kilo`.

Icons come from `hindsight-docs/static/img/icons/` where the docs site already
carries the brand (Cline, GitHub Copilot, Devin, Grok). It carries none for
Antigravity or Kilo, so those are the vendors' own marks; the registry comment
and CLAUDE.md now say that is allowed rather than implying the docs dir is the
only source.

`gemini` stays registered even though the integration replaced that harness with
`antigravity-cli` and nothing emits it any more: documents retained while it did
are still in people's banks and should keep their logo. The test that pins the
registry to the emitted set now carries that as an explicit RETIRED list, so a
speculative id still can't sneak in.

Monochrome dark-on-transparent marks (Cline, Copilot, Devin) get `dark:invert`.
Grok deliberately does not — it is a filled black tile with a white glyph, so it
reads on dark already and inverting would burn a white square into the row.
Verified all eleven at 16px against both themes.
2026-07-31 15:03:53 +02:00
BenandClaude Opus 4.8 a90f922376 blog(github-copilot): redate to 2026-07-30 (#3087)
Move the GitHub Copilot CLI persistent-memory post to today's date to align
with the 0.1.1 release and social launch. Renames the file and updates the
slug and date to 2026/07/30 (URL changes from /2026/07/29/... accordingly).

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-30 14:17:40 -04:00
BenandClaude Opus 4.8 53325898e6 fix(copilot-cli): parse Copilot CLI 1.0.76 native message transcript format (#3081)
* fix(copilot-cli): parse Copilot CLI 1.0.76 native message transcript format

Copilot CLI >= 1.0.76 writes session transcript events as dotted event
names — `{"type":"user.message","data":{"content":"..."}}` and
`assistant.message` — with the message text under `data.content`. The
transcript parser only recognized the older flat / SDK-envelope / role-nested
shapes, so it extracted zero messages from current Copilot transcripts.

Because the failure is silent (hooks load, fire, and exit 0; the parser just
returns an empty list and retain skips with "No messages in transcript"),
auto-retain quietly stopped persisting anything to the bank on newer Copilot
builds.

Teach `_parse_transcript_entry` to read the native `user.message` /
`assistant.message` envelope, using the clean `data.content` (not the sibling
`transformedContent`, which carries injected system reminders). Add a
regression test with a 1.0.76-shaped transcript asserting messages are
extracted and the reminder-laden transformedContent is ignored.

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

* release(copilot-cli): 0.1.1 — transcript parser fix + changelog

Bump hindsight-copilot-cli to 0.1.1 for the Copilot CLI 1.0.76 transcript
parser fix, and add a CHANGELOG.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-30 14:04:19 -04:00
Nicolò Boschi b651f43f22 fix(control-plane): knowledge-pages polish (#3080)
* fix(control-plane): stop next-intl parsing 'type:<x>' tag hint as an unclosed tag

The Tags hint used 'type:<x>', which next-intl reads as an unclosed rich-text
tag (INVALID_MESSAGE: UNCLOSED_TAG), crashing the create/edit page dialog.
Replace the angle-bracket placeholder with 'type:…' across all locales.

* refactor(control-plane): replace loading-emoji hourglasses with a shared Spinner

The UI used a spinning  emoji for loading in several places — inconsistent and
not accessible. Add a shared <Spinner> (wrapping lucide Loader2, sized xs–xl,
role=status) and use it for all 7 loading states: the large centered
'loading…' placeholders (documents / document / chunks / chunk-modal) and the
inline save-button spinners (tags / content / edit-memory).

* feat(control-plane): branded tumbling-logo spinner for loading states

Add a LogoSpinner that renders the Hindsight mark doing a looping 2D
'tumble' (crouch → hop + 360° flip → land squash), ported from the motion
lab. The hop is expressed as a % of the element so one keyframe scales
across sizes, and reduced-motion falls back to an opacity pulse.

Use it for the prominent centered loading states (documents list, document/
chunk modal); the compact lucide Spinner stays for tiny inline spots.

* feat(control-plane): make the tumbling logo the shared spinner everywhere

Fold LogoSpinner into the shared Spinner so every loading state renders the
Hindsight mark, and swap all remaining ad-hoc spinners (lucide Loader2, the
hand-rolled ring-<div> spinners, the sonner toast loader, and the two
RefreshCw-as-loader stand-ins) over to it — ~65 sites across 22 files.

Spinner now has two motions:
- variant "flip" (default): an in-place 360° flip + squash, safe inline and in
  buttons (only a tiny vertical bob, stays on the text baseline).
- variant "jump": the full tumble (crouch + hop + flip), for prominent centered
  loaders that have vertical room.

RefreshCw icons that spin only while refreshing are left as-is — a spinning
refresh arrow reads as "refreshing", which is distinct from a generic loader.

* fix(control-plane): show the loader (not empty state) on Documents, and finish the spinner sweep

Documents: the mount fetch is debounced, so the empty state ("No documents
found" / "0 documents") flashed before the spinner. Track a `loaded` flag and
gate the empty state + count on it, so the loader shows until the first fetch
resolves. Also swap the 📄/📊 emoji empty states for lucide icons.

Finish converting the loaders the first sweep missed (they used non-spinner
patterns, so grepping for Loader2/animate-spin didn't find them):
- pulsing Clock full-view loaders → jump Spinner (stats page, bank profile)
- emoji + text-only loaders (entities graph/list/linked-memories, llm-requests
  and audit-logs chart loaders) → Spinner
- emoji empty states (no chunks / no entities / no data) → lucide icons

* fix(control-plane): render the Documents loader on first paint (hard refresh)

On a hard refresh, bank-context starts currentBank=null and only resolves it
from the URL in an effect (after the first paint, before hydration + theme), so
the previous `!!currentBank` guard made the empty state win the very first
render — you'd see "No documents found" flash before anything else.

Gate the loader on `!loaded` alone. currentBank always resolves on a
/banks/[id] route and the fetch's finally flips `loaded` (even on an invalid
bank whose fetch errors), so it can't get stuck. Verified: the SSR HTML now
renders the loading Spinner, not the empty state.

* fix(control-plane): apply theme before first paint (no light flash on hard refresh)

ThemeProvider only reads the saved/system theme in a useEffect (after paint), so
a dark-mode user saw a light flash on every hard refresh. Add a tiny blocking
inline script as the first <body> child that sets the .dark class synchronously
before the content paints — the standard anti-FOUC pattern (what next-themes does
internally). <html> already has suppressHydrationWarning for the class mutation.
Logic mirrors lib/theme-context.tsx exactly (saved || system).

* feat(control-plane): add the tumbling mascot to the no-bank welcome screen

Loop the jump Spinner above 'Welcome to Hindsight' as a friendly greeting on
the dashboard shown when no bank is selected.

* feat(control-plane): spin the header logo on sidebar navigation

Clicking a sidebar item now gives the header Hindsight logo a one-shot 'spin
round' — a little playful nav feedback. The sidebar dispatches a
'hindsight:logo-spin' window event (decoupled, like DOCUMENTS_REFRESH_EVENT) and
the header listens and toggles a one-shot animation, cleared on animationEnd.

The logo is a wide lockup, so a 2D rotate would swing it vertical and overflow
the header — use a rotateY card-flip that stays within its footprint instead.

* feat(control-plane): spin only the logo mark (not the wordmark) on navigation

Split the header lockup into two pieces: the octopus mark (favicon.png, a
standalone image so it can rotate freely) and the 'Hindsight' wordmark (the
right slice of the full logo.png, shown via a cropped background). Their widths
sum to the full logo so they butt together seamlessly. Only the mark carries the
one-shot spin, so the wordmark stays put.

Because the mark alone is ~square, the spin is now a clean 2D rotate (reverted
the rotateY card-flip workaround that the wide full lockup had required).

* feat(control-plane): soften logo nav feedback from a spin to a subtle wiggle

A full 360 rotate was too much. Replace it with a small tilt that springs back
(logo-wiggle, ~450ms) on the mark — less impactful but still a bit of life.

* chore(control-plane): drop the dead logo-spin-once reduced-motion block

Leftover from the rename to logo-wiggle.

* style(control-plane): apply prettier formatting to the spinner-sweep files

Ran scripts/hooks/lint.sh — the earlier commits were eslint-clean but not
prettier-formatted, which tripped verify-generated-files.
2026-07-30 19:04:56 +02:00
Nicolò Boschi d105038323 fix(consolidation): trigger mental model refresh on resolved scope, not the tags column (#3053) (#3078)
The candidate query in `_trigger_mental_model_refreshes` gated on the mental
model's `tags` column, but a model's refresh scope is whatever
`_resolve_refresh_tag_filtering` resolves. Two configurations put untagged
memories in a *tagged* model's scope, and both were silently starved:

- `trigger.tags_match` "any"/"all" — non-strict matching ORs untagged rows in
- `trigger.tag_groups` — overrides the tags column entirely

Such a model reported stale forever and was only ever refreshed when some
unrelated tagged memory happened to be consolidated.

Both branches now prefilter on "can this model's scope reach untagged
memories?" instead of on the column. A tagged model left on the default
`all_strict` is still excluded — strict matching drops untagged rows, so an
untagged-only consolidation genuinely cannot make it stale, and refreshing it
would burn an LLM call to regenerate identical content. The final gate is
unchanged: `compute_mental_model_is_stale` evaluates the resolved scope, so
widening the prefilter cannot produce spurious refreshes.

The predicate uses `trigger ? 'tag_groups'`; the Oracle rewriter's key-exists
regex only matched unquoted columns, so it missed `"trigger"` (already quoted
as a reserved word by that point) and left the operator untranslated.
2026-07-30 17:10:32 +02:00
Sanderhoff-alt bc604ab91b fix(packaging): bundle licenses in Python distributions (#3067)
Stage the canonical repository license in each isolated Python build
context so wheels and source distributions include the MIT text.

Declare SPDX license metadata and verify every release artifact before
publishing to prevent repository firewalls from quarantining packages.

Closes #3054
2026-07-30 16:55:15 +02:00
Nicolò Boschi aa38790dd6 feat(control-plane): show the coding agent's logo on documents and memories (#3079)
Documents retained by hindsight-coding-agents carry the agent that wrote them
as `metadata.harness` plus a `harness:<id>` tag, but the UI rendered that as
just another `key=value` chip — indistinguishable from `session_id` while
scanning a column of near-identical `conversation:<uuid>` IDs.

Resolve the value to a logo instead:

- documents table: the mark trails the "Updated …" line (leading the ID shifted
  every row that had no harness), and the now-redundant `harness=` metadata
  chip is dropped — the `harness:<id>` tag stays, since clicking it filters
- document dialog: logo in the title, plus a Harness row
- memory dialog: a Harness card in the Document tab, next to the document's
  tags — memory units inherit the document's metadata at retain time, so no
  second lookup is needed. That tab also gained the document's metadata,
  rendered with the shared MetadataChip

The registry holds exactly the ids that integration emits (claude-code, codex,
cursor-cli, gemini, opencode; see its src/harness/hook-lifecycle.ts) and a test
asserts it stays in step — an id nothing writes is a logo nothing renders. An
unregistered harness is not an error: no logo, value still shown as metadata.

Monochrome marks are flagged so only they get `dark:invert`; multi-colour ones
are left alone.
2026-07-30 16:53:58 +02:00
Sanderhoff-alt f61d383acf feat(file-parser): support custom OCR headers (#3065)
Allow MarkItDown OCR clients to receive operator-defined default
headers for proxy routing and request tracing.

Wire the JSON environment setting through parser construction, document
the option, and cover configured and unset behavior.
2026-07-30 16:42:44 +02:00
Nicolò Boschi 29aea56281 docs(hermes): deprecate standalone hindsight-hermes plugin (#3057) (#3077)
The standalone hindsight-hermes pip plugin fails on current Hermes builds
with "Timeout context manager should be used inside a task" (an upstream
hermes-agent tool-dispatch bug). Hermes now ships a native Hindsight memory
provider, so mark the old plugin deprecated instead of chasing the upstream bug:

- Add a deprecation warning admonition pointing users to the native
  provider and the existing migration guide.
- Reword the Architecture section from plugin/entry-point language to the
  native provider.
- Drop the plugin-specific "Plugin not loading" entry-point troubleshooting.

Regenerated the hindsight-docs skill mirror.
2026-07-30 16:42:01 +02:00
Nicolò Boschi 218e6d34b1 feat(knowledge-base): client-managed knowledge pages, control-plane UI + hindsight fs CLI (#2455)
* 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.

* 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.

* 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.

* 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.

* 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.

* feat(control-plane): bank Home dashboard, Knowledge tabs, Notion editor + memory Euler graph

A large control-plane pass on the knowledge base UX:

- Home dashboard (home-view): memory constellation + read-only knowledge-page
  TOC (reuses the Pages tree) + recent documents + the bank-profile "Memory store"
  card and "Memories by ingested time" chart (extracted as reusable exports).
  Fixed-height top row so the constellation fills and the side cards scroll.
- Sidebar: add Home (first); order Home → Memories → Knowledge.
- Knowledge view: Pages / Mental Models sub-tabs (Mental Models moved out of the
  Memories view). Pages tab is an Obsidian-style workspace — file-tree sidebar +
  inline editor with open-page tabs; the generation prompt is tucked behind a
  "How this page is derived" expander; a "Backed by N memories" line opens the
  backing model's based_on via the existing mental-model detail modal. First page
  auto-opens; deep-link via ?page=.
- Knowledge graph: reframed as an Euler/Venn of the source memories — nodes are
  based_on memories, one translucent circle per page (overlaps = shared memories),
  plus the memory graph's own edges. New Constellation venn mode (nodeGroupsFn /
  groupColorFn / groupLabelFn) drawing overlapping per-group circles + pill labels.
  Backend: knowledge_page_memory_graph endpoint (pages' based_on → memory nodes).
- Pages default to the living-document trigger (observation-only, delta, exclude
  mental models, auto-refresh) + 4096 max_tokens.
- Documents: metadata badges are expandable (show all keys, not just 3).
- Regenerated OpenAPI + docs-skill.

* feat(cli): port hindsight-fs into the Rust CLI as `hindsight fs`

Rewrite the standalone TypeScript hindsight-fs tool as a native subcommand
of hindsight-cli. Mirrors a bank's knowledge base (folders + pages) to a
local folder of markdown files, one-way (API -> disk) with read-only files
and drift-revert, plus a detached background refresh daemon.

- new src/commands/fs/ module (client, format, sync, state, daemon,
  health, config, paths) with unit tests for the pure logic
- subcommands: mount/start/stop/restart/sync/status/list/logs/unmount
- deps: reqwest blocking + sha2 + libc
- remove the TS package and its npm workspace entry

* feat(knowledge-base): drop the pages Graph view + polish the Pages UX

Client:
- Remove the Tree/Graph toggle and the whole graph branch (the toggle row
  was the dead band between the tabs and the content).
- Tree rows: full-width page name + compact status dot (was a pill that
  crushed the name to a few chars); float the hover actions so they no
  longer reserve width; widen the sidebar (w-64 -> w-72).
- Borderless workspace card; solid sticky editor-tab bar (was bg-muted/20,
  so scrolled body text bled through); tighter sub-tab spacing.
- Drop getKnowledgeBaseGraph + the /api/knowledge-base/graph proxy route
  and the now-unused i18n keys (viewTree/viewGraph/graphEmpty).

Server:
- Remove GET /knowledge-base/graph, KnowledgePageGraphResponse, the
  knowledge_page_memory_graph engine method + its helper, and the orphaned
  KnowledgeGraph/palette code in okf.py.
- Regenerate OpenAPI + SDK clients + docs-skill.

* feat(knowledge-base): add hybrid GET /knowledge-base/search (BM25 + vector)

Doc-level hybrid search over a bank's knowledge pages, fused with Reciprocal
Rank Fusion in a single query — no reranker, tuned for latency (~sub-100ms
query on top of the embed).

- engine.search_knowledge_pages: vector arm (mm.embedding ANN) + BM25 arm
  (mm.search_vector, the generated tsvector over page name+content) via
  websearch_to_tsquery('english'), RRF-fused (k=60) in SQL. BM25-only
  fallback when the query embedding is unavailable. Folders excluded.
- GET /v1/default/banks/{bank}/knowledge-base/search?q=&limit= with
  KnowledgePageSearchResult/Response models (id, name, mental_model_id,
  snippet, score, updated_at).
- tests: ranks the relevant page first, excludes folders, respects limit,
  requires q.
- Regenerate OpenAPI + Python/TS/Go clients + docs-skill (Rust client has
  no KB ops; the CLI's fs port uses raw reqwest there).

* feat(control-plane): wire knowledge-page hybrid search into the Pages sidebar

A debounced search box at the top of the Knowledge sidebar queries the new
/knowledge-base/search endpoint (BM25 + vector, RRF-fused). A non-empty query
swaps the folder tree for a ranked result list (name + snippet); clicking a hit
opens the page in a tab. Clear (×) restores the tree.

- new /api/knowledge-base/search proxy route
- client.searchKnowledgePages(bankId, q, limit) in lib/api.ts
- search box + results list in knowledge-base-view.tsx (reuses openPage)
- i18n: searchPlaceholder / clearSearch / searchEmpty + api.errors.knowledgeBase.search across all locales

* feat(control-plane): widen the Knowledge tree to 1/3 + show page tags inline

- file tree pane w-72 -> w-1/3 (content gets the other 2/3)
- render each page's tags as small chips under its timestamp in the tree

* feat(control-plane): let new pages set tags in the create dialog

The create-page dialog gains a comma-separated Tags field (wired to the
existing createKnowledgePage tags param); a type:<x> tag sets the page's
OKF type. i18n added across all locales.

* perf(control-plane): stop the home dashboard blocking on a 22MB graph

The memory constellation fetched limit=1000 (≈67k edges, ~22MB) and the whole
dashboard awaited it before painting. Now the light panels (stats/pages/docs)
render immediately and the constellation loads on its own (with a spinner) at a
200-node cap (~3.7MB). The full graph stays in the Memories view.

* feat(control-plane): add a 'View all' button to the home memory card

The memory-constellation card header gets a 'View all →' link to the Memories
(data) view, matching the Knowledge pages / Recent documents cards.

* feat(knowledge-base): edit a page's source query, tags, and token budget

PATCH /knowledge-base/nodes/{id} now also updates a page's options on its
backing mental model — source_query, tags, max_tokens — each applied only when
present. Changing source_query schedules an async refresh so the page rebuilds
against the new question.

- engine.update_knowledge_page + UpdateNodeRequest fields + endpoint wiring
- control plane: an "Edit" button on the open page opens a dialog (name /
  source query / tags); tags pre-fill from the raw tree tags so the type:<x>
  tag isn't dropped on save. i18n across all locales.
- tests: update options persist; empty PATCH is 400.
- regenerated OpenAPI + clients + docs-skill.

* refactor(knowledge-base): squash page migrations into one + drop OKF naming

- Fold the three knowledge_pages migrations (table / managed column / unique
  page-name index) into a single a9b8c7d6e5f4 migration.
- Rename api/okf.py -> api/page_markdown.py and replace the "OKF" / "Open
  Knowledge Format" terminology throughout (server, control plane, CLI, i18n)
  with plain "markdown" / "page" wording — pages just render to markdown.
- Add the knowledge-base endpoints to the CLI OpenAPI-coverage skip list (they
  live in the control plane UI / are mirrored by `hindsight fs`, no CLI cmd).
- Regenerate OpenAPI + clients + docs-skill.

* test(knowledge-base): rename test_okf -> test_page_markdown, drop dead graph tests

Follow-up to the okf.py rename + Graph-view removal: the test module still
imported the old `okf` module (breaking collection for the whole api test
suite) and still tested the removed tag-based knowledge_graph() builder.

* fix(transfer): classify knowledge_pages in export-bank (skip for now)

test_export_bank_covers_schema requires every BACKUP_TABLES entry to be
classified by export-bank. knowledge_pages is skipped: carrying its self-
referential parent_id tree needs a parents-first restore order that the generic
per-row _restore_rows doesn't provide (follow-up). Mental models are carried, so
the target can recreate the tree.
2026-07-30 16:02:41 +02:00
Nicolò Boschi 0f9dc55084 chore(deps): bump pg0-embedded to >=0.15.0 (#3073) 2026-07-30 14:44:42 +02:00
Nicolò Boschi b769045b64 feat(engine): pluggable memories storage backend (#2917)
Squashes the feat/pluggable-memories-provider work into one commit.

- Carve the `memory_units` + link slice out from behind raw SQL into a pluggable
  MemoriesExtension (engine/memories/), so a different engine (memlake) can own
  memories, links, retrieval, consolidation and curation while documents, chunks,
  banks and the entity registry stay in Postgres. The default PostgresMemories
  keeps everything exactly where it was; every call site routes through the store
  interface rather than branching on the implementation.
- Route recall (semantic+BM25+graph), scan/get, stats/counts, consolidation
  writes, curation edits, bank/document deletion, entity postings and graph reads
  through the store.
- Cross-store write-group transactions (begin/decide/mint/witness + recovery
  sweep) so a store that keeps memories elsewhere commits atomically with the
  Postgres side of a retain/consolidation/curation/delete.
- Documents & chunks: when the store owns a dedicated document store
  (owns_document_store), a document's bulky extracted text + chunk texts move out
  of Postgres into it (Postgres keeps thin rows: id, content_hash, chunk_index,
  tags); reads overlay the text from the store; the original file goes through a
  memlake FileStorage backend. All gated so the Postgres path is unchanged.
2026-07-30 14:41:03 +02:00
Nicolò Boschi 74cff93098 docs: give the documents table its own section in the 0.8.6 blog post (#3064)
* docs: cover the reworked documents table in the 0.8.6 blog post

* docs: give the documents table its own section in the 0.8.6 blog post

* docs: add the documents table screencast to the 0.8.6 blog post

* docs: replace the 0.8.6 blog GIFs with 30fps MP4 video
2026-07-30 11:50:17 +02:00
Nicolò Boschi 556c2c76c6 docs: add missing 0.8.6 changelog entries (Copilot CLI, retain deadlock) (#3063) 2026-07-30 09:56:45 +02:00
BenandClaude Opus 4.8 cc1eaeeeba blog: Give GitHub Copilot CLI a memory of your codebase (#3055)
* blog: Give GitHub Copilot CLI a memory of your codebase

Tutorial for hindsight-copilot-cli (published, v0.1.0): persistent memory
for GitHub Copilot CLI via hooks. Recall on sessionStart, retain on
agentStop/sessionEnd, subagents seeded with baseline project memory.
Grounded in the integration source; documents the once-per-session recall
limitation honestly.

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

* blog: co-brand Copilot cover with the official GitHub Copilot mark

Add the GitHub Copilot logo (top-left lockup + terminal title bar) so the
cover reads as a Copilot x Hindsight co-brand.

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

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-07-29 16:01:37 -04:00
Nicolò Boschi 6268654bf6 docs: changelog and blog post for v0.8.6 (#3051)
* docs: changelog and blog post for v0.8.6

* docs: focus 0.8.6 blog post on new features

* docs: lead 0.8.6 blog with the entity timeline

* docs: use entity timeline gif in 0.8.6 blog post

* docs: drop embedded-engine bullet from 0.8.6 blog post

* chore(docs-skill): sync openapi version to 0.8.6
2026-07-29 18:14:45 +02:00
Nicolò Boschi 08995e3013 Release v0.8.6
- Update version to 0.8.6 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-29 18:09:56 +02:00
Nicolò Boschi 3a1841c421 feat(control-plane): add document tag filtering and unify facet chips (#3049)
Documents list
--------------
The documents table exposed only a document-ID search, even though
`GET /banks/{id}/documents` has supported `tags` + `tags_match` all along.
Wires those through the control-plane proxy and the client, and adds the
tag filter (with autocomplete and an any/all toggle) to the toolbar. Both
UI modes map to their `*_strict` variant, since the non-strict ones
deliberately include untagged documents and read as a broken filter.

Also fixes two things found while working on it:

- The filter toolbar lived inside the "has results" branch, so a filter
  matching nothing removed the only means of clearing it.
- Two effects both called `loadDocuments`, one debounced and one not, so
  every keystroke issued two requests.

The table is reworked around what it is actually scanned for: updated-at
moves under the document ID, created-at is dropped (it duplicated
updated-at for nearly every document, and both remain in the dialog),
tags and metadata share one column, and size / memory-units get fixed
right-aligned columns. `table-fixed` is what makes the declared widths
hold — under auto layout the long mono IDs sized the first column
themselves, so `truncate` never engaged.

Facet chips
-----------
Tags, entities and metadata were styled independently in each view: tags
were blue in the memory dialog, amber in the documents table and purple
in directives, while entities reused the tag blue so the two were
indistinguishable. The memories table had them inverted relative to the
dialog. `ui/facet-chip` is now the single place all three are rendered,
adopted across 13 components.

Colour does not carry the kind. Several revisions tried a saturated fill
per kind and each read as busy at chip size, duplicating what the `#` and
`key=` prefixes and the header legend already say. Kinds are separated by
form, over one quiet neutral treatment in three tonal variants; the brand
cyan is spent only on an active filter, as an outline.

Tokens live in globals.css so both themes resolve through the `.dark`
block. That also sidesteps a pre-existing issue: the app is on Tailwind
v4, where `dark:` compiles to a prefers-color-scheme media query, but
themes are switched with a `.dark` class and no `@custom-variant dark` is
declared — so literal `dark:` utilities only fire when the OS agrees.
That still affects ~115 utilities elsewhere and is left for a separate
change.

Layout
------
- Bank page used `min-h-screen`, so the page grew past the viewport and
  scrolled the header and sidebar away instead of scrolling `main`.
- TagFilterInput put applied chips inline with the input and the match
  toggle, which came apart past two or three tags. Chips now get their
  own row, and `flex-1 min-w-0` is baked in: without it the block's
  flex-basis was the max-content width of the chips row, so one long tag
  collapsed the sibling search input to nothing.
2026-07-29 16:58:47 +02:00
Nicolò Boschi 94f4adfbed fix(control-plane): bind the dark: variant to the .dark class (#3050)
Tailwind v4 changed the default meaning of `dark:`: it now compiles to
`@media (prefers-color-scheme: dark)` unless a `@custom-variant` says
otherwise. This app switches themes by toggling a `.dark` class on <html>
(lib/theme-context.tsx) and never declared one, so every literal `dark:`
utility was keyed to the OS setting rather than the in-app toggle — it
fired only when the two happened to agree, and never at all for a user on
a light OS.

The CSS-variable half of theming always worked, since the `.dark` block
overrides the tokens directly. That is what made this easy to miss:
backgrounds and foregrounds flipped correctly while ~145 literal `dark:`
utilities silently did nothing. They are almost entirely `dark:text-*-400`
lightened text plus `dark:prose-invert` for rendered markdown, i.e. exactly
the "make this readable on a dark surface" cases.

Measured on the LLM Requests table, the `success` badge
(`text-green-800 dark:text-green-300` on `bg-green-500/10`):

  before   1.17:1   — effectively invisible
  after   12.81:1

Every utility affected was audited: all 145 are dark-appropriate values
(lightened text, darker `bg-*-900/30` fills, stronger borders,
`prose-invert`). None were tuned to compensate for the variant being
inert, so switching it on corrects them rather than inverting anything.
Contrast was re-checked across the affected elements — on the bank config
page, all 25 elements carrying a `dark:` class pass at 7.02:1 or better.

Verified with a production build, since `@custom-variant` is parsed at
build time.
2026-07-29 16:58:04 +02:00
Nicolò Boschi 70c09adcf0 fix(clients): expose mental model query controls in python wrapper (#3047)
Mirror the TypeScript wrapper fix (#3042) on the Python side: the
hand-written Hindsight wrapper's list_mental_models forwarded only
tags, and get_mental_model forwarded no query at all, so Python
consumers silently inherited the server's detail=full default and
could not use tag-match or pagination — even though the generated
MentalModelsApi already supports all of them.

Forward tags_match/detail/limit/offset on list_mental_models and
detail on get_mental_model, and add mapping regression tests so a
refactor cannot restore the dropped controls.

Follow-up to #2975 / #3042 (Python-wrapper parity).
2026-07-29 16:00:47 +02:00
Sanderhoff-alt c5643fdf5c fix(reflect): apply exact empty scope to mental models (#3039)
Apply the exact tag filter even when the requested tag list is empty.
This keeps mental-model retrieval aligned with facts and observations.

Add a regression test that verifies the generated query selects only the
untagged global scope.
2026-07-29 15:56:59 +02:00
Nicolò Boschi 6a460d2c9a feat(reflect): add apply_all_directives to bypass directive tag scoping (#3031) (#3046)
* feat(reflect): add apply_all_directives to bypass directive tag scoping (#3031)

Directives are tag-scoped like memories: a reflect with no tags loads only
untagged directives, and tagged directives apply only when the request's tags
match. This is deliberate (isolation_mode), but it means an operator's
tag-organized directives silently never reach an untagged reflect — 45% of
standing rules in the deployment reported in #3031.

Add an opt-in `apply_all_directives` flag on the reflect request (default
false, preserving current behavior). When true, every active directive is
loaded regardless of tags, ignoring tag scope. Wired through the HTTP API,
both MCP reflect variants, and the engine.

Also correct the docs, which claimed directives are "always" enforced without
mentioning tag scoping.

Regenerated OpenAPI, clients (Go/Python/TS/Rust), and the docs skill mirror;
updated the control-plane reflect proxy + api.ts types.

* chore(cli): record apply_all_directives CLI-coverage exemption

The reflect field is intentionally not exposed as a CLI flag (available via
the REST API, SDKs, and control plane). Record the exemption so cli-coverage-check
passes, matching the existing tag_groups entry.
2026-07-29 15:39:50 +02:00
Evo 97b00ca75a fix(clients): expose mental model query controls (#3042) 2026-07-29 15:28:48 +02:00
Nicolò Boschi 9452ac29da feat(retain): report zero-fact documents at write time (#3040) (#3044)
* feat(retain): report zero-fact documents at write time (#3040)

A document whose fact extraction legitimately returns zero facts is stored
but unreachable: only memory_units carry embeddings, so recall and reflect
cannot reach a document that owns none. The retain still succeeds, the
operation reports completed, and nothing in the response, the webhook or
the metrics says the document produced no memories — the operator has no
way to know it needs a reprocess. FAIL_ON_EXTRACTION_ERRORS (#2721) cannot
help by construction: there is no error to fail on.

#2861 made retain.completed fire for zero-fact batches, but the payload is
byte-identical to a successful one, so it still carries no signal.

Add the count to all three write-time surfaces:

- retain.completed gains data.memory_unit_count, filled inside the outbox
  callback on the retain's own connection so units written by the enclosing
  transaction are visible.
- The synchronous retain response gains memory_units_created.
- New counter hindsight.retain.documents.total{outcome=facts|no_facts},
  emitted per document at both extraction exits.

The webhook and the metric report the document's total *after* the retain,
not what the call created: the delta path skips unchanged chunks, so an
idempotent re-retain creates zero units while the document keeps every
memory it had. Reporting units created would raise a false alarm on every
re-submit. The count query only runs when the call created nothing, which
is the path where no work was done anyway.

Docs: how a retain mission trades away retrieval of the raw source, the
three signals, the non-determinism caveat, and reprocess as the way back.

* fix(retain): drop memory_units_created from the retain response

The synchronous response field reported units created by that call, which is
a different number from the one the webhook and the metric report (the
document's total after the retain) and only ever populated on the sync path.
The async path is the one that matters, and it is already covered by
retain.completed carrying data.memory_unit_count.

Removing it also takes the API surface back to identical with main — the
webhook payload is now the only public shape change — so the regenerated
Python/TypeScript/Go clients and the OpenAPI spec carry no delta.

Also renames the metric's parameter to memory_unit_count to match what it is
actually handed: the document total, not units created.
2026-07-29 15:04:58 +02:00
Nicolò Boschi 40d2b7f6b8 fix(graph): serialize graph-maintenance queue enqueue against worker drain (#3034) (#3045)
The graph_maintenance_queue used a lock-free duplicate-suppression enqueue
(`ON CONFLICT DO NOTHING` on PG, `IGNORE_ROW_ON_DUPKEY_INDEX` on Oracle). Neither
locks the existing row, so a mutation re-enqueueing an already-queued unit could
not serialize against a worker that concurrently claimed (deleted) that row and
processed the unit's pre-mutation state. The re-enqueue signal was silently lost
and the unit's derived temporal/semantic links were left stale with an empty queue.

Fix (issue Option 1 — schema-free):
- PG enqueue: DO NOTHING -> DO UPDATE SET enqueued_at = <table>.enqueued_at, a
  no-op update whose purpose is to take the existing row's lock.
- PG claim: ordered-lock CTE — choose oldest by enqueued_at, then lock FOR UPDATE
  in (bank_id, unit_id) order (same idiom as prune_stale_cooccurrences' #2529 lock),
  matching the enqueue's sorted lock order so producer and worker cannot cycle.
- Oracle enqueue: MERGE (WHEN MATCHED locks the row) replacing the lock-free hint;
  claim deletes claimed keys in sorted unit_id order.
- Worker Pass 1: each claim+relink batch runs inside retry_with_backoff (already
  ORA-00060 / DeadlockDetectedError-aware) as a backstop; _BatchOutcome dataclass
  folds counters into JobResult only after commit to avoid double-counting on retry.

Adds tests/test_graph_maintenance_queue_race.py covering both interleavings, batch
selection, and concurrent no-deadlock, driven against the real Postgres test DB.
2026-07-29 14:56:35 +02:00
Nicolò Boschi b1a0ef5f7d feat(config): add per-operation reasoning_effort override (#2998) (#3043)
reasoning_effort was the only LLM request setting without a per-operation
override: retain, reflect and consolidation all read the single global
HINDSIGHT_API_LLM_REASONING_EFFORT. When one operation requires a specific
value (e.g. reflect needs "none" for OpenAI reasoning models that reject
function tools otherwise), that value is forced onto the others, silently
degrading their generation quality.

Add REASONING_EFFORT to the existing per-operation set, following the
established fallback pattern:

  HINDSIGHT_API_RETAIN_LLM_REASONING_EFFORT
  HINDSIGHT_API_REFLECT_LLM_REASONING_EFFORT
  HINDSIGHT_API_CONSOLIDATION_LLM_REASONING_EFFORT

Each falls back to HINDSIGHT_API_LLM_REASONING_EFFORT when unset.
2026-07-29 14:52:34 +02:00
Derek Bouius 4f4c2988e9 fix(oracle): guard consolidator search_vector to_tsvector for Oracle (#3021)
The consolidator emitted `search_vector = to_tsvector('...'::regconfig, ...)` gated only on `text_search_extension == "native"` — dialect-blind, so the PostgreSQL-only expression reached Oracle and the `::regconfig` cast became an unbound `:REGCONFIG` placeholder (DPY-4010). Consolidation failed on every run against Oracle.

The three UPDATE sites now route through a dialect-aware `_native_search_vector_update()` helper and the INSERT branch is guarded on `not _is_oracle()`, falling through to the no-`search_vector` path. Oracle loses nothing: `memory_units.search_vector` is a vestigial CLOB there that no Oracle code populates, and keyword search runs off `idx_mu_content_text` (CTXSYS.CONTEXT on `memory_units(text)`, SYNC ON COMMIT). All PostgreSQL paths are unchanged.

Verified in `test-typescript-client-oracle`: 200 DPY-4010 errors and 50 `Task execution failed: consolidation` on the baseline, zero here, with consolidation completing normally.
2026-07-29 11:20:23 +02:00
Sanderhoff-altandNicolò Boschi feac397324 chore(repo): remove unused code (#3007)
* chore(api): remove unused code

Remove confirmed unreferenced helpers from the API and engine.

Delete tests only where they cover superseded internal paths. Keep
active test helpers and public memory operations unchanged.

* chore(cli): remove unused code

Remove dead CLI configuration, client, and output helpers.

Drop the parser implementation and tests used only by the retired
output path.

* chore(control-plane): remove unused code

Remove unused ControlPlaneClient methods and unreachable directive
detail state from the think view.

* chore(dev): remove unused code

Remove unreferenced benchmark and repository maintenance helpers.

* chore(embed): remove unused code

Remove the unused daemon port lookup helper while preserving current
profile-based daemon discovery.

* chore(integrations): remove unused code

Remove unreferenced helpers across supported integrations.

Drop tests only for retired internal paths and retain active test and
lifecycle infrastructure.

* test(consolidation): port prompt regression tests to split builders

The dead-code cleanup removed build_batch_consolidation_prompt and its tests,
but those tests guarded behaviors that are still live in the current
build_consolidation_system_prompt / build_consolidation_input path:

- brace-safety of a mission / capacity note containing literal { } (a lone
  brace would raise KeyError in the internal str.format() and crash
  consolidation)
- output-language directive injection into the cached system prompt
- the built-in default mission when none is supplied

Re-add these as regression tests against the current builders instead of
dropping the coverage. Also fix a stale comment referencing the removed
utils.extract_facts module.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-29 11:18:30 +02:00
Nick OldandNicolò Boschi b8e1524a17 fix(tracing): serialize unvalidated provider responses (#3033)
* fix(tracing): serialize unvalidated provider responses

* test(claude-code): lock in best-effort span recording on recorder failure

Add a regression test asserting that when the span recorder itself raises,
the Claude Code provider still returns its result (the best-effort contract
restored in #3025). Also apply ruff import sorting to the test module.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-29 10:47:53 +02:00
Nicolò Boschi 979999651a docs+config(worker): rename per-type WORKER_*_MAX_SLOTS to *_RESERVED_SLOTS (#2963) (#3016)
* docs+config(worker): rename per-type WORKER_*_MAX_SLOTS to *_RESERVED_SLOTS (#2963)

The per-operation `HINDSIGHT_API_WORKER_<TYPE>_MAX_SLOTS` env vars set a
reservation *floor* (a guaranteed minimum), not a ceiling — despite the name a
type overflows the shared pool up to WORKER_MAX_SLOTS. The reporter hit exactly
this: consolidation ran 6-concurrent with "MAX_SLOTS=1".

Rename them to `<TYPE>_RESERVED_SLOTS`, which says what they do. The old
`<TYPE>_MAX_SLOTS` stays as a deprecated alias that logs a warning (setting both
is an error), so no existing deployment changes behavior. Defaults unchanged
(consolidation reserved=2).

Docs (current + version-0.8) updated to state plainly that a reservation is a
floor, not a cap — a type's real ceiling is WORKER_MAX_SLOTS. Tests cover the
new env var, the deprecated-alias mapping + warning, and the both-set error.

This is the issue's "part 1" — the cheap, high-value half. A genuine per-type
concurrency ceiling is a separate follow-up if operators need one.

* chore(docs-skill): regenerate for WORKER_*_RESERVED_SLOTS rename
2026-07-29 10:37:43 +02:00
Evo 91160f3bab fix(tei): retry HTTP 429 backpressure for reranking and embeddings (#3001)
TEI >=1.9 returns 429 as normal overload backpressure (fail-fast permit
acquisition, one permit per text), but the TEI reranker and embeddings clients only
retried 5xx, so a single 429 failed the whole recall and aborted the surrounding
consolidation round.

- Retry 429 alongside 5xx in both TEI clients, honoring Retry-After (numeric and
  HTTP-date). Other 4xx still fail fast and the retry budget is unchanged.
- Spread retries with equal jitter. TEI overload is self-synchronising, so a narrow
  jitter window left concurrent callers retrying in lockstep and re-colliding on the
  same exhausted permit pool.
- Cap a single backoff at 5s rather than the client request timeout. The reranker holds
  its concurrency semaphore across the sleep, so a large server-supplied Retry-After
  would otherwise stall every queued rerank.
- Document the TEI permit-pool sizing invariant in the reranker configuration docs.

Fixes #2991
2026-07-29 10:10:33 +02:00
Ben 2dc40f3bc7 blog: How to move your agent's memory off a vector database (#3022)
* blog: How to move your agent's memory off a vector database

Practical migration guide from Pinecone/Chroma to Hindsight: export the
text (not the vectors), map namespaces to banks, batch-retain, verify with
recall/reflect. Complements the existing "case against vector DBs" post
with the how-to. Grounded in the public SDK (create_bank, batch retain).
2026-07-28 14:22:32 -04:00
1350 changed files with 164931 additions and 11776 deletions
+79
View File
@@ -78,6 +78,17 @@ results = await asyncio.gather(*tasks, return_exceptions=True)
- **Authentication/tenancy is enforced inside each engine method, not assumed by the handler.** Every engine method that touches bank-scoped data must authenticate via `request_context` — typically `await self._authenticate_tenant(request_context)` (often indirectly through `get_bank_profile(...)`) — so the correct tenant schema is resolved before any query runs. Handlers must thread `request_context` through to the engine method; never query a tenant-scoped table assuming the schema is already set.
- Engine methods return typed models (Pydantic/dataclass), not raw dicts (see Type Safety).
### Bank/Tenant Isolation in Queries
- **Bank isolation is a hard security invariant: no query may read, count, update, or delete another bank's rows.** Tenant isolation is enforced at the schema level (the resolved `search_path` / `fq_table(...)` qualifier, gated by `_authenticate_tenant`); bank isolation is enforced *within* a schema by a `bank_id` predicate on every statement that touches a multi-bank table.
- **Every SQL statement against a multi-bank table must be constrained by `bank_id`** — directly in the `WHERE`, or transitively (see below). Multi-bank tables carry a `bank_id` column: `memory_units`, `documents`, `entities`, `entity_links`, `mental_models`, `knowledge_pages`, `memory_links`, `observation_history`, and similar.
- **The trap: filtering by a caller-supplied, non-globally-unique key without `bank_id`.** Keys like `document_id` and `mental_models.id` are unique only *per bank* (their PK is composite, e.g. `(id, bank_id)`), so the *same* id legally exists in every bank. A statement like `UPDATE memory_units SET tags = $1 WHERE document_id = $2` — no `bank_id` — silently reads/writes **every** bank's rows that share the id. This is the exact defect from #3429/#3430. Adding `AND bank_id = $n` fixes it.
- **Three ways a statement is legitimately scoped** (accept these; flag anything that fits none):
1. **Explicit** `WHERE ... AND bank_id = $n`.
2. **Globally-unique single-column PK.** Filtering by a global uuid PK (`memory_units.id`, `entities.id`, `knowledge_pages.id`) or a bank-encoded key (`chunks.chunk_id` is `{bank_id}_{document_id}_{idx}`) cannot collide across banks. Contrast the *composite*-PK ids (`documents.id`/`document_id`, `mental_models.id`) — those are dangerous and MUST carry `bank_id`.
3. **Transitive.** Junction tables without a `bank_id` column (`unit_entities`, `entity_cooccurrences`, `observation_sources`) are safe only when reached through globally-unique unit/entity ids that were themselves selected from a bank-scoped query in the same call, and edges are intra-bank by construction. If the id set could contain another bank's ids, it is not scoped.
- **Watch two smells:** (a) a caller-supplied id used in the `WHERE` with no adjacent `bank_id`, while a *neighbouring* statement in the same method does carry `bank_id` (asymmetry is the tell); (b) a `bank_id` predicate applied only under `if bank_id:` with a `bank_id: str | None = None` default — latent even if all current callers pass one.
- **Cross-bank by design must rewrite `bank_id` to the destination.** The transfer/import path is the only one that legitimately crosses banks; verify every write pins the *destination* `bank_id` and never inherits a source row's `bank_id`.
### Database Locking
- **Never use PostgreSQL advisory locks** (`pg_advisory_lock`, `pg_try_advisory_lock`, `pg_advisory_xact_lock`, `pg_advisory_unlock`, …) in migrations, engine code, or anything else. Hindsight runs against connection poolers and managed/PG-compatible services where advisory locks are unreliable or unsupported: session-level locks silently leak or vanish when a pooler hands the session to another client, and callers can block forever on a lock the server never grants. Reject any new occurrence, including ones that look "safe" because they are transaction-scoped.
- The pre-existing usage in `hindsight_api/migrations.py` is grandfathered, not a precedent — it is tracked for removal. Don't copy it.
@@ -159,12 +170,35 @@ If any files in `hindsight-api-slim/hindsight_api/api/` were changed:
- Were the client SDKs regenerated? (`./scripts/generate-clients.sh`)
- Were the control plane proxy routes updated? (`hindsight-control-plane/src/app/api/`)
### 7a. Check TS/Python wrapper-client parity
Two of the generated SDKs ship a **hand-written, maintained convenience wrapper** on top of the auto-generated low-level client — and *only* these two:
- **TypeScript**: `hindsight-clients/typescript/src/index.ts` (`HindsightClient`)
- **Python**: `hindsight-clients/python/hindsight_client/hindsight_client.py` (`Hindsight`)
(The Rust/Go/etc. clients are generated-only — no wrapper to keep in sync.)
These wrappers are what most third-party consumers actually call, and they must expose the same surface. **If a change touches one wrapper's method — adds/removes a parameter, changes a default, forwards a new query/body field — the equivalent method in the *other* wrapper must get the same change in the same (or an immediately-following) PR.** A parameter that exists in the generated SDK but is dropped by one wrapper silently strips it for every consumer of that language (this is exactly what #2975 / #3042 fixed for `detail`/`tags_match`/`limit`/`offset` on `listMentalModels`/`getMentalModel`). **Should fix** — flag any wrapper method that gains capabilities in one language but not the other, and add a matching mapping regression test on both sides.
Note: the `client-coverage-check` CI tool only validates **request-body** fields, not GET **query** parameters — so query-param parity gaps are *not* caught automatically and must be checked by hand here.
### 7b. Check API-layer data-access boundary
For each changed handler in `hindsight-api-slim/hindsight_api/api/` (e.g. `http.py`, `mcp.py`):
- **Flag any direct DB access in the handler** — `acquire_with_retry`, `conn.fetch` / `fetchrow` / `execute`, raw SQL strings, or `fq_table(...)`. These are a **must fix**: the query must be moved into a `MemoryEngine` method that returns a typed model, and the handler must call that method.
- **Verify authentication is enforced in the engine** — the handler must delegate to an engine method that authenticates via `request_context` (`_authenticate_tenant`, typically through `get_bank_profile`). A handler that reads/writes tenant-scoped data without an engine method enforcing auth is a **must fix** (tenant data could leak across schemas).
### 7c. Check bank/tenant query scoping
For **every SQL statement added or changed** in the diff (grep the diff for `conn.fetch`, `conn.fetchrow`, `conn.fetchval`, `conn.execute`, `executemany`, and any raw `SELECT`/`INSERT`/`UPDATE`/`DELETE` f-strings, including multi-line ones), verify it cannot touch another bank's rows — see **Bank/Tenant Isolation in Queries** above.
For each statement against a multi-bank table (`memory_units`, `documents`, `entities`, `entity_links`, `mental_models`, `knowledge_pages`, `memory_links`, `observation_history`, …), confirm it is scoped by one of the three legitimate mechanisms:
1. explicit `AND bank_id = $n`;
2. a globally-unique single-column PK (`*.id` uuid, or the bank-encoded `chunks.chunk_id`) — **not** a composite-PK id like `documents.id`/`document_id` or `mental_models.id`;
3. transitively, through a globally-unique id set that was itself selected from a bank-scoped query in the same call.
**Flag as a must fix** any statement filtering a multi-bank table by a caller-supplied, non-globally-unique key (`document_id`, `mental_models.id`, an entity name, …) with **no** `bank_id` predicate — construct the concrete two-bank scenario (two banks share the id; the statement reads/counts/updates/deletes the wrong bank's rows or over-reports) to confirm it's real before flagging. Prime tells: a `bank_id`-carrying sibling statement right next to a `bank_id`-less one; a `WHERE bank_id` guarded by `if bank_id:` with a `None` default; an import/transfer write that inherits a source `bank_id` instead of pinning the destination.
### 8. Check code comments
For each non-trivial change:
@@ -181,6 +215,48 @@ If any files in `hindsight-integrations/` were added or changed, verify:
- **Docs gallery + sidebar entry** — the integration must have an entry in `hindsight-docs/src/data/integrations.json`. This file is the **single source of truth** that drives both the integrations gallery and the docs sidebar (the sidebar category is injected from it at render time across all docs versions). The entry needs an internal `/sdks/integrations/<slug>` `link` and a matching page at `hindsight-docs/docs-integrations/<slug>.md(x)`. The `hindsight-docs/scripts/check-integrations.mjs` build step enforces both directions — forward: every internal JSON entry has a doc page; reverse: every released tag (`integrations/<name>/vX.Y.Z`) appears in the JSON (private infra like `cloudflare-oauth-proxy` is in the script's `EXCLUDED` set). Flag any integration that is released (or being released) but missing from `integrations.json`, and any JSON entry without a doc page. Do **not** hand-edit `versioned_sidebars/*.json` to add integration links — they are positional placeholders filled from the JSON.
- **Code standards** — the integration code must follow all Python style rules (type hints, no raw dicts, no tuple returns, etc.).
### 9a. Check parity across sibling implementations
Whenever the same capability is implemented once per *variant* — one per harness, per language, per
dialect, per provider — the new or changed variant is where a capability silently goes missing. The
defect never looks like a bug in the diff: the code that's wrong is the code that **isn't there**,
and every existing test still passes because the sibling that forgot is by definition the one nobody
wrote a test for. That is how dsh shipped in daemon mode without ever starting a daemon (#3524):
`ensureDaemon` sat in the hook-only wrappers, so all five persistent-plugin harnesses lacked it.
Known sibling families in this repo (this is not the whole list — the rule is about the *shape*):
| Family | Where |
|---|---|
| Coding-agent harnesses | `hindsight-integrations/coding-agents/src/` (hook harnesses vs. persistent-plugin harnesses: dsh, opencode, Kilo, Cline, Prime Agent) |
| Wrapper SDK clients | TypeScript + Python wrappers — see step 7a |
| Alembic migrations | `_pg_upgrade` / `_oracle_upgrade` in every migration |
| Dataplane ↔ control plane | `api/http.py` params vs `hindsight-control-plane/src/app/api/**` proxy routes + `lib/api.ts` |
| LLM providers | per-provider branches in `engine/llm_wrapper.py` |
**Procedure — do this by hand; no linter catches it.** When the diff adds a new sibling, or changes
one sibling of a family:
1. **Enumerate the family.** List every existing sibling (`ls` the directory, grep the registry).
2. **Diff the capability list, not the code.** For each capability the *other* siblings have —
lifecycle hooks called, setup/teardown performed, config flags honoured, opt-outs respected,
registry/installer/docs entries — confirm the changed sibling has it, or that its absence is
deliberate and commented. Grep is the tool: `grep -rn ensureDaemon src` proves who calls it.
3. **Prefer hoisting over copying.** If the capability now exists in N places, the fix is usually to
move it into the one path every sibling already shares (e.g. `RuntimeCore`, `buildHookOutput`),
not to paste an Nth copy that the N+1th sibling will forget again.
4. **Demand a structural guard, not just a unit test.** A test for the sibling that forgot doesn't
exist by construction, so ask for a test that asserts *over the whole family*: enumerate the
siblings from the filesystem/registry and assert each satisfies the contract, with an explicit,
commented exemption list. Precedents: `registry covers every installable harness`
(`harness/registry.test.ts`), `every harness entrypoint reaches a daemon` (`core/daemon.test.ts`),
`test_backup_tables_covers_entire_schema`, `test_migration_shape.py`.
Flag a capability present in every sibling but one as a **must fix** — state which siblings have it,
which doesn't, and what the user-visible symptom is (for #3524: every `hindsight_*` tool call fails
with ECONNREFUSED and nothing ever starts the daemon). A new sibling family member landing with no
family-wide guard test is a **should fix**.
### 10. Check MCP tool registration completeness
If any new MCP tools were added or existing tools renamed in `hindsight-api-slim/hindsight_api/mcp_tools.py`:
@@ -242,9 +318,12 @@ Present a clear summary organized by severity:
- Missing tests for new endpoints
- Direct DB access (raw SQL / `acquire_with_retry` / `fq_table`) in an `api/` handler instead of a `MemoryEngine` method
- Tenant-scoped data accessed without authentication enforced in the engine (`_authenticate_tenant` / `get_bank_profile`)
- A SQL statement against a multi-bank table filtered by a caller-supplied, non-globally-unique key without a `bank_id` predicate (cross-bank read/write leak — see step 7c)
- New integration missing tests, CI job, or release-integration.sh entry
- Released/added integration missing from `hindsight-docs/src/data/integrations.json`, or a JSON entry with no `docs-integrations/<slug>` page (fails the docs build via `check-integrations.mjs`)
- New PostgreSQL table missing from `BACKUP_TABLES` in `admin/cli.py` (silent data loss on restore)
- A capability every sibling implementation has except the one in the diff (see step 9a) — a
harness, dialect, provider or language variant that skips a lifecycle step the others perform
**Should fix** — issues that hurt code quality:
- Dead code / unused imports missed by linter
+130 -7
View File
@@ -2,12 +2,15 @@
# 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, atlas, volcano
# Supported providers: openai, openai-responses, 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
HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# Reasoning effort for providers/models that support it. Examples: low, medium, high, xhigh.
# Reasoning effort for providers/models that support it. Examples: none, low, medium, high, xhigh.
# Set it and the value is sent as given, whatever the model is called — use `none` to stop a
# self-hosted reasoning model (vLLM, Ollama, llama.cpp, TGI) emitting thinking blocks. Unset,
# no reasoning parameter is sent at all and each model runs at its own default effort.
# HINDSIGHT_API_LLM_REASONING_EFFORT=low
# Sampling temperature for internal LLM calls. Set a number in [0.0, 2.0], or `none`
@@ -35,6 +38,32 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# Disable it only for those backends; consolidation still enforces the cap.
# HINDSIGHT_API_LLM_SUPPORTS_MAX_ITEMS=true
# Pin a conversation to one backend prompt cache (OpenAI-compatible providers only).
# Server-side prompt caches are per backend server, so the same conversation has to
# reach the same one to hit. Values: auto (default), xai_conv_id (sends xAI's
# x-grok-conv-id header), openai_prompt_cache_key (sends OpenAI's prompt_cache_key
# field), none (sends nothing). "auto" picks from the base URL host and is an
# allowlist: x.ai / grok.com get the header, native OpenAI / openai.com / Azure
# OpenAI get the field, and every other backend gets nothing. Per-operation
# overrides take precedence. Set to none to disable entirely.
# HINDSIGHT_API_LLM_CACHE_AFFINITY=auto
# HINDSIGHT_API_RETAIN_LLM_CACHE_AFFINITY=none
# HINDSIGHT_API_REFLECT_LLM_CACHE_AFFINITY=xai_conv_id
# HINDSIGHT_API_CONSOLIDATION_LLM_CACHE_AFFINITY=none
# Ask litellm/litellmrouter/bedrock for structured output via a forced tool call
# instead of response_format. Enable it for backends that reject response_format
# outright -- e.g. Bedrock Claude in ap-southeast-2 ("Extra inputs are not permitted");
# the same model in us-east-1 accepts response_format and needs nothing here.
# HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL=false
# Transport-level output cap for reflect's final synthesis call. Unset = uncapped:
# the model runs to a natural stop and the reflect/mental-model max_tokens governs
# visible length via a prompt directive + a post-hoc rewrite (not by truncating the
# provider call, which on thinking models is eaten by reasoning tokens and cuts pages
# off mid-word). Set an integer only to enforce a hard cost ceiling on the call.
# HINDSIGHT_API_REFLECT_MAX_COMPLETION_TOKENS=16000
# Diagnostic: on any LLM 4xx, log the exact assembled request ([LLM_4XX_DUMP]) --
# serialized request config (message bodies stripped) + capped per-message previews.
# For debugging otherwise-unreproducible rejected calls. Off by default.
@@ -57,6 +86,12 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# HINDSIGHT_API_LLM_API_KEY=your-minimax-api-key
# HINDSIGHT_API_LLM_MODEL=MiniMax-M3 # or MiniMax-M2.7 for the previous generation
# Example: OpenAI Responses API (/v1/responses) — reasoning + function tools together
# HINDSIGHT_API_LLM_PROVIDER=openai-responses
# HINDSIGHT_API_LLM_API_KEY=your-openai-api-key
# HINDSIGHT_API_LLM_MODEL=gpt-5.6 # reasoning model (gpt-5.x / o-series); e.g. gpt-5.6-terra
# HINDSIGHT_API_LLM_REASONING_EFFORT=high # sent alongside tools, unlike chat/completions
# Example: DeepSeek configuration (https://api.deepseek.com)
# HINDSIGHT_API_LLM_PROVIDER=deepseek
# HINDSIGHT_API_LLM_API_KEY=your-deepseek-api-key
@@ -134,10 +169,22 @@ HINDSIGHT_API_LOG_LEVEL=info
# 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_DB_MAX_PARALLEL_WORKERS_PER_GATHER= # Optional cap on Postgres planner parallelism for this process's pool connections. Unset leaves the server default; 0 makes background/bulk queries run serially (useful on worker processes sharing a primary with latency-sensitive traffic).
# HINDSIGHT_API_DB_SESSION_SETUP_ON_ACQUIRE=true # Re-apply the per-connection session settings (statement_timeout, planner parallelism, trigram threshold, vector-search tuning, and the vchord search path) every time a connection is taken from the pool, not just when it is opened. Releasing a connection resets it to the server defaults, so turn this off only when the same settings are pinned on the role/database (ALTER ROLE ... SET) — then it is a pure round trip per acquire, worth reclaiming behind a transaction-mode pooler. On the vchord text-search backend the search path is in that set and losing it fails recall outright, so pin it too. application_name is always re-applied regardless.
# HINDSIGHT_API_ENTITY_TRGM_SIMILARITY_THRESHOLD=0.15 # Postgres pg_trgm.similarity_threshold applied on every pool connection, used by entity resolution's % trigram match. Must be in (0, 1]. Lower catches more substring-ish matches at higher CPU cost on large entity sets; higher is stricter and cheaper.
# HINDSIGHT_API_ENTITY_INTRABATCH_MERGE_SIMILARITY=0.5 # Trigram similarity (pg_trgm-equivalent, computed in-memory) at/above which two new names created by the SAME retain are merged into one entity (in-batch dedup of surface-form variants). Must be in (0, 1]. A merge cutoff, stricter than the recall threshold above; raise toward 1.0 to merge only near-identical forms.
# HINDSIGHT_API_RETAIN_ENTITY_RESOLUTION_MAX_CANDIDATES=200 # Max candidates scored per entity mention during retain. The fuzzy lookup keeps only this many best matches per name (ranked by trigram/Jaro-Winkler similarity) before scoring them one by one. On banks holding thousands of near-identical names an uncapped set turns one retain into minutes of CPU that stall the worker's health checks. Raise only if entities that should merge are being duplicated.
# 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).
# HINDSIGHT_API_OPERATION_RETENTION_DAYS=30 # Prune terminal operation rows, payloads, and metadata after this many days; 0 (the default) keeps them forever.
# HINDSIGHT_API_OPERATION_CLEANUP_BATCH_SIZE=1000 # Maximum expired terminal rows deleted per tenant schema in each cleanup cycle; must be positive.
# Background maintenance cadences (Optional)
# Each sweep begins with one cross-tenant discovery call that probes every schema holding the relevant
# table, in every API/worker process — so its cost scales with tenant count while the work it finds does
# not. On deployments with thousands of tenants these intervals are the knob to raise.
# HINDSIGHT_API_RETENTION_SWEEP_INTERVAL_SECONDS=3600 # How often expired audit_log / llm_requests rows are deleted across all tenant schemas. Retention is counted in days, so this only sets how promptly they disappear; 0 disables the sweeps.
# HINDSIGHT_API_OPERATION_CLEANUP_INTERVAL_SECONDS=900 # How often expired terminal operation rows are pruned; with the batch size above this sets the drain rate for a backlog. 0 disables the job.
# HINDSIGHT_API_MAINTENANCE_START_JITTER_SECONDS=60 # Upper bound on a random delay before a process runs its FIRST maintenance tick. Every job is due on that tick, so without an offset a fleet started together runs every sweep in every process at once. 0 disables the jitter.
# Vector Extension (Optional - uses pgvector by default)
# Options: "pgvector" (default), "vchord", "pgvectorscale" (DiskANN)
# HINDSIGHT_API_VECTOR_EXTENSION=pgvector
@@ -156,11 +203,18 @@ HINDSIGHT_API_LOG_LEVEL=info
# chinese_lindera/lindera(chinese), japanese_lindera/lindera(japanese),
# korean_lindera/lindera(korean), ngram(min,max), edge_ngram(min,max)
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER=
# Optional cap on the number of terms in the native PostgreSQL BM25 tsquery.
# Long queries OR-join every normalized token, which can match too much of a
# large bank. 0 (default) keeps the historical uncapped behavior; a positive
# value bounds only the native backend (other BM25 backends get the raw query).
# HINDSIGHT_API_BM25_MAX_QUERY_TERMS=0
# Cap on the number of terms in the native PostgreSQL BM25 tsquery. Long queries
# OR-join every normalized token, and native ranking (no IDF, re-ranks every
# match) can then scan a large fraction of the bank and time out. Over the cap,
# the most selective terms are kept — lowest tenant-wide document frequency, read
# for free from pg_stats (no reindex). 0 restores the uncapped behavior; the cap
# bounds only the native backend (other BM25 backends get the raw query).
# HINDSIGHT_API_BM25_MAX_QUERY_TERMS=16
# When the cap above trims a query, keep the most selective terms (lowest
# document frequency, from pg_stats) instead of the first N. true is strictly
# better for recall at no extra cost when stats exist; set false to opt out of
# the catalog read and cap by position. Ignored when the cap is 0.
# HINDSIGHT_API_BM25_SELECTIVE_TERMS=true
# File Parser (Optional - uses markitdown by default)
# HINDSIGHT_API_FILE_PARSER=markitdown
@@ -173,6 +227,8 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL=
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL=
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_PROMPT=
# Optional JSON dict of custom headers for the OCR OpenAI client (e.g. proxies / request tracing).
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_DEFAULT_HEADERS=
# Embeddings Configuration (Optional - uses local by default)
# Provider: "local" (default), "onnx", "tei", "openai", "cohere", "google", "openrouter", "zeroentropy", "litellm", or "litellm-sdk"
@@ -199,6 +255,11 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH=/models/multilingual-e5-small
# Optional for China network / restricted HF access:
# HF_ENDPOINT=https://hf-mirror.com
# Applies to any provider: cap each input at this many tiktoken tokens before
# embedding, so oversized content is truncated instead of failing the embed call
# permanently (e.g. Bedrock Titan V2's 8192, or a llama.cpp server's context). Off
# by default. (Deprecated alias: HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS)
# HINDSIGHT_API_EMBEDDINGS_MAX_INPUT_TOKENS=8192
# For TEI provider:
# HINDSIGHT_API_EMBEDDINGS_TEI_URL=http://localhost:8080
# For OpenAI-compatible embeddings:
@@ -229,6 +290,17 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_SEMANTIC_LINK_MIN_SIMILARITY=0.7
# HINDSIGHT_API_CONSOLIDATION_DEDUP_THRESHOLD=0.97
# Recall pipeline stages (all on by default). Each is hierarchical, so a single
# bank can switch a stage off via the config API without changing the server
# default. Turning all three off leaves semantic + BM25 fused by RRF, the
# lowest-latency recall path.
# Temporal retrieval arm, plus the date-aware query analysis that feeds it:
# HINDSIGHT_API_ENABLE_TEMPORAL_RETRIEVAL=true
# Entity/link graph traversal arm:
# HINDSIGHT_API_ENABLE_GRAPH_RETRIEVAL=true
# Cross-encoder rerank of the fused candidates (false = use the RRF order):
# HINDSIGHT_API_ENABLE_RERANKING=true
# Reranker Configuration (Optional - uses local by default)
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
# HINDSIGHT_API_RERANKER_PROVIDER=local
@@ -244,6 +316,27 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_RERANKER_LOCAL_ALLOW_MPS=false
# For TEI provider:
# HINDSIGHT_API_RERANKER_TEI_URL=http://localhost:8081
# For flashrank provider: passages scored per ONNX forward pass. Each pass
# allocates attention tensors sized batch * heads * seq^2, so raising this
# raises peak memory quadratically in passage length:
# HINDSIGHT_API_RERANKER_FLASHRANK_BATCH_SIZE=32
# Max candidates the cross-encoder reranks per recall (RRF pre-filters the rest):
# HINDSIGHT_API_RERANKER_MAX_CANDIDATES=300
# Optionally scale that cap by the recall budget level (the cross-encoder dominates
# a large recall's latency). 0 = fall back to the flat cap above; fully backwards-compatible.
# HINDSIGHT_API_RERANKER_MAX_CANDIDATES_LOW=0
# HINDSIGHT_API_RERANKER_MAX_CANDIDATES_MID=0
# HINDSIGHT_API_RERANKER_MAX_CANDIDATES_HIGH=0
# Reranker failover chain: extra rerankers tried, in order, when the one above
# fails. Members are numbered from 1 (indices must be contiguous) and every
# setting of member n carries the same index. A member inherits nothing from the
# primary, so spell out everything it needs. Unset = no fallback (default): a
# failing reranker fails the recall. End the chain with "rrf" to fail open and
# keep the retrieval order instead.
# HINDSIGHT_API_RERANKER_1_PROVIDER=cohere
# HINDSIGHT_API_RERANKER_1_COHERE_API_KEY=your-cohere-api-key
# HINDSIGHT_API_RERANKER_2_PROVIDER=rrf
# Observability & Tracing (Optional - disabled by default)
# Enable OpenTelemetry tracing for LLM calls (GenAI semantic conventions)
@@ -274,6 +367,36 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_LOOP_WATCHDOG_POLL_INTERVAL_MS=250
# HINDSIGHT_API_DB_ACQUIRE_WARN_THRESHOLD_MS=1000
# -----------------------------------------------------------------------------
# Extensions (Optional)
# -----------------------------------------------------------------------------
# Request headers copied into RequestContext.extra_headers so a custom
# TenantExtension / OperationValidatorExtension can read them. Comma-separated,
# matched case-insensitively. Unset by default: extensions see only the
# Authorization header. Use this when the bearer token identifies a proxy rather
# than the caller, and per-caller identity arrives in a separate header. A listed
# header that arrives more than once is dropped, so only list headers the proxy
# in front of Hindsight sets itself (stripping any client-supplied copy).
# HINDSIGHT_API_EXTENSION_PASSTHROUGH_HEADERS=x-user-assertion
# -----------------------------------------------------------------------------
# Webhooks (Optional)
# -----------------------------------------------------------------------------
# Outbound webhook delivery targets caller-supplied URLs. To prevent SSRF, the
# delivery worker blocks private, loopback, and link-local destinations
# (including the cloud metadata address 169.254.169.254) by default. List hosts
# or IP/CIDR ranges here (comma-separated) to re-permit specific internal
# destinations — e.g. 127.0.0.1 for local testing, or an internal receiver.
# HINDSIGHT_API_WEBHOOK_ALLOWED_HOSTS=127.0.0.1,internal-receiver.svc,10.0.0.0/8
# Whether the webhook delivery-history API returns the raw upstream response
# body. Off by default: returning arbitrary response bodies to callers is an
# information-exfiltration primitive. The delivery status code is always
# returned regardless. Enable only if you trust your webhook destinations.
# HINDSIGHT_API_WEBHOOK_EXPOSE_RESPONSE_BODY=false
# -----------------------------------------------------------------------------
# Control Plane (Optional)
# -----------------------------------------------------------------------------
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because it is too large Load Diff
+71 -1
View File
@@ -25,6 +25,20 @@ jobs:
with:
python-version-file: ".python-version"
# Each package is built from its own directory, so stage the repository's
# canonical license inside each isolated build context.
- name: Stage Python package licenses
run: |
for package in \
hindsight-clients/python \
hindsight-api-slim \
hindsight-api \
hindsight-all \
hindsight-all-slim \
hindsight-embed; do
cp LICENSE "$package/LICENSE"
done
# Build all packages
- name: Build hindsight-client
working-directory: ./hindsight-clients/python
@@ -50,6 +64,24 @@ jobs:
working-directory: ./hindsight-embed
run: uv build --out-dir dist
- name: Verify Python package licenses
run: |
for package in \
hindsight-clients/python \
hindsight-api-slim \
hindsight-api \
hindsight-all \
hindsight-all-slim \
hindsight-embed; do
wheel=$(find "$package/dist" -maxdepth 1 -name '*.whl' -print -quit)
sdist=$(find "$package/dist" -maxdepth 1 -name '*.tar.gz' -print -quit)
unzip -Z1 "$wheel" | grep -Eq '\.dist-info/licenses/LICENSE$'
unzip -p "$wheel" '*/METADATA' | grep -Fxq 'License-Expression: MIT'
unzip -p "$wheel" '*/METADATA' | grep -Fxq 'License-File: LICENSE'
tar -tzf "$sdist" | grep -Eq '/LICENSE$'
done
# Publish in order (client and api-slim first, then api/all wrappers which depend on them)
- name: Publish hindsight-client to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
@@ -295,17 +327,50 @@ jobs:
working-directory: hindsight-cli
run: cargo build --release --target ${{ matrix.target }}
- name: Install cargo-about
if: matrix.asset_name == 'hindsight-linux-amd64'
uses: taiki-e/install-action@v2
with:
tool: [email protected]
- name: Verify cargo-about
if: matrix.asset_name == 'hindsight-linux-amd64'
run: cargo about --version
# The build above only fetches crates for this target; cargo-about resolves
# the graph for every target platform, so fetch for all of them (that is what
# `cargo fetch` without --target does) before the --offline generate.
- name: Fetch crate sources for the license scan
if: matrix.asset_name == 'hindsight-linux-amd64'
working-directory: hindsight-cli
run: cargo fetch
- name: Generate license manifest
if: matrix.asset_name == 'hindsight-linux-amd64'
working-directory: hindsight-cli
run: mkdir -p ../artifacts && cargo about generate --offline --manifest-path Cargo.toml --config about.toml about.hbs --output-file ../artifacts/THIRD_PARTY_LICENSES.txt
- name: Verify license files
if: matrix.asset_name == 'hindsight-linux-amd64'
run: |
test -s LICENSE
test -s artifacts/THIRD_PARTY_LICENSES.txt
grep -Fq "THIRD-PARTY SOFTWARE LICENSES" artifacts/THIRD_PARTY_LICENSES.txt
- name: Prepare artifact
run: |
mkdir -p artifacts
cp hindsight-cli/target/${{ matrix.target }}/release/${{ matrix.artifact_name }} artifacts/${{ matrix.asset_name }}
if [ "${{ matrix.asset_name }}" = "hindsight-linux-amd64" ]; then
cp LICENSE artifacts/LICENSE
fi
chmod +x artifacts/${{ matrix.asset_name }}
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
name: rust-cli-${{ matrix.asset_name }}
path: artifacts/${{ matrix.asset_name }}
path: artifacts/*
retention-days: 1
release-docker-images:
@@ -569,6 +634,11 @@ jobs:
cp artifacts/rust-cli-linux-arm64/hindsight-linux-arm64 release-assets/ || true
cp artifacts/rust-cli-darwin-amd64/hindsight-darwin-amd64 release-assets/ || true
cp artifacts/rust-cli-darwin-arm64/hindsight-darwin-arm64 release-assets/ || true
# Rust CLI license files (shared by all four platform binaries)
cp artifacts/rust-cli-linux/LICENSE release-assets/
cp artifacts/rust-cli-linux/THIRD_PARTY_LICENSES.txt release-assets/
test -s release-assets/LICENSE
test -s release-assets/THIRD_PARTY_LICENSES.txt
# Helm chart
cp artifacts/helm-chart/*.tgz release-assets/ || true
ls -la release-assets/
+29
View File
@@ -0,0 +1,29 @@
name: Update star history
on:
schedule:
- cron: '17 3 * * *'
workflow_dispatch:
permissions:
contents: write
jobs:
update:
concurrency:
group: star-history
cancel-in-progress: false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: nicoloboschi/gh-stars@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
line-color: '#14b8a6'
- name: Commit chart
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add .github/star-history/data.json .github/star-history/chart.svg
git diff --cached --quiet || git commit -m 'chore: update star history'
git push
+258 -12
View File
@@ -25,23 +25,26 @@ jobs:
cli: ${{ steps.filter.outputs.cli }}
docker: ${{ steps.filter.outputs.docker }}
helm: ${{ steps.filter.outputs.helm }}
docs: ${{ steps.filter.outputs.docs }}
doc-examples: ${{ steps.filter.outputs.doc-examples }}
embed: ${{ steps.filter.outputs.embed }}
all-npm: ${{ steps.filter.outputs.all-npm }}
hindsight-all: ${{ steps.filter.outputs.hindsight-all }}
integration-tests: ${{ steps.filter.outputs.integration-tests }}
integrations-openclaw: ${{ steps.filter.outputs.integrations-openclaw }}
integrations-ai-sdk: ${{ steps.filter.outputs.integrations-ai-sdk }}
integrations-eliza: ${{ steps.filter.outputs.integrations-eliza }}
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-coding-agents: ${{ steps.filter.outputs.integrations-coding-agents }}
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-zcode: ${{ steps.filter.outputs.integrations-zcode }}
integrations-agent-plugin: ${{ steps.filter.outputs.integrations-agent-plugin }}
integrations-copilot-cli: ${{ steps.filter.outputs.integrations-copilot-cli }}
integrations-crewai: ${{ steps.filter.outputs.integrations-crewai }}
integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }}
@@ -116,12 +119,17 @@ jobs:
- 'docker/**'
helm:
- 'helm/**'
docs:
- 'hindsight-docs/**'
- '*.md'
# Integration changes can add/rename integrations, which the docs
# build's integrations check validates against integrations.json.
- 'hindsight-integrations/**'
# The RUNNABLE samples only. This replaces a broad `docs` filter that also
# matched 'hindsight-docs/**', '*.md' and 'hindsight-integrations/**' — the
# last of those so an integration rename would be caught by the docs build's
# integrations check, except that build (build-docs) has no `if:` and runs
# unconditionally anyway. test-doc-examples was the filter's only consumer,
# and it executes every sample against a live LLM-backed server, so every
# integration and prose-only PR paid for four provider-credentialed runs that
# none of those files can affect.
doc-examples:
- 'hindsight-docs/examples/**'
- 'scripts/test-doc-examples.sh'
embed:
- 'hindsight-embed/**'
all-npm:
@@ -136,6 +144,8 @@ jobs:
- 'hindsight-integrations/openclaw/**'
integrations-ai-sdk:
- 'hindsight-integrations/ai-sdk/**'
integrations-eliza:
- 'hindsight-integrations/eliza/**'
integrations-agent-framework:
- 'hindsight-integrations/agent-framework/**'
integrations-composio:
@@ -144,6 +154,8 @@ jobs:
- 'hindsight-integrations/chat/**'
integrations-claude-code:
- 'hindsight-integrations/claude-code/**'
integrations-coding-agents:
- 'hindsight-integrations/coding-agents/**'
integrations-cline:
- 'hindsight-integrations/cline/**'
integrations-codex:
@@ -186,6 +198,8 @@ jobs:
- 'hindsight-integrations/zed/**'
integrations-zcode:
- 'hindsight-integrations/zcode/**'
integrations-agent-plugin:
- 'hindsight-integrations/agent-plugin/**'
integrations-n8n:
- 'hindsight-integrations/n8n/**'
integrations-zapier:
@@ -472,6 +486,42 @@ jobs:
working-directory: ./hindsight-integrations/openclaw
run: ./scripts/smoke-test.sh
test-coding-agents:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-coding-agents == '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 || '' }}
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: hindsight-integrations/coding-agents/package-lock.json
- name: Install dependencies
working-directory: ./hindsight-integrations/coding-agents
run: npm ci
- name: Typecheck
working-directory: ./hindsight-integrations/coding-agents
run: npx tsc --noEmit
- name: Unit tests
working-directory: ./hindsight-integrations/coding-agents
run: npm test
- name: Build
working-directory: ./hindsight-integrations/coding-agents
run: npm run build
test-claude-code-integration:
needs: [detect-changes]
if: >-
@@ -752,6 +802,29 @@ jobs:
working-directory: ./hindsight-integrations/zcode
run: uv run pytest tests -v
test-agent-plugin-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-agent-plugin == '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: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Validate Agent Plugin manifests
working-directory: ./hindsight-integrations/agent-plugin
run: python3 validate.py
build-ai-sdk-integration:
needs: [detect-changes]
if: >-
@@ -815,6 +888,37 @@ jobs:
working-directory: ./hindsight-integrations/ai-sdk
run: npm run test:deno
build-eliza-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-eliza == '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/eliza
run: npm ci
- name: Run tests
working-directory: ./hindsight-integrations/eliza
run: npm test
- name: Build
working-directory: ./hindsight-integrations/eliza
run: npm run build
test-opencode-integration:
needs: [detect-changes]
if: >-
@@ -1351,6 +1455,30 @@ jobs:
hindsight-cli/target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Install cargo-about
uses: taiki-e/install-action@v2
with:
tool: [email protected]
- name: Verify cargo-about
run: cargo about --version
# cargo-about resolves the dependency graph for every target platform, so it
# needs crates this host never builds (e.g. the Android-only
# android_system_properties). Populate the registry first: `cargo fetch`
# without --target downloads for all targets, and Cargo.lock is not checked
# in, so nothing is cached from a previous step.
- name: Fetch crate sources for the license scan
working-directory: hindsight-cli
run: cargo fetch
- name: Generate and verify license manifest
working-directory: hindsight-cli
run: |
cargo about generate --offline --manifest-path Cargo.toml --config about.toml about.hbs --output-file /tmp/THIRD_PARTY_LICENSES.txt
test -s /tmp/THIRD_PARTY_LICENSES.txt
grep -Fq "THIRD-PARTY SOFTWARE LICENSES" /tmp/THIRD_PARTY_LICENSES.txt
- name: Run unit tests
working-directory: hindsight-cli
run: cargo test
@@ -4489,6 +4617,73 @@ jobs:
fi
done || true
# Compatibility gate against hermes-agent's *main* branch.
#
# `hermes memory setup` installs `hindsight-all` into Hermes' own venv to run
# memory in local_embedded mode, and Hermes exact-pins every direct dependency
# (`==X.Y.Z`) as a deliberate supply-chain policy — they will not loosen a pin
# for us. So any version range Hindsight declares that excludes one of their
# pins makes the two impossible to co-install for every Hermes user on
# embedded memory. That was #3251: our `cryptography>=48.0.1` / `pillow>=12.3.0`
# against their `==46.0.7` / `==12.2.0`, which left `pip check` permanently
# broken. Both sides bump on their own schedule, so this needs a standing gate
# rather than a one-off fix; tracking main surfaces the next collision while
# it is still cheap to fix on either side.
#
# Deliberately not gated on has_secrets — the resolution, wiring, runtime and
# daemon-boot checks need no credentials, so this runs on fork PRs too. Only
# retain/recall need an LLM and the script skips them when no key is present.
test-hermes-compat:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.embed == 'true' ||
needs.detect-changes.outputs.hindsight-all == 'true' ||
needs.detect-changes.outputs.clients-python == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
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
# hindsight-all pulls the local-ml extra, so the embedded daemon can load
# sentence-transformers models. Cache them like the test-embed job does.
- name: Cache HuggingFace models
uses: actions/cache@v5
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-hermes-compat-${{ hashFiles('hindsight-api-slim/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-hermes-compat-
${{ runner.os }}-huggingface-
- name: Run Hermes compatibility test
env:
GITHUB_TOKEN: ${{ github.token }}
run: ./scripts/test-hermes-compat.sh
- name: Collect embedded daemon logs on failure
if: failure()
run: |
for f in ~/.hindsight/profiles/hermes-ci*.log ~/.hindsight/profiles/hermes-ci*.stderr.log; do
if [ -f "$f" ]; then
echo "=== $f ==="
cat "$f"
fi
done || true
test-hindsight-all:
needs: [detect-changes]
if: >-
@@ -4590,7 +4785,7 @@ jobs:
needs.detect-changes.outputs.clients-python == 'true' ||
needs.detect-changes.outputs.clients-go == 'true' ||
needs.detect-changes.outputs.cli == 'true' ||
needs.detect-changes.outputs.docs == 'true' ||
needs.detect-changes.outputs.doc-examples == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -4994,13 +5189,26 @@ jobs:
exit 0
fi
echo "Checking OpenAPI compatibility against base branch: $BASE_BRANCH"
# Compare against the point this branch was cut from, NOT the live tip of
# the base branch. Using the tip reports every endpoint main has gained
# since the branch was cut as "removed by this PR" — a false positive that
# fails PRs which touch no spec at all, and whose only cure is an unrelated
# rebase. The merge-base answers the question the check actually asks:
# did *this branch* remove something?
MERGE_BASE="$(git merge-base "origin/$BASE_BRANCH" HEAD)"
# Extract the old OpenAPI spec from base branch
git show "origin/$BASE_BRANCH:hindsight-docs/static/openapi.json" > /tmp/old-openapi.json
if [ -z "$MERGE_BASE" ]; then
echo "⚠️ Warning: Could not determine merge-base with $BASE_BRANCH. Skipping compatibility check."
exit 0
fi
echo "Checking OpenAPI compatibility against $BASE_BRANCH merge-base: $MERGE_BASE"
# Extract the old OpenAPI spec from the merge-base
git show "$MERGE_BASE:hindsight-docs/static/openapi.json" > /tmp/old-openapi.json
if [ ! -s /tmp/old-openapi.json ]; then
echo "⚠️ Warning: Could not find OpenAPI spec in base branch. Skipping compatibility check."
echo "⚠️ Warning: Could not find OpenAPI spec at the merge-base. Skipping compatibility check."
exit 0
fi
@@ -5042,6 +5250,42 @@ jobs:
cd hindsight-dev
uv run cli-coverage-check
# hindsight-dev/tests had no job of its own, so nothing ran it: the benchmark
# harness was only exercised by the nightly Performance Tests workflow, where a
# plain construction bug in the answer/judge LLM config surfaced as a red
# benchmark hours later instead of on the PR that introduced it.
test-dev:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.dev == '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
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Install hindsight-dev dependencies
run: |
cd hindsight-dev && uv sync --frozen --extra test --index-strategy unsafe-best-match
- name: Run hindsight-dev tests
working-directory: hindsight-dev
run: uv run pytest tests/ -v
# Report CI status back to the PR for pull_request_review events.
# GitHub does not automatically link pull_request_review check runs to the PR,
# so we create a commit status on the PR head SHA and post a comment.
@@ -5061,6 +5305,7 @@ jobs:
- test-codex-integration
- test-cursor-cli-integration
- test-zcode-integration
- test-agent-plugin-integration
- build-ai-sdk-integration
- test-ai-sdk-integration-deno
- test-opencode-integration
@@ -5116,6 +5361,7 @@ jobs:
- test-embed
- test-embed-windows
- verify-embed-control-center-bundle
- test-hermes-compat
- test-hindsight-all
- test-hindsight-agent-sdk
- test-claude-agent-sdk-integration
+10
View File
@@ -5,6 +5,13 @@ build/
dist/
wheels/
*.egg-info
# Release builds stage the canonical root license in each package context.
/hindsight-clients/python/LICENSE
/hindsight-api-slim/LICENSE
/hindsight-api/LICENSE
/hindsight-all/LICENSE
/hindsight-all-slim/LICENSE
/hindsight-embed/LICENSE
.mcp.json
.playwright-mcp/
.osgrep
@@ -13,6 +20,9 @@ wheels/
# Node
node_modules/
# Without this, the pattern above matches directories only — a node_modules SYMLINK (what you get
# pointing a scratch worktree at an installed one) is a file, slips past it, and can be committed.
node_modules
# Environment variables and local config
.env
+29
View File
@@ -286,6 +286,35 @@ When adding or modifying parameters in the dataplane API (hindsight-api), you mu
- Update the client type definition in `lib/api.ts`
- Update any UI components that need to use the new parameter
### Harness Attribution (which coding agent wrote a document)
`hindsight-integrations/hindsight-coding-agents/` stamps the coding agent on every
document it retains, so the control plane can show its logo instead of another
`key=value` chip:
- `metadata.harness = "<id>"` — the authoritative field
- tag `harness:<id>` — the same value, so the documents list can filter on it
The ids are defined by that integration's HookSpecs
(`src/harness/hook-lifecycle.ts`) plus the persistent-plugin entrypoints
registered in `src/harness/registry.ts`, whose id is their
`createPluginEntry(...)` argument — currently `antigravity-cli`, `claude-code`,
`cline-cli`, `codex`, `copilot-cli`, `cursor-cli`, `devin-cli`, `grok-build`,
`kilo`, `opencode`.
The control plane resolves the value in
`hindsight-control-plane/src/lib/harness-logo.ts` (metadata wins over the tag) and
renders it with `components/ui/harness-logo.tsx` in the documents table and the
document detail dialog. **Adding a harness to the integration means adding it to
that registry in the same change**: copy its icon from
`hindsight-docs/static/img/icons/` (or take it from the agent's own brand assets
when the docs site carries none) into
`hindsight-control-plane/public/img/harness/` and add one entry. Don't register
ids nothing writes — a test asserts the registry matches the emitted set, plus an
explicit list of retired ids kept so already-retained documents keep their logo.
An unregistered harness is not an error: it renders no logo and still shows as
ordinary metadata.
### Adding New Integrations
Every new integration in `hindsight-integrations/` must satisfy all of the following before it can be merged:
+1 -1
View File
@@ -298,7 +298,7 @@ client.reflect(bank_id="my-bank", query="What should I know about Alice?")
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=vectorize-io/hindsight&type=date&legend=top-left)](https://www.star-history.com/#vectorize-io/hindsight&type=date&legend=top-left)
[![Star history](https://raw.githubusercontent.com/vectorize-io/hindsight/main/.github/star-history/chart.svg)](https://github.com/vectorize-io/hindsight/stargazers)
---
## Supported Platforms
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

@@ -65,7 +65,7 @@ services:
environment:
# LLM Configuration
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
HINDSIGHT_API_LLM_API_KEY: ${HINDSIGHT_API_LLM_API_KEY:-}
# Database Configuration
HINDSIGHT_API_DATABASE_URL: postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:-hindsight_password}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
@@ -17,7 +17,7 @@ FROM ghcr.io/vectorize-io/hindsight:latest-slim
# `pip install` would fall back to user site-packages and not be visible
# to the runtime python.
RUN uv pip install --python /app/api/.venv/bin/python --no-cache \
'sentence-transformers>=3.3.0' \
'sentence-transformers>=5.0.0' \
'transformers>=4.53.0' \
'torch>=2.6.0'
@@ -27,7 +27,7 @@ needed in the image.
## Quick start
```bash
export OPENAI_API_KEY=sk-xxx
export HINDSIGHT_API_LLM_API_KEY=sk-xxx
docker compose -f docker/docker-compose/custom-models/docker-compose.yaml up --build
```
@@ -3,11 +3,12 @@ name: hindsight-custom-models
# in at build time, so pod startup does not depend on HuggingFace at runtime.
#
# Quick start:
# export OPENAI_API_KEY=sk-xxx
# export HINDSIGHT_API_LLM_API_KEY=sk-xxx
# docker compose -f docker/docker-compose/custom-models/docker-compose.yaml up --build
#
# Required environment variables:
# - OPENAI_API_KEY (or configure another LLM provider via HINDSIGHT_API_LLM_*)
# - HINDSIGHT_API_LLM_API_KEY (pair it with HINDSIGHT_API_LLM_PROVIDER to use
# a provider other than the default openai)
services:
hindsight:
@@ -25,7 +26,7 @@ services:
- "9999:9999"
environment:
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
HINDSIGHT_API_LLM_API_KEY: ${HINDSIGHT_API_LLM_API_KEY:-}
# Point Hindsight at the models baked into the image above.
HINDSIGHT_API_EMBEDDINGS_PROVIDER: local
@@ -39,7 +39,7 @@ services:
- "8888:8888"
- "9999:9999"
environment:
- HINDSIGHT_API_LLM_API_KEY=${OPENAI_API_KEY?Please set the OPENAI_API_KEY env variable}
- HINDSIGHT_API_LLM_API_KEY=${HINDSIGHT_API_LLM_API_KEY:?Please set the HINDSIGHT_API_LLM_API_KEY env variable}
- HINDSIGHT_API_DATABASE_URL=postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:?Please set the HINDSIGHT_DB_PASSWORD env variable}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
depends_on:
- db
@@ -72,7 +72,7 @@ services:
environment:
# LLM Configuration
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
HINDSIGHT_API_LLM_API_KEY: ${HINDSIGHT_API_LLM_API_KEY:-}
# Database Configuration
HINDSIGHT_API_DATABASE_URL: postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:-hindsight_password}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
@@ -68,7 +68,7 @@ services:
environment:
# LLM Configuration
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
HINDSIGHT_API_LLM_API_KEY: ${HINDSIGHT_API_LLM_API_KEY:-}
# Database Configuration
HINDSIGHT_API_DATABASE_URL: postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:-hindsight_password}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
+4 -14
View File
@@ -3,21 +3,11 @@
# pgroonga is a multilingual full-text search extension built on Groonga.
# It works out of the box for CJK (Chinese, Japanese, Korean) and other
# non-whitespace-segmented languages via the TokenBigram tokenizer.
FROM groonga/pgroonga:latest-debian-pg17
FROM groonga/pgroonga:4.0.8-debian-17
# Install pgvector on top of the pgroonga base image (which already provides
# pgroonga and the Groonga library).
# pgroonga, the Groonga library, and the PostgreSQL PGDG package repository).
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
git \
postgresql-server-dev-17 \
postgresql-17-pgvector=0.8.6-1.pgdg13+1 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN cd /tmp && \
git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git && \
cd pgvector && \
make && \
make install
RUN rm -rf /tmp/pgvector && \
apt-get purge -y --auto-remove build-essential git postgresql-server-dev-17
@@ -68,7 +68,7 @@ services:
environment:
# LLM Configuration
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
HINDSIGHT_API_LLM_API_KEY: ${HINDSIGHT_API_LLM_API_KEY:-}
# Database Configuration
HINDSIGHT_API_DATABASE_URL: postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:-hindsight_password}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
@@ -59,7 +59,7 @@ services:
- "8888:8888"
- "9999:9999"
environment:
- HINDSIGHT_API_LLM_API_KEY=${OPENAI_API_KEY?Please set the OPENAI_API_KEY env variable}
- HINDSIGHT_API_LLM_API_KEY=${HINDSIGHT_API_LLM_API_KEY:?Please set the HINDSIGHT_API_LLM_API_KEY env variable}
- HINDSIGHT_API_DATABASE_URL=postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:?Please set the HINDSIGHT_DB_PASSWORD env variable}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
# S3 file storage configuration (SeaweedFS)
- HINDSIGHT_API_FILE_STORAGE_TYPE=s3
+107
View File
@@ -0,0 +1,107 @@
# Hindsight with TEI embeddings + reranker
Example Docker Compose setup that serves **embeddings and reranking from two
[HuggingFace Text Embeddings Inference (TEI)](https://github.com/huggingface/text-embeddings-inference)
sidecars** instead of the in-process local models.
Because embeddings and reranking run outside the API, Hindsight itself needs
no baked-in models, so this uses the **slim** image
(`ghcr.io/vectorize-io/hindsight:latest-slim`). Only the LLM — used for
retain/recall/reflect — still needs a provider and API key.
## When to use this
- You want embeddings/reranking on a dedicated, independently scalable
inference server (e.g. a GPU node) rather than in the API process.
- You run the **slim** image and pull embeddings/reranking from an external
service.
- You want a self-hosted, offline-capable alternative to a cloud embeddings
provider (OpenAI, Cohere, ...).
If you just want local models in-process, use the default full image — no
sidecars required.
## What it runs
| Service | Image | Model |
| --------------- | ------------------------------------------------------ | ---------------------------------------- |
| `tei-embedding` | `ghcr.io/huggingface/text-embeddings-inference:cpu-1.8.3` | `BAAI/bge-small-en-v1.5` (384-dim) |
| `tei-reranker` | `ghcr.io/huggingface/text-embeddings-inference:cpu-1.8.3` | `BAAI/bge-reranker-base` |
| `hindsight` | `ghcr.io/vectorize-io/hindsight:latest-slim` | — (slim; talks to the sidecars) |
This is a prod-like configuration: the embedding model is Hindsight's default
(`bge-small-en-v1.5`), the reranker is the `bge-reranker-base` cross-encoder
commonly paired with it on dedicated inference servers, and both services carry
throughput flags (`--max-concurrent-requests`, `--max-batch-tokens`,
`--max-client-batch-size`) tuned for sustained multi-client load instead of
TEI's bare defaults. The API points at the sidecars with:
```
HINDSIGHT_API_EMBEDDINGS_PROVIDER=tei
HINDSIGHT_API_EMBEDDINGS_TEI_URL=http://tei-embedding:80
HINDSIGHT_API_RERANKER_PROVIDER=tei
HINDSIGHT_API_RERANKER_TEI_URL=http://tei-reranker:80
```
## Quick start
```bash
export HINDSIGHT_API_LLM_API_KEY=sk-xxx
docker compose -f docker/docker-compose/tei/docker-compose.yaml up
```
- API: http://localhost:8888
- Control Plane: http://localhost:9999
- TEI embedding server: http://localhost:8080 (exposed for debugging)
- TEI reranker server: http://localhost:8081 (exposed for debugging)
`hindsight` waits (via `depends_on: service_healthy`) until both TEI servers
report healthy, so the first boot pauses while each model downloads into its
`tei_*_cache` volume. Subsequent boots reuse the cached models.
To use an LLM provider other than the default `openai`:
```bash
export HINDSIGHT_API_LLM_PROVIDER=gemini
export HINDSIGHT_API_LLM_API_KEY=...
docker compose -f docker/docker-compose/tei/docker-compose.yaml up
```
## Using your own models
Change the `--model-id` in each service's `command` to any TEI-supported
model. The embedding dimension is **auto-detected from the server** and the
pgvector schema is adjusted to match on first boot — no dimension env var to
set. (If you switch the embedding model after data already exists, start from
a fresh `pg_data` volume, since the stored vectors were built for the old
dimension.)
## Verifying the servers
```bash
# Health
curl 127.0.0.1:8080/health && curl 127.0.0.1:8081/health
# Embedding (returns a 384-length vector for the default model)
curl 127.0.0.1:8080/embed -H 'content-type: application/json' \
-d '{"inputs":"hello world"}'
# Rerank
curl 127.0.0.1:8081/rerank -H 'content-type: application/json' \
-d '{"query":"what is the capital of France?","texts":["Paris is the capital of France.","Bananas are yellow."]}'
```
## Apple Silicon / arm64
The `cpu-1.8.3` TEI images are published for `linux/amd64` only. On an
Apple Silicon Mac, run under emulation:
```bash
export DOCKER_DEFAULT_PLATFORM=linux/amd64
docker compose -f docker/docker-compose/tei/docker-compose.yaml up
```
Emulated startup is slow (model load takes a few minutes). For production,
run on `amd64` hosts — or a GPU node with the CUDA-tagged TEI image and a GPU
reservation.
@@ -0,0 +1,113 @@
name: hindsight-tei
# Example: run Hindsight with embeddings and reranking served by two
# HuggingFace Text Embeddings Inference (TEI) sidecars instead of the
# in-process local models.
#
# Because embeddings and reranking are external, Hindsight itself needs no
# baked-in models — this uses the **slim** image
# (`ghcr.io/vectorize-io/hindsight:latest-slim`). Only the LLM (used for
# retain/recall/reflect) still needs a provider + API key.
#
# The two TEI services here run a prod-like configuration:
# `BAAI/bge-small-en-v1.5` embeddings (384-dim, Hindsight's default) and the
# `BAAI/bge-reranker-base` cross-encoder, with the batching/concurrency flags
# tuned for sustained multi-client load rather than TEI's bare defaults. Swap
# the `--model-id` args to serve any TEI-supported model — the embedding
# dimension is auto-detected from the server, and the pgvector schema is
# adjusted to match on first boot.
#
# Quick start:
# export HINDSIGHT_API_LLM_API_KEY=sk-xxx
# docker compose -f docker/docker-compose/tei/docker-compose.yaml up
#
# First boot downloads the two models into the `tei_*_cache` volumes;
# subsequent boots reuse them.
services:
tei-embedding:
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.8.3
container_name: hindsight-tei-embedding
# Prod-like tuning: high request concurrency with bounded batch sizes.
command:
[
"--model-id", "BAAI/bge-small-en-v1.5",
"--max-concurrent-requests", "512",
"--max-batch-tokens", "16384",
"--max-client-batch-size", "32",
"--auto-truncate",
]
environment:
# TEI listens on port 80 inside the container by default.
PORT: "80"
ports:
# Exposed on the host so you can curl the server directly, e.g.
# curl 127.0.0.1:8080/embed -H 'content-type: application/json' \
# -d '{"inputs":"hello world"}'
- "8080:80"
volumes:
- tei_embedding_cache:/data
healthcheck:
# The TEI image ships curl; hit its /health endpoint so Hindsight only
# starts once the model is loaded and serving.
test: ["CMD", "curl", "-fsS", "http://localhost:80/health"]
interval: 10s
timeout: 5s
retries: 60
start_period: 30s
tei-reranker:
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.8.3
container_name: hindsight-tei-reranker
# Prod-like tuning: reranking batches are larger than embedding batches
# (rerank inputs are query+document pairs scored in bulk during recall).
command:
[
"--model-id", "BAAI/bge-reranker-base",
"--max-concurrent-requests", "512",
"--max-batch-tokens", "32768",
"--max-client-batch-size", "128",
"--auto-truncate",
]
environment:
PORT: "80"
ports:
- "8081:80"
volumes:
- tei_reranker_cache:/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:80/health"]
interval: 10s
timeout: 5s
retries: 60
start_period: 30s
hindsight:
image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest-slim}
container_name: hindsight-tei
depends_on:
tei-embedding:
condition: service_healthy
tei-reranker:
condition: service_healthy
ports:
- "8888:8888"
- "9999:9999"
environment:
# LLM still runs through a provider — bring your own key. Pair with
# HINDSIGHT_API_LLM_PROVIDER to use a provider other than openai.
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${HINDSIGHT_API_LLM_API_KEY:-}
# Embeddings + reranking served by the TEI sidecars above. Use the
# in-cluster service DNS names, not localhost.
HINDSIGHT_API_EMBEDDINGS_PROVIDER: tei
HINDSIGHT_API_EMBEDDINGS_TEI_URL: http://tei-embedding:80
HINDSIGHT_API_RERANKER_PROVIDER: tei
HINDSIGHT_API_RERANKER_TEI_URL: http://tei-reranker:80
volumes:
- pg_data:/home/hindsight/.pg0
volumes:
pg_data:
tei_embedding_cache:
tei_reranker_cache:
+2 -7
View File
@@ -8,17 +8,12 @@ HINDSIGHT_VERSION=latest
# LLM Configuration
HINDSIGHT_API_LLM_PROVIDER=openai
OPENAI_API_KEY=your-openai-api-key-here
HINDSIGHT_API_LLM_API_KEY=your-openai-api-key-here
# Alternative LLM providers (uncomment and configure as needed):
# Alternative LLM providers (uncomment and set the key above accordingly):
# HINDSIGHT_API_LLM_PROVIDER=anthropic
# ANTHROPIC_API_KEY=your-anthropic-api-key
# HINDSIGHT_API_LLM_PROVIDER=gemini
# GEMINI_API_KEY=your-gemini-api-key
# HINDSIGHT_API_LLM_PROVIDER=groq
# GROQ_API_KEY=your-groq-api-key
# Vector and Text Search (already configured in docker-compose.yaml)
# HINDSIGHT_API_VECTOR_EXTENSION=pgvectorscale
+3 -3
View File
@@ -9,14 +9,14 @@ Both extensions are from [Timescale](https://github.com/timescale) and provide p
## Prerequisites
- Docker and Docker Compose installed
- OpenAI API key (or another LLM provider)
- An OpenAI API key (or a key for another LLM provider)
## Quick Start
```bash
# Set environment variables
export HINDSIGHT_DB_PASSWORD="your-secure-password"
export OPENAI_API_KEY="your-openai-api-key"
export HINDSIGHT_API_LLM_API_KEY="your-openai-api-key"
# Build and start
docker compose -f docker/docker-compose/timescale/docker-compose.yaml up -d --build
@@ -50,7 +50,7 @@ docker compose -f docker/docker-compose/timescale/docker-compose.yaml down -v
| `HINDSIGHT_DB_USER` | PostgreSQL username | `hindsight_user` |
| `HINDSIGHT_DB_NAME` | Database name | `hindsight_db` |
| `HINDSIGHT_VERSION` | Hindsight Docker image version | `latest` |
| `OPENAI_API_KEY` | OpenAI API key | (required) |
| `HINDSIGHT_API_LLM_API_KEY` | API key for the LLM provider | (required) |
| `HINDSIGHT_API_LLM_PROVIDER` | LLM provider | `openai` |
### Why Timescale Extensions?
@@ -8,7 +8,8 @@ name: hindsight
#
# Required environment variables:
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
# - OPENAI_API_KEY (or configure another LLM provider)
# - HINDSIGHT_API_LLM_API_KEY (pair it with HINDSIGHT_API_LLM_PROVIDER to use
# a provider other than the default openai)
#
# Optional environment variables with defaults:
# - HINDSIGHT_VERSION: Hindsight application version (default: latest)
@@ -80,7 +81,7 @@ services:
environment:
# LLM Configuration
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
HINDSIGHT_API_LLM_API_KEY: ${HINDSIGHT_API_LLM_API_KEY:-}
# Database Configuration
HINDSIGHT_API_DATABASE_URL: postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:-hindsight_password}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
@@ -70,7 +70,7 @@ services:
# LLM Configuration (uses OpenAI for testing vchord)
# LLM configuration
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
HINDSIGHT_API_LLM_API_KEY: ${HINDSIGHT_API_LLM_API_KEY:-}
# Database Configuration
HINDSIGHT_API_DATABASE_URL: postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:-hindsight_password}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.8.5
appVersion: "0.8.5"
version: 0.9.1
appVersion: "0.9.1"
keywords:
- ai
- memory
+15 -4
View File
@@ -36,16 +36,22 @@ api:
cpu: 500m
memory: 1Gi
# Liveness and readiness probes
# Liveness and readiness probes.
# Liveness uses /health/live, which performs no database access: a slow or
# unreachable database must gate traffic (readiness), never restart pods.
# Needs an image from this chart's appVersion or newer — older ones serve
# /health only, and would fail this probe with a 404.
livenessProbe:
httpGet:
path: /health
path: /health/live
port: 8888
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# Readiness checks the database, so a pod that cannot reach it is pulled out
# of the Service and put back once the database recovers.
readinessProbe:
httpGet:
path: /health
@@ -131,10 +137,15 @@ worker:
cpu: 500m
memory: 1Gi
# Liveness and readiness probes
# Liveness and readiness probes.
# Liveness uses /health/live, which performs no database access. Restarting a
# worker whose database is merely slow requeues its claimed operations with
# retry_count incremented, so DB checks must stay out of liveness.
# Needs an image from this chart's appVersion or newer — older ones serve
# /health only, and would fail this probe with a 404.
livenessProbe:
httpGet:
path: /health
path: /health/live
port: 8889
initialDelaySeconds: 30
periodSeconds: 10
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.8.5",
"version": "0.9.1",
"description": "Node.js programmatic lifecycle manager for Hindsight — embeds a local hindsight daemon in a Node application. Pair with @vectorize-io/hindsight-client for memory operations.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+5 -4
View File
@@ -1,17 +1,18 @@
[build-system]
requires = ["setuptools>=61"]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "hindsight-all-slim"
version = "0.8.5"
version = "0.9.1"
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
license = "MIT"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim==0.8.5",
"hindsight-api-slim==0.9.1",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
"hindsight-embed==0.9.1",
]
[tool.uv.sources]
+6 -5
View File
@@ -1,17 +1,18 @@
[build-system]
requires = ["hatchling"]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.8.5"
version = "0.9.1"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
license = "MIT"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim[all]==0.8.5",
"hindsight-api-slim[all]==0.9.1",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
"hindsight-embed==0.9.1",
]
[tool.uv.sources]
@@ -21,7 +22,7 @@ hindsight-embed = { workspace = true }
[project.optional-dependencies]
local-llm = [
"hindsight-api-slim[local-llm]==0.8.5",
"hindsight-api-slim[local-llm]==0.9.1",
]
test = [
"pytest>=7.0.0",
+1 -1
View File
@@ -53,4 +53,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.8.5"
__version__ = "0.9.1"
@@ -0,0 +1,23 @@
"""Text-search SQL shapes shared by index DDL and the queries that must hit it.
A PostgreSQL expression index is only selectable when the query repeats the
indexed expression verbatim, so the DDL (``migrations.py`` and the Alembic
versions) and the read arms (``engine/sql/postgresql.py``) cannot be allowed to
drift. Both sides call the helpers here — same idea as
``_pg_search.pg_search_bm25_columns``.
"""
def mental_models_text_document(alias: str | None = None) -> str:
"""The ``mental_models`` full-text document: model/page name + content.
Mirrors the generating expression of the native tsvector column created by
the ``n9i0j1k2l3m4`` (learnings / pinned_reflections) migration, so every
backend indexes and queries the exact same document. ``content`` is NOT NULL,
hence the deliberate lack of a ``COALESCE`` around it.
``alias`` qualifies the columns for queries that join the table (``mm``);
leave it unset for DDL, where the expression is already table-scoped.
"""
prefix = f"{alias}." if alias else ""
return f"(COALESCE({prefix}name, '') || ' ' || {prefix}content)"
+109 -18
View File
@@ -8,6 +8,7 @@ import asyncio
import io
import json
import logging
import struct
import zipfile
from dataclasses import dataclass
from datetime import datetime, timezone
@@ -59,6 +60,7 @@ BACKUP_TABLES = [
"observation_history",
"mental_models",
"mental_model_history",
"knowledge_pages",
"directives",
"async_operations",
"webhooks",
@@ -66,6 +68,7 @@ BACKUP_TABLES = [
"audit_log",
"llm_requests",
"graph_maintenance_queue",
"entity_maintenance_queue",
]
MANIFEST_VERSION = "2"
@@ -79,6 +82,76 @@ class BackupColumn:
type_name: str
@dataclass(frozen=True)
class TableRestorePlan:
"""How one table's backed-up binary COPY stream is replayed onto the target.
``columns`` is the target column list handed to ``copy_to_table``, in stream
order. When the target no longer has a backed-up column, its field is stripped
from every tuple (``dropped_field_indices``) before the stream is replayed —
binary COPY is positional, so the column list and the tuple fields must agree.
"""
columns: list[str]
dropped_field_indices: tuple[int, ...]
source_field_count: int
# Header of a PostgreSQL binary COPY stream: an 11-byte signature, an int32 flags
# field, and an int32 header-extension length followed by that many bytes.
_COPY_BINARY_SIGNATURE = b"PGCOPY\n\xff\r\n\x00"
_COPY_BINARY_HEADER_LEN = len(_COPY_BINARY_SIGNATURE) + 8
def _strip_binary_copy_fields(data: bytes, plan: TableRestorePlan) -> bytes:
"""Drop `plan.dropped_field_indices` from every tuple of a binary COPY stream.
Restore used to reject a backup whose columns the target no longer had — the
preflight raised "target is missing backup columns …", which made any backup
taken before a column-dropping migration unrestorable afterwards. Those columns
are now ignored instead, but they cannot simply be left out of the
``copy_to_table`` column list: binary COPY carries no column identities, so each
tuple's fields are matched to the column list purely by position and an unedited
stream would desynchronise (or, worse, land values in the wrong columns). So the
stream itself is rewritten here.
Tuple format: int16 field count, then per field an int32 length (-1 for NULL)
followed by that many bytes. An int16 of -1 is the end-of-data trailer.
"""
if not plan.dropped_field_indices:
return data
if not data.startswith(_COPY_BINARY_SIGNATURE):
raise ValueError("Backup stream is not in PostgreSQL binary COPY format")
(extension_len,) = struct.unpack_from("!i", data, len(_COPY_BINARY_SIGNATURE) + 4)
pos = _COPY_BINARY_HEADER_LEN + extension_len
out = bytearray(data[:pos])
dropped = set(plan.dropped_field_indices)
kept_count = plan.source_field_count - len(dropped)
while True:
(field_count,) = struct.unpack_from("!h", data, pos)
pos += 2
if field_count == -1: # end-of-data trailer
out += struct.pack("!h", -1)
break
if field_count != plan.source_field_count:
raise ValueError(
f"Backup stream tuple has {field_count} fields, manifest declares {plan.source_field_count}"
)
out += struct.pack("!h", kept_count)
for index in range(field_count):
(length,) = struct.unpack_from("!i", data, pos)
pos += 4
payload = b"" if length == -1 else data[pos : pos + length]
pos += max(length, 0)
if index in dropped:
continue
out += struct.pack("!i", length)
out += payload
return bytes(out)
async def _table_columns(conn: asyncpg.Connection, schema: str, table: str) -> list[BackupColumn]:
rows = await conn.fetch(
"""
@@ -98,37 +171,52 @@ async def _table_columns(conn: asyncpg.Connection, schema: str, table: str) -> l
async def _validate_restore_schema(
conn: asyncpg.Connection, manifest: dict[str, Any], schema: str
) -> dict[str, list[str]]:
) -> dict[str, TableRestorePlan]:
"""Validate every COPY stream against the target before destructive work starts.
Type equality is an exact ``format_type`` string match. This is deliberately
stricter than binary-COPY wire compatibility (e.g. ``varchar`` and ``text``
share a binary format yet compare unequal here): we would rather fail a
genuinely-restorable backup with a clear, actionable error than silently risk
a subtle binary mismatch. Restores blocked this way can be recovered by
aligning the target schema.
A column the target no longer has is **not** an error: a migration that drops a
column would otherwise make every backup taken before it permanently
unrestorable. Such columns are skipped (their fields are stripped from the
stream by ``_strip_binary_copy_fields``) and reported, so the operator sees what
was discarded instead of the restore failing outright.
Type mismatches remain fatal. Type equality is an exact ``format_type`` string
match. This is deliberately stricter than binary-COPY wire compatibility (e.g.
``varchar`` and ``text`` share a binary format yet compare unequal here): we
would rather fail a genuinely-restorable backup with a clear, actionable error
than silently risk a subtle binary mismatch. Restores blocked this way can be
recovered by aligning the target schema.
"""
restore_columns: dict[str, list[str]] = {}
plans: dict[str, TableRestorePlan] = {}
errors: list[str] = []
for table, table_manifest in manifest["tables"].items():
source_columns = [BackupColumn(**column) for column in table_manifest["columns"]]
target_by_name = {column.name: column for column in await _table_columns(conn, schema, table)}
missing = [column.name for column in source_columns if column.name not in target_by_name]
unknown = [
(index, column.name) for index, column in enumerate(source_columns) if column.name not in target_by_name
]
mismatched = [
f"{column.name} ({column.type_name} in backup, {target_by_name[column.name].type_name} in target)"
for column in source_columns
if column.name in target_by_name and target_by_name[column.name].type_name != column.type_name
]
if missing:
errors.append(f"{table}: target is missing backup columns {', '.join(missing)}")
if mismatched:
errors.append(f"{table}: incompatible column types: {', '.join(mismatched)}")
restore_columns[table] = [column.name for column in source_columns]
if unknown:
typer.echo(
f" {table}: ignoring {len(unknown)} backup column(s) absent from the target schema: "
f"{', '.join(name for _, name in unknown)}"
)
plans[table] = TableRestorePlan(
columns=[column.name for column in source_columns if column.name in target_by_name],
dropped_field_indices=tuple(index for index, _ in unknown),
source_field_count=len(source_columns),
)
if errors:
details = "; ".join(errors)
raise ValueError(f"Backup schema is incompatible with target schema '{schema}': {details}")
return restore_columns
return plans
def _effective_backup_tables() -> list[str]:
@@ -263,7 +351,7 @@ async def _restore(
# Complete the compatibility check before entering the transaction
# that truncates tables. This turns historical schema drift into an
# actionable error without risking the target's existing data.
restore_columns = await _validate_restore_schema(conn, manifest, schema)
restore_plans = await _validate_restore_schema(conn, manifest, schema)
# Use a transaction for atomic restore - either all tables are
# restored or none are, preventing partial/inconsistent state.
@@ -284,13 +372,15 @@ async def _restore(
expected_rows = manifest["tables"].get(table, {}).get("rows", "?")
typer.echo(f" [{i}/{len(backup_tables)}] Restoring {table}... {expected_rows} rows")
data = zf.read(filename)
buffer = io.BytesIO(data)
plan = restore_plans[table]
# Strips the fields of any column the target no longer has;
# a no-op when the schemas still line up.
buffer = io.BytesIO(_strip_binary_copy_fields(zf.read(filename), plan))
# asyncpg requires schema_name as separate parameter
await conn.copy_to_table(
table,
schema_name=schema,
columns=restore_columns[table],
columns=plan.columns,
source=buffer,
format="binary",
)
@@ -758,7 +848,8 @@ def import_bank_command(
f"Imported bank '{result.bank_id}': {result.documents_imported} doc(s), "
f"{result.facts_imported} fact(s), {result.observations_imported} observation(s), "
f"{result.mental_models_imported} mental model(s), "
f"{result.mental_model_history_imported} mm-history row(s), {result.directives_imported} directive(s), "
f"{result.mental_model_history_imported} mm-history row(s), "
f"{result.knowledge_pages_imported} knowledge page(s), {result.directives_imported} directive(s), "
f"{result.webhooks_imported} webhook(s), {result.history_rows_imported} history row(s)"
)
@@ -0,0 +1,67 @@
"""Drop observation_history's FK to memory_units.
The history table records one snapshot per observation change, keyed by
``(bank_id, observation_id)``. Its foreign key to ``memory_units`` existed only to
cascade-delete history when the observation row went away.
That assumes every observation *is* a ``memory_units`` row, which is true only
while Postgres is the memories store. When another store owns the memories the
observation lives there and Postgres holds no row for it, so every history insert
raises a foreign-key violation — swallowed by the writer as "a race with parallel
consolidation" and logged at warning level. The audit trail goes silently empty.
Dropping the constraint lets history be recorded wherever the observation is
stored. The cleanup the cascade used to do is now explicit, in the paths that
delete observations (``_execute_delete_action``, ``clear_observations``,
``delete_bank``). Rows orphaned by a path that misses — a document delete
cascading through ``memory_units``, for instance — are invisible to readers,
which always filter by ``(bank_id, observation_id)``, and are reclaimed when the
bank is deleted.
Oracle builds this schema through its own DDL runner and never had the
constraint, so the Oracle slot is a deliberate no-op.
Revision ID: a1c9e7f3b2d8
Revises: c7d1e9a4b3f2
"""
from collections.abc import Sequence
from alembic import op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a1c9e7f3b2d8"
down_revision: str | Sequence[str] | None = "c7d1e9a4b3f2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_CONSTRAINT = "observation_history_observation_id_fkey"
def _pg_upgrade() -> None:
op.execute(f"ALTER TABLE observation_history DROP CONSTRAINT IF EXISTS {_CONSTRAINT}")
def _pg_downgrade() -> None:
# Re-adding the FK requires every row to reference a live memory_unit, so
# clear any history whose observation is not a Postgres row first — those are
# exactly the rows this migration made possible.
op.execute(
"DELETE FROM observation_history h "
"WHERE NOT EXISTS (SELECT 1 FROM memory_units m WHERE m.id = h.observation_id)"
)
op.execute(
f"ALTER TABLE observation_history ADD CONSTRAINT {_CONSTRAINT} "
"FOREIGN KEY (observation_id) REFERENCES memory_units(id) ON DELETE CASCADE"
)
def upgrade() -> None:
# Oracle never had the constraint (its schema is built by a separate DDL
# runner), so only Postgres has anything to drop.
run_for_dialect(pg=_pg_upgrade, oracle=None)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=None)
@@ -0,0 +1,126 @@
"""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.
``managed`` lets a client tag a node as system-owned vs. hand-authored; it
carries no server-side behaviour. A partial unique index keeps page names unique
within a folder (case-insensitive; root pages compared under an empty parent).
Revision ID: a9b8c7d6e5f4
Revises: a1c9e7f3b2d8
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 = "a1c9e7f3b2d8"
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,
managed BOOLEAN NOT NULL DEFAULT false,
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)"
)
# 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")
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:
# No case-insensitive unique index on Oracle: `name` is a CLOB and cannot be
# indexed with lower(); page-name uniqueness is enforced on PG only.
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,
managed NUMBER(1) 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,213 @@
"""Add entities.entity_kind and exclude label entities from the trigram index.
Label entities (values of ``entity_labels`` config groups, stored as
``key:value`` canonical names) resolve by exact match only — fuzzy resolution
must never merge distinct label values (#1558), and since #3187 they are looked
up via the exact-match unique index rather than probed through pg_trgm. Their
rows were still covered by the shared trigram index, so every fuzzy probe for a
*regular* entity name pulled them into its candidate set only to discard them
in the bitmap recheck. On banks where a free-text label group accumulated tens
of thousands of mutually-similar values this recheck-discard overhead dominated
database CPU under ingest bursts (#3208).
"Is this row a label" was previously derived at runtime from the bank's
``entity_labels`` config, which an index predicate cannot reference — so the
classification is now materialised on the row:
1. Add ``entity_kind`` ("regular"/"label", CHECK-constrained) on both dialects.
A kind column rather than a boolean so future entity kinds don't need
another column.
2. Backfill per bank by classifying ``canonical_name`` against the bank's
``entity_labels`` config with the same ``is_label_entity()`` the resolver
uses at insert time — a SQL reimplementation would be a second source of
truth (and the map-group recursion doesn't translate). Banks hold at most
tens of thousands of entities, so the synchronous per-bank backfill is fine.
Label configs supplied only by a tenant extension (not stored in
``banks.config``) can't be seen here; their rows stay "regular", which
costs index size but never correctness — label *texts* still resolve via
the exact-match unique index.
3. Rebuild the PG trigram index as a partial index excluding label rows.
Built CONCURRENTLY (autocommit block, invalid-leftover sweep, IF NOT
EXISTS — same shape as 2071c7518f88) and only then drop the old full
index, so fuzzy probes never lose index coverage. Skipped entirely when
pg_trgm is absent (the resolver falls back to the "full" strategy, #626).
Oracle has no trigram index — it fuzzy-matches with a UTL_MATCH scan — so it
only gets the column + backfill; the resolver adds the matching
``entity_kind != 'label'`` filter to that scan.
Revision ID: b3e8d1c6f4a9
Revises: f2a6d8c4b1e9
Create Date: 2026-08-06
"""
import json
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "b3e8d1c6f4a9"
down_revision: str | Sequence[str] | None = "f2a6d8c4b1e9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_OLD_INDEX = "entities_canonical_name_lower_trgm_idx"
_NEW_INDEX = "entities_canonical_name_lower_trgm_nonlabel_idx"
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 _backfill_entity_kind(schema: str) -> None:
"""Set entity_kind='label' on rows matching their bank's entity_labels config.
Runs the resolver's own classification (``is_label_entity``) per bank in
Python rather than reimplementing the enum/text/map prefix rules in SQL.
Shared by both dialects: plain SELECT/UPDATE with expanding IN binds.
"""
from hindsight_api.engine.retain.entity_labels import (
build_labels_lookup,
is_label_entity,
parse_entity_labels,
)
bind = op.get_bind()
banks = bind.execute(sa.text(f"SELECT bank_id, config FROM {schema}banks")).fetchall()
for bank_id, raw_config in banks:
# PG JSONB arrives as a dict; Oracle CLOB arrives as a LOB object on
# raw text() fetches (oracledb's fetch_lobs default) — read it into a
# JSON string first.
if raw_config is not None and not isinstance(raw_config, (str, dict)):
raw_config = raw_config.read()
config = json.loads(raw_config) if isinstance(raw_config, str) else (raw_config or {})
labels_cfg = parse_entity_labels(config.get("entity_labels"))
if labels_cfg is None:
continue
lookup = build_labels_lookup(labels_cfg)
rows = bind.execute(
sa.text(f"SELECT id, canonical_name FROM {schema}entities WHERE bank_id = :bank_id"),
{"bank_id": bank_id},
).fetchall()
label_ids = [entity_id for entity_id, name in rows if is_label_entity(name, labels_cfg, lookup)]
# Chunked to stay under Oracle's 1000-element IN limit; also keeps PG
# bind arrays bounded.
for start in range(0, len(label_ids), 500):
chunk = label_ids[start : start + 500]
stmt = sa.text(f"UPDATE {schema}entities SET entity_kind = 'label' WHERE id IN :ids").bindparams(
sa.bindparam("ids", expanding=True)
)
bind.execute(stmt, {"ids": chunk})
def _pg_upgrade() -> None:
bind = op.get_bind()
schema = _pg_schema_prefix()
# `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
# IF NOT EXISTS: the transactional part below commits when the autocommit
# block is entered, so a failure during the CONCURRENTLY build leaves the
# revision unstamped with the column already added — the retry must not
# trip over it. The constant default is a metadata-only change on PG 11+.
op.execute(
f"ALTER TABLE {schema}entities ADD COLUMN IF NOT EXISTS entity_kind TEXT DEFAULT 'regular' NOT NULL "
f"CONSTRAINT chk_entities_entity_kind CHECK (entity_kind IN ('regular', 'label'))"
)
_backfill_entity_kind(schema)
# Without pg_trgm neither the old index nor the extension's opclass exists;
# the resolver already runs the "full" strategy there (#626).
has_trgm = bind.execute(sa.text("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')")).scalar()
if not has_trgm:
return
# CREATE INDEX CONCURRENTLY cannot run inside a transaction block; the
# autocommit_block runs each statement outside Alembic's migration
# transaction. Build the partial index first and drop the old full index
# only afterwards, so fuzzy probes never lose index coverage.
with op.get_context().autocommit_block():
# A CONCURRENTLY build that errored on a previous run leaves an INVALID
# index of this name behind, which IF NOT EXISTS would skip forever.
leftover_invalid = bind.execute(
sa.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": _NEW_INDEX, "target_schema": target_schema},
).scalar()
if leftover_invalid:
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}{_NEW_INDEX}")
# The predicate must textually match the resolver's candidate query
# (`entity_kind != 'label'`) for the planner to choose this index.
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {_NEW_INDEX} "
f"ON {schema}entities USING GIN (LOWER(canonical_name) gin_trgm_ops) "
f"WHERE entity_kind != 'label'"
)
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}{_OLD_INDEX}")
def _pg_downgrade() -> None:
bind = op.get_bind()
schema = _pg_schema_prefix()
has_trgm = bind.execute(sa.text("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')")).scalar()
if has_trgm:
# Restore the full index before dropping the partial one so fuzzy
# probes keep index coverage throughout.
with op.get_context().autocommit_block():
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {_OLD_INDEX} "
f"ON {schema}entities USING GIN (LOWER(canonical_name) gin_trgm_ops)"
)
# Dropping the column also drops the partial index and CHECK constraint.
op.execute(f"ALTER TABLE {schema}entities DROP COLUMN IF EXISTS entity_kind")
def _oracle_upgrade() -> None:
# Swallow ORA-01430 (column already exists) so a retry after a mid-run
# failure is idempotent — Oracle DDL auto-commits statement by statement.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE entities ADD (entity_kind VARCHAR2(16) DEFAULT ''regular'' NOT NULL
CONSTRAINT chk_entities_entity_kind CHECK (entity_kind IN (''regular'', ''label'')))';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -1430 THEN RAISE; END IF;
END;
"""
)
_backfill_entity_kind("")
def _oracle_downgrade() -> None:
# Swallow ORA-00904 (column does not exist).
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE entities DROP COLUMN entity_kind';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -904 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,118 @@
"""Add entity_maintenance_queue table (+ seed it with every existing entity)
Queue of entities whose unit references may have gone away — the input to the
graph_maintenance job's orphan-entity and stale-cooccurrence prunes.
Those two prunes used to be bank-wide single statements re-evaluated on every
run: the orphan prune probed once per entity in the bank, and the cooccurrence
prune evaluated an INTERSECT per cooccurrence row in the bank, whether or not
anything had changed. Their cost tracked the size of the bank rather than the
size of the delete, so past a few million rows they blew asyncpg's command
timeout on every run and the job could never complete (#3222).
With a queue the prunes only examine entities a delete actually touched, the
same way ``graph_maintenance_queue`` already scopes the relink pass.
Deliberately NOT seeded with the existing entities. Backfilling them would
reclaim whatever a bank accumulated while its sweep was failing, but it writes
one row per entity inside a migration that runs at API startup, and then charges
a prune check for every one of them — a slow upgrade plus a large self-inflicted
backlog, to collect rows that cost a bank nothing. The queue starts empty and
fills from real deletes; historical strays stay until something touches them.
Revision ID: c4f7a91b2d38
Revises: d9c1a7b4e2f6
Create Date: 2026-08-11
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c4f7a91b2d38"
down_revision: str | Sequence[str] | None = "d9c1a7b4e2f6"
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()
# Composite PK gives ON CONFLICT DO NOTHING dedup when the same entity is
# enqueued from overlapping deletes. No FK to entities: the prune's whole
# job is to delete the entity, and a cascade would race it away mid-drain.
# A queue row naming an entity that no longer exists is a no-op.
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}entity_maintenance_queue (
bank_id TEXT NOT NULL,
entity_id UUID NOT NULL,
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (bank_id, entity_id)
)
"""
)
op.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_entity_maintenance_queue_bank_enqueued
ON {schema}entity_maintenance_queue (bank_id, enqueued_at)
"""
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_entity_maintenance_queue_bank_enqueued")
op.execute(f"DROP TABLE IF EXISTS {schema}entity_maintenance_queue")
def _oracle_execute_ignoring_955(sql: str) -> None:
"""Run a CREATE statement and swallow ORA-00955 (object already exists).
Mirrors the helper in the graph_maintenance_queue migration so reruns stay
safe on a database where the table was created by an earlier partial run.
"""
block = (
"BEGIN "
"EXECUTE IMMEDIATE :stmt; "
"EXCEPTION WHEN OTHERS THEN "
"IF SQLCODE = -955 THEN NULL; ELSE RAISE; END IF; "
"END;"
)
op.get_bind().exec_driver_sql(block, {"stmt": sql.strip()})
def _oracle_upgrade() -> None:
_oracle_execute_ignoring_955(
"""
CREATE TABLE entity_maintenance_queue (
bank_id VARCHAR2(256) NOT NULL,
entity_id RAW(16) NOT NULL,
enqueued_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_entity_maintenance_queue PRIMARY KEY (bank_id, entity_id)
)
"""
)
_oracle_execute_ignoring_955(
"CREATE INDEX idx_entity_maintenance_queue_bank_enqueued ON entity_maintenance_queue (bank_id, enqueued_at)"
)
def _oracle_downgrade() -> None:
op.execute("DROP INDEX idx_entity_maintenance_queue_bank_enqueued")
op.execute("DROP TABLE entity_maintenance_queue")
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,300 @@
"""Make the cross-schema maintenance routines skip a schema under concurrent DDL.
``banks_needing_consolidation()``, ``mental_models_with_cron()``,
``schemas_with_expired_rows(...)`` and ``schemas_with_expired_operations(...)``
snapshot the schemas owning a target table from ``pg_class`` and then query each
schema in turn, inside one transaction. Every such query takes AccessShareLock on
two or three relations, and those locks are held until the caller commits.
``c7e9f1a3b5d2`` already handles the schema *vanishing* mid-scan. The same race
has a second outcome: the schema is not gone, it is being rewritten, and its DDL
holds — or is queued for — AccessExclusiveLock. A queued AccessExclusiveLock
blocks later AccessShareLock requests, so::
routine holds AccessShare(memory_units) -> wants AccessShare(banks)
dropper queued AccessExclusive(banks) -> wants AccessExclusive(memory_units)
is a cycle, and PostgreSQL breaks it by killing one side. When it picks the
routine the whole scan aborts, so one tenant being dropped takes out an entire
maintenance pass. Observed as a recurring ``DeadlockDetectedError`` in the test
suite, where xdist workers create and drop schemas continuously while
``test_maintenance_routines`` calls the routines against the same database; in
production the background maintenance loop races tenant deletion and migration
the same way.
Fix the routine's side of the cycle: give each per-schema query a short
``lock_timeout`` so it abandons the wait long before the deadlock detector runs,
and skip that schema. A schema mid-DDL has nothing useful to report anyway, and
the maintenance loop runs on a ticker, so it is picked up on the next pass. Locks
already held from earlier schemas stay until the caller commits — that is fine,
the point is only that this routine stops *waiting* on the other party.
``lock_timeout`` is set via ``set_config(..., is_local => true)`` rather than
``SET LOCAL``: PL/pgSQL rejects the ``SET`` command inside a non-volatile
function, and these are all ``STABLE``. The previous value is restored before
returning so the caller's transaction is left as it was found. Only conflicting
DDL can trigger it — AccessShareLock does not conflict with ordinary DML — so
this never fires on a merely busy table.
Downgrade is a no-op: the bodies here are the ones from ``b6d2f8a4c1e7`` /
``d7b2f8a1c934`` plus strictly-additive resilience, with identical signatures and
results, so leaving them in place is harmless. Downgrading past those migrations
restores or drops them as they define.
Revision ID: c8b4e2a71f95
Revises: e7c3a91f4b62
Create Date: 2026-08-17
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
from hindsight_api.config import get_config
revision: str = "c8b4e2a71f95"
down_revision: str | Sequence[str] | None = "e7c3a91f4b62"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# Short enough to abandon the wait before PostgreSQL's deadlock detector runs
# (deadlock_timeout defaults to 1s), long enough to ride out a brief DDL
# statement rather than skipping a healthy schema.
_LOCK_TIMEOUT = "250ms"
# Both outcomes of the same race, kept as separate arms so each reason is legible
# at the point it is handled.
_SKIP_ARMS = """
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;
-- Schema is mid-DDL and holds (or has queued) an
-- AccessExclusiveLock. Skip it rather than wait: waiting is
-- what closes the deadlock cycle. deadlock_detected is
-- belt-and-braces for a cycle formed before lock_timeout.
WHEN lock_not_available OR deadlock_detected THEN
CONTINUE;
"""
def _configured_schema() -> str:
"""The one schema this deployment's routines live in and are called from."""
return get_config().database_schema or "public"
def _target_schema() -> str | None:
return context.config.get_main_option("target_schema")
def _is_install_run() -> bool:
"""True for the single run that owns the routines (mirrors b6d2f8a4c1e7)."""
target = _target_schema()
return not target or target == _configured_schema()
def _prefix(schema: str | None) -> str:
"""Qualifier for ``schema``, or ``""`` to fall back to ``search_path``."""
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
# Tenant schemas carry no copy of these routines; only the configured
# schema's copy is ever called. Non-install runs have nothing to replace —
# and unlike b6d2f8a4c1e7 there are no stray per-tenant copies to clean up,
# that migration already did it.
if not _is_install_run():
return
schema = _prefix(_target_schema())
op.execute(
f"""
CREATE OR REPLACE FUNCTION {schema}banks_needing_consolidation()
RETURNS TABLE(schema_name text, bank_id text)
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
prev_lock_timeout text;
BEGIN
prev_lock_timeout := current_setting('lock_timeout');
PERFORM set_config('lock_timeout', '{_LOCK_TIMEOUT}', true);
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);
{_SKIP_ARMS} END;
END LOOP;
PERFORM set_config('lock_timeout', prev_lock_timeout, true);
END;
$fn$;
"""
)
op.execute(
f"""
CREATE OR REPLACE FUNCTION {schema}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;
prev_lock_timeout text;
BEGIN
IF p_days IS NULL OR p_days <= 0 THEN
RETURN;
END IF;
prev_lock_timeout := current_setting('lock_timeout');
PERFORM set_config('lock_timeout', '{_LOCK_TIMEOUT}', true);
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;
{_SKIP_ARMS} END;
IF has_expired THEN
RETURN NEXT sch;
END IF;
END LOOP;
PERFORM set_config('lock_timeout', prev_lock_timeout, true);
END;
$fn$;
"""
)
op.execute(
f"""
CREATE OR REPLACE FUNCTION {schema}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;
prev_lock_timeout text;
BEGIN
prev_lock_timeout := current_setting('lock_timeout');
PERFORM set_config('lock_timeout', '{_LOCK_TIMEOUT}', true);
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);
{_SKIP_ARMS} END;
END LOOP;
PERFORM set_config('lock_timeout', prev_lock_timeout, true);
END;
$fn$;
"""
)
op.execute(
f"""
CREATE OR REPLACE FUNCTION {schema}schemas_with_expired_operations(p_days int)
RETURNS SETOF text
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
has_expired boolean;
prev_lock_timeout text;
BEGIN
-- Zero (or negative) retention means "keep forever": report nothing
-- so the caller skips the sweep entirely.
IF p_days IS NULL OR p_days <= 0 THEN
RETURN;
END IF;
prev_lock_timeout := current_setting('lock_timeout');
PERFORM set_config('lock_timeout', '{_LOCK_TIMEOUT}', true);
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'async_operations' AND c.relkind = 'r'
LOOP
BEGIN
-- Matches the worker's prune predicate: only terminal rows
-- are eligible, so a schema holding nothing but pending or
-- processing work is correctly reported as having nothing
-- to prune. Uses idx_async_operations_terminal_cleanup.
EXECUTE format(
'SELECT EXISTS ('
' SELECT 1 FROM %I.async_operations'
' WHERE status IN (''completed'', ''failed'', ''cancelled'')'
' AND updated_at < NOW() - make_interval(days => $1)'
')',
sch
) INTO has_expired USING p_days;
{_SKIP_ARMS} END;
IF has_expired THEN
RETURN NEXT sch;
END IF;
END LOOP;
PERFORM set_config('lock_timeout', prev_lock_timeout, true);
END;
$fn$;
"""
)
def _pg_downgrade() -> None:
# No-op by design — see the module docstring. The bodies installed here are
# the previous ones plus a skip arm; dropping the routines would strand the
# migrations that claim to own them, and re-installing the old bodies would
# duplicate their definitions here.
return
def upgrade() -> None:
# Oracle slot intentionally absent: these routines are PostgreSQL-only, and
# the Oracle worker keeps its per-schema sweep.
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,95 @@
"""Add async_operations.serialization_key for per-document retain serialization.
``update_mode="append"`` is a read-modify-write over the whole document: the
retain reads ``documents.original_text``, concatenates the new content onto it,
and reprocesses the result. Two appends to one document whose read→write
windows overlap therefore lose an update — the loser's turn is content nobody
else has.
The orchestrator now detects that at write time and fails the loser instead of
committing over it, but detection alone turns lost data into wasted extraction.
This column lets the worker's claim query keep a document to one in-flight
retain at a time, so the conflict is avoided rather than paid for: a second
retain for the same document simply is not claimed until the first finishes,
and the waiting operation holds no worker slot while it waits.
It carries the single document an operation targets (NULL when it targets none
or several), so the claim predicate can compare it without digging into
``task_payload`` — a shape both dialects index cheaply and which the Oracle
rewrite of the claim SQL can handle.
The partial index covers only live rows: claims never look at terminal
operations, and retain queues are dominated by completed history.
Revision ID: d9c1a7b4e2f6
Revises: b3e8d1c6f4a9
Create Date: 2026-08-11
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "d9c1a7b4e2f6"
down_revision: str | Sequence[str] | None = "b3e8d1c6f4a9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_INDEX = "idx_async_operations_serialization_key"
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 = context.config.get_main_option("target_schema")
op.add_column(
"async_operations",
sa.Column("serialization_key", sa.Text(), nullable=True),
schema=schema or None,
)
prefix = _pg_schema_prefix()
op.execute(
f"CREATE INDEX IF NOT EXISTS {_INDEX} ON {prefix}async_operations "
f"(bank_id, serialization_key) "
f"WHERE serialization_key IS NOT NULL AND status IN ('pending', 'processing')"
)
def _pg_downgrade() -> None:
schema = context.config.get_main_option("target_schema")
prefix = _pg_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {prefix}{_INDEX}")
op.drop_column("async_operations", "serialization_key", schema=schema or None)
def _oracle_upgrade() -> None:
op.add_column("async_operations", sa.Column("serialization_key", sa.String(4000), nullable=True))
# Oracle has no partial indexes. A function-based index on the same
# predicate gets the equivalent selectivity: terminal rows collapse to NULL
# and Oracle does not store all-NULL entries, so the index only holds the
# live rows the claim query looks at.
op.get_bind().exec_driver_sql(
f"CREATE INDEX {_INDEX} ON async_operations ("
f" CASE WHEN status IN ('pending', 'processing') THEN bank_id END,"
f" CASE WHEN status IN ('pending', 'processing') THEN serialization_key END)"
)
def _oracle_downgrade() -> None:
op.get_bind().exec_driver_sql(f"DROP INDEX {_INDEX}")
op.drop_column("async_operations", "serialization_key")
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,114 @@
"""Drop the never-written `access_count` column from memory_units (and its archive).
``memory_units.access_count`` has been dead since the initial schema
(5a366d414dce): no code path anywhere in the repo ever writes it, and — despite
the ``access_count DESC`` index created alongside it — no query ever reads or
orders by it either. It is 0 on every row of every install. The lone remaining
mentions were an index, a stale comment naming an ``access_count_update`` task
type that was never implemented, and the column's name in the Oracle backend's
numeric-RETURNING list; all three go away with this change.
The column is dropped from the curation archive too. ``invalidated_memory_units``
was cloned ``LIKE memory_units`` (c9a1b2d3e4f5), so it inherited the column, and
curation's INSERT…SELECT round-trip builds its column list from the catalog
(``writes.py::_memory_unit_columns``) — the two tables must stay in lockstep or
the round-trip breaks on a column-count mismatch.
Dropping the column implicitly drops its index on both dialects
(``idx_memory_units_access_count`` on PG, ``idx_mu_access_count`` on Oracle), so
PostgreSQL also stops maintaining a btree that nothing ever probed.
Cost: on PostgreSQL ``DROP COLUMN`` is metadata-only (the attribute is marked
dropped, no table rewrite). On Oracle it does delete the column data row by row,
so on a large ``memory_units`` this migration is not free — it is still bounded
work on a single small integer column, and Oracle installs of that size can run
it during a maintenance window ahead of the upgrade if they prefer.
Revision ID: e4a7c1b9d2f6
Revises: a9b8c7d6e5f4
Create Date: 2026-08-03
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "e4a7c1b9d2f6"
down_revision: str | Sequence[str] | None = "a9b8c7d6e5f4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_TABLES = ("memory_units", "invalidated_memory_units")
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()
for table in _TABLES:
# Drops idx_memory_units_access_count along with the column.
op.execute(f"ALTER TABLE {schema}{table} DROP COLUMN IF EXISTS access_count")
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
for table in _TABLES:
op.execute(f"ALTER TABLE {schema}{table} ADD COLUMN IF NOT EXISTS access_count integer NOT NULL DEFAULT 0")
# The archive was cloned without indexes; only the live table carried one.
op.execute(f"CREATE INDEX IF NOT EXISTS idx_memory_units_access_count ON {schema}memory_units (access_count DESC)")
def _oracle_upgrade() -> None:
# Oracle has no `DROP COLUMN IF EXISTS`; swallow ORA-00904 (column does not
# exist) so the migration is idempotent and safe on a schema that already
# lacks the column. Dropping the column also drops idx_mu_access_count.
for table in _TABLES:
op.execute(
f"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE {table} DROP COLUMN access_count';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -904 THEN RAISE; END IF;
END;
"""
)
def _oracle_downgrade() -> None:
# Swallow ORA-01430 (column already exists) for idempotency. Matches the
# Oracle baseline's declaration: NUMBER(10) DEFAULT 0 NOT NULL.
for table in _TABLES:
op.execute(
f"""
BEGIN
EXECUTE IMMEDIATE
'ALTER TABLE {table} ADD (access_count NUMBER(10) DEFAULT 0 NOT NULL)';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -1430 THEN RAISE; END IF;
END;
"""
)
# ORA-00955: index name already in use.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'CREATE INDEX idx_mu_access_count ON memory_units(access_count DESC)';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -955 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,99 @@
"""Add last_memory_seen_at to mental_models, splitting it from last_refreshed_at.
``last_refreshed_at`` carried two meanings at once: the wall-clock time of the
last refresh, and the source-data watermark (the newest in-scope memory the
refresh saw) that staleness keys off. A refresh persisted the watermark into it,
and the watermark is clamped so it never regresses — so on a model whose scope
gained no new memories the refresh wrote back the value already there. The
document was rewritten, the timestamp never moved, and a client asking
"have I already refreshed this?" refreshed it again on every tick.
``last_memory_seen_at`` takes over the watermark meaning; ``last_refreshed_at``
goes back to being what its name says. The new column is backfilled from
``last_refreshed_at`` — which today holds the watermark — so staleness decides
exactly as it did before the migration and no bank mass-refreshes on deploy.
Nullable, so consumers COALESCE back to ``last_refreshed_at`` for any row a
refresh has not stamped yet.
Revision ID: e7c3a91f4b62
Revises: c4f7a91b2d38
Create Date: 2026-08-17
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "e7c3a91f4b62"
down_revision: str | Sequence[str] | None = "c4f7a91b2d38"
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}mental_models
ADD COLUMN IF NOT EXISTS last_memory_seen_at TIMESTAMP WITH TIME ZONE
"""
)
# last_refreshed_at currently holds the watermark, so copying it carries each
# model's staleness decision across the cutover unchanged. Only stamp rows
# still NULL, so re-running the migration is a no-op rather than a rollback of
# watermarks that refreshes have since advanced.
op.execute(
f"""
UPDATE {schema}mental_models
SET last_memory_seen_at = last_refreshed_at
WHERE last_memory_seen_at IS NULL
"""
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS last_memory_seen_at")
def _oracle_upgrade() -> None:
# Oracle has no ADD COLUMN IF NOT EXISTS; guard on the data dictionary so a
# re-run doesn't fail with ORA-01430 (column already exists).
op.get_bind().exec_driver_sql(
"""
DECLARE
n NUMBER;
BEGIN
SELECT COUNT(*) INTO n FROM user_tab_columns
WHERE table_name = 'MENTAL_MODELS'
AND column_name = 'LAST_MEMORY_SEEN_AT';
IF n = 0 THEN
EXECUTE IMMEDIATE
'ALTER TABLE mental_models ADD (last_memory_seen_at TIMESTAMP WITH TIME ZONE)';
END IF;
END;
"""
)
op.get_bind().exec_driver_sql(
"UPDATE mental_models SET last_memory_seen_at = last_refreshed_at WHERE last_memory_seen_at IS NULL"
)
def _oracle_downgrade() -> None:
op.get_bind().exec_driver_sql("ALTER TABLE mental_models DROP COLUMN last_memory_seen_at")
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,85 @@
"""Repair: drop the stale global memory_units vector index on per-bank backends.
Revision ID: f2a6d8c4b1e9
Revises: e4a7c1b9d2f6
Create Date: 2026-08-06
Migration d5e6f7a8b9c0 dropped the global ``idx_memory_units_embedding`` for
per-bank backends (every vector search is bank + fact_type scoped and served
by the ``idx_mu_emb_*`` partial indexes; the global index is never chosen by
the planner). However, older versions of the post-migration reconcile
(``ensure_vector_extension``) recreated the index when they found none, so
schemas that were provisioned or reconciled in that window carry it to this
day — paying a second vector graph insertion on every ``memory_units`` write
for an index no query uses.
This repair drops the leftover index. It is intentionally a migration, not
runtime reconcile behavior: ``DROP INDEX`` takes an ACCESS EXCLUSIVE lock on
``memory_units``, which belongs in the versioned, once-per-schema migration
path — not in code that runs at unpredictable times during startup or tenant
provisioning. The reconcile now leaves memory_units vector-index DDL to
migrations entirely on per-bank backends.
ScaNN deployments keep the global index by design (filtered vector search over
a global index; per-bank partial indexes cannot be built safely there), so the
migration is a no-op for them.
"""
import os
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "f2a6d8c4b1e9"
down_revision: str | Sequence[str] | None = "e4a7c1b9d2f6"
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 _configured_vector_extension() -> str:
ext = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
if ext not in {"pgvector", "pgvectorscale", "vchord", "scann"}:
raise ValueError(
f"Invalid HINDSIGHT_API_VECTOR_EXTENSION: {ext}. Must be 'pgvector', 'vchord', 'pgvectorscale', or 'scann'"
)
return ext
def _pg_upgrade() -> None:
# ScaNN uses a global vector index by design — nothing stale to repair.
if _configured_vector_extension() == "scann":
return
schema = _pg_schema_prefix()
# DROP INDEX needs ACCESS EXCLUSIVE on memory_units. While it waits for
# in-flight transactions, every new query on the table queues behind it,
# so on a write-busy schema an unbounded wait can pile up traffic. Fail
# fast instead: the migration errors, the schema stays below head, and
# the next migration pass retries — preferable to freezing the table.
# SET LOCAL scopes the timeout to this migration's transaction.
op.execute("SET LOCAL lock_timeout = '10s'")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_embedding")
def _pg_downgrade() -> None:
# Intentional no-op: recreating a potentially multi-GB vector index that no
# query uses is not a safe downgrade action. Downgrading past d5e6f7a8b9c0
# restores the global index for deployments that genuinely need it.
pass
def upgrade() -> None:
# PG-only repair: the stale index is a PostgreSQL artifact of the old
# reconcile; Oracle deployments never had a reconcile that created it.
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,72 @@
"""Add a partial index for the cron-scheduled mental model discovery sweep.
``mental_models_with_cron()`` (``f4d1c2b3a5e6``, currently installed by
``c8b4e2a71f95``) is a cross-tenant discovery routine: it loops over every schema
holding a ``mental_models`` table and, for each, selects the models carrying a
non-empty ``trigger->>'refresh_cron'``. No index covers that predicate, so each
per-schema probe is a **sequential scan** of that tenant's ``mental_models``
table — paid on every maintenance tick, in every API/worker process, whether or
not the tenant has a single cron-scheduled model.
Cron-scheduled models are rare by construction (the trigger defaults to
``{"refresh_after_consolidation": false}``), so at thousands of tenants the sweep
spends essentially all of its time proving that tenants have nothing to do. A
partial index whose predicate matches the routine's WHERE clause exactly turns a
tenant with no cron-scheduled models into an empty index scan.
``bank_id`` is the indexed column so the routine's projection stays on the
leading column of the index; the predicate is what does the work here.
PostgreSQL only: the maintenance loop and its discovery routines are PG-only
(the Oracle slot is intentionally absent, mirroring ``f4d1c2b3a5e6``), so an
Oracle deployment never runs the scan this index exists to avoid.
Revision ID: f2a7c9d4b168
Revises: c8b4e2a71f95
Create Date: 2026-08-17
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "f2a7c9d4b168"
down_revision: str | Sequence[str] | None = "c8b4e2a71f95"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_INDEX = "idx_mental_models_cron"
def _pg_schema_prefix() -> str:
"""Schema-qualifier for PostgreSQL multi-tenant migration runs."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# Plain (non-CONCURRENT) build: mental_models holds one row per mental model,
# so this is a sub-second SHARE lock even on large installations — unlike the
# async_operations indexes in a8c1e4f7b0d3, which needed CONCURRENTLY.
# The predicate is character-for-character the routine's WHERE clause, which
# is what lets the planner match the partial index.
op.execute(
f"CREATE INDEX IF NOT EXISTS {_INDEX} ON {schema}mental_models (bank_id) "
"WHERE COALESCE(\"trigger\"->>'refresh_cron', '') <> ''"
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}{_INDEX}")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
File diff suppressed because it is too large Load Diff
+35 -1
View File
@@ -9,6 +9,7 @@ from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api import __version__ as HINDSIGHT_VERSION
from hindsight_api.api.passthrough_headers import collect_passthrough_headers
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
@@ -52,6 +53,11 @@ _current_api_key_id: ContextVar[str | None] = ContextVar("current_api_key_id", d
# Context variable for MCP pre-authentication flag (set when MCP_AUTH_TOKEN validates)
_current_mcp_authenticated: ContextVar[bool] = ContextVar("current_mcp_authenticated", default=False)
# Context variable for the headers an operator opted into forwarding to extensions
# (HINDSIGHT_API_EXTENSION_PASSTHROUGH_HEADERS). Defaults to None rather than {}
# so no single dict is shared as a default across requests.
_current_extra_headers: ContextVar[dict[str, str] | None] = ContextVar("current_extra_headers", default=None)
def get_current_bank_id() -> str | None:
"""Get the current bank_id from context."""
@@ -78,6 +84,15 @@ def get_current_mcp_authenticated() -> bool:
return _current_mcp_authenticated.get()
def get_current_extra_headers() -> dict[str, str]:
"""Get the allowlisted passthrough headers for the current request.
Returns a copy: every RequestContext built during the request owns its dict,
so extension code mutating one cannot alter what the next tool call sees.
"""
return dict(_current_extra_headers.get() or {})
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):
@@ -159,6 +174,7 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
tenant_id_resolver=get_current_tenant_id, # Propagate tenant_id for usage metering
api_key_id_resolver=get_current_api_key_id, # Propagate api_key_id for usage metering
mcp_authenticated_resolver=get_current_mcp_authenticated, # Propagate MCP pre-auth flag
extra_headers_resolver=get_current_extra_headers, # Propagate allowlisted headers to extensions
include_bank_id_param=multi_bank,
tools=base_tools,
retain_description=retain_description,
@@ -378,6 +394,16 @@ class MCPMiddleware:
return header_value.decode()
return None
def _get_extra_headers(self, scope: dict) -> dict[str, str]:
"""Collect the headers an operator opted into forwarding to extensions.
Shares ``collect_passthrough_headers`` with the HTTP transport, so both
agree on decoding and on what a duplicated header means. Empty unless
HINDSIGHT_API_EXTENSION_PASSTHROUGH_HEADERS names a header the request
actually carries.
"""
return collect_passthrough_headers(scope.get("headers", []), _get_raw_config().extension_passthrough_headers)
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
@@ -412,6 +438,11 @@ class MCPMiddleware:
# Support both "Bearer <token>" and direct token
auth_token = auth_header[7:].strip() if auth_header.startswith("Bearer ") else auth_header.strip()
# Resolved before authentication so authenticate_mcp() can read a
# passthrough header, not just the bearer token. Named for the request
# side: _send_error()'s `extra_headers` below is *response* headers.
passthrough_headers = self._get_extra_headers(scope)
# Authenticate: check legacy MCP_AUTH_TOKEN first, then TenantExtension
tenant_context = None
auth_tenant_id: str | None = None
@@ -431,7 +462,7 @@ class MCPMiddleware:
else:
# Use TenantExtension.authenticate_mcp() for auth
try:
auth_context = RequestContext(api_key=auth_token)
auth_context = RequestContext(api_key=auth_token, extra_headers=dict(passthrough_headers))
tenant_context = await self.tenant_extension.authenticate_mcp(auth_context)
# Capture tenant_id and api_key_id set by authenticate() for usage metering
auth_tenant_id = auth_context.tenant_id
@@ -483,6 +514,8 @@ class MCPMiddleware:
api_key_id_token = _current_api_key_id.set(auth_api_key_id) if auth_api_key_id else None
# Store MCP pre-authentication flag to skip tenant re-validation
mcp_auth_token = _current_mcp_authenticated.set(mcp_pre_authenticated)
# Store the allowlisted passthrough headers so per-tool RequestContexts carry them
extra_headers_token = _current_extra_headers.set(passthrough_headers)
try:
new_scope = scope.copy()
new_scope["path"] = new_path
@@ -528,6 +561,7 @@ class MCPMiddleware:
if api_key_id_token is not None:
_current_api_key_id.reset(api_key_id_token)
_current_mcp_authenticated.reset(mcp_auth_token)
_current_extra_headers.reset(extra_headers_token)
if schema_token is not None:
_current_schema.reset(schema_token)
@@ -0,0 +1,171 @@
"""Markdown rendering for knowledge pages.
Knowledge pages render as *read-only* markdown documents over the existing mental
models: each mental model becomes a markdown body with a YAML frontmatter block
(``type`` required; ``title``/``description``/``tags``/``timestamp`` optional).
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 rendering unit-testable without a DB or
LLM and lets the HTTP layer stay a thin wrapper.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
# Every page carries exactly one ``type`` frontmatter field. 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 ``type`` through a tag of the form ``type:runbook``.
# This keeps rendering schema-free (no new mental_models column): the type is
# lifted from the existing tags array.
TYPE_TAG_PREFIX = "type:"
INDEX_FILENAME = "index.md"
@dataclass(frozen=True)
class PageType:
"""A page's ``type`` and the tags that remain after the type tag is split off."""
type: str
display_tags: list[str]
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 a ``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 leak into the page's displayed tags.
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 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 markdown 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:
"""Bundle filename for a page id."""
return f"{page_id}.md"
def log_filename(page_id: str) -> str:
"""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 markdown 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"
@@ -0,0 +1,56 @@
"""Collection of the request headers an operator forwards to extensions.
Shared by both transports (the HTTP dependency and the MCP ASGI middleware) so
they cannot disagree about which value an extension sees. Both hand over raw
ASGI header pairs — Starlette exposes them as ``request.headers.raw``, the MCP
middleware reads them straight off the ASGI scope — so one implementation covers
decoding, case-folding and duplicate handling for both.
"""
import logging
from collections.abc import Iterable, Sequence
logger = logging.getLogger(__name__)
def collect_passthrough_headers(
raw_headers: Iterable[tuple[bytes, bytes]],
allowlist: Sequence[str],
) -> dict[str, str]:
"""Pick the allowlisted headers out of a request, keyed by lower-cased name.
``allowlist`` is ``HindsightConfig.extension_passthrough_headers``, already
lower-cased at config load; empty (the default) means nothing is forwarded.
A header sent more than once is dropped rather than resolved. These headers
carry identity for the deployments that enable this, and there is no safe
universal rule for picking between copies: a proxy may append its trusted
value after a client-supplied one or before it. Dropping turns a duplicate
into a loud failure in the extension (which sees no header) instead of a
silent choice between a real and a spoofed value.
Values are decoded as latin-1, matching Starlette and the HTTP/1.1 wire
encoding, so a header carrying non-UTF-8 bytes cannot fail the request.
"""
if not allowlist:
return {}
wanted = set(allowlist)
found: dict[str, list[bytes]] = {}
for raw_name, raw_value in raw_headers:
name = raw_name.decode("latin-1").lower()
if name in wanted:
found.setdefault(name, []).append(raw_value)
collected: dict[str, str] = {}
for name, values in found.items():
if len(values) > 1:
logger.warning(
"Header '%s' is in HINDSIGHT_API_EXTENSION_PASSTHROUGH_HEADERS but arrived %d times; "
"not forwarding it to extensions (no safe way to choose between the copies)",
name,
len(values),
)
continue
collected[name] = values[0].decode("latin-1")
return collected
@@ -66,11 +66,6 @@ def color_end(text: str) -> str:
return color(text, 1.0)
def color_mid(text: str) -> str:
"""Color text with gradient middle color."""
return color(text, 0.5)
def dim(text: str) -> str:
"""Dim/gray text."""
return f"\033[38;2;128;128;128m{text}\033[0m"
File diff suppressed because it is too large Load Diff
@@ -11,8 +11,10 @@ multiple API servers.
import asyncio
import json
import logging
from dataclasses import asdict, replace
from typing import TYPE_CHECKING, Any
from dataclasses import asdict, fields, replace
from functools import lru_cache
from types import UnionType
from typing import TYPE_CHECKING, Any, Union, get_args, get_origin
from hindsight_api.config import (
RECALL_BUDGET_FUNCTIONS,
@@ -32,6 +34,14 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
class BankConfigPersistenceConflictError(ValueError):
"""Raised when a validated bank config update can no longer be persisted."""
def __init__(self, bank_id: str):
self.bank_id = bank_id
super().__init__(f"Cannot update config for bank '{bank_id}': the bank does not exist")
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):
@@ -128,12 +138,13 @@ 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.
# Multi-LLM chains and the reranker failover chain 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,
reranker_members=self._global_config.reranker_members,
llm_members=self._global_config.llm_members,
llm_strategy=self._global_config.llm_strategy,
retain_llm_members=self._global_config.retain_llm_members,
@@ -286,7 +297,8 @@ class ConfigResolver:
# Only return active overrides for configurable fields. JSON null is a tombstone
# for "Server Default" in the bank-config UI and should not override defaults.
return {k: v for k, v in normalized.items() if k in self._configurable_fields and v is not None}
active = {k: v for k, v in normalized.items() if k in self._configurable_fields and v is not None}
return _coerce_stored_bank_overrides(bank_id, active)
except Exception as e:
logger.error(f"Failed to load bank config for {bank_id}: {e}")
@@ -326,7 +338,7 @@ class ConfigResolver:
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
result[row["bank_id"]] = _coerce_stored_bank_overrides(row["bank_id"], overrides)
except Exception as e:
logger.error(f"Failed to bulk-load bank configs: {e}")
return result
@@ -401,7 +413,7 @@ class ConfigResolver:
f"Not allowed to modify fields: {sorted(disallowed)}. "
f"Your permissions allow: {sorted(list(allowed_fields)[:10])}..."
if allowed_fields
else "Not allowed to modify fields: {sorted(disallowed)}. "
else f"Not allowed to modify fields: {sorted(disallowed)}. "
"Your permissions do not allow any config modifications."
)
except ValueError:
@@ -410,6 +422,11 @@ class ConfigResolver:
logger.warning(f"Failed to check permissions for bank {bank_id}: {e}")
# Continue without permission check (fail open for backward compatibility)
# Validate every value against its declared field type before the
# field-specific checks below, so a wrong-shaped value is reported as such
# instead of tripping a structural validator with a confusing message.
_validate_config_value_types(normalized_updates)
# Validate entity_labels structure
if "entity_labels" in normalized_updates and normalized_updates["entity_labels"] is not None:
from .engine.retain.entity_labels import parse_entity_labels
@@ -426,6 +443,16 @@ class ConfigResolver:
raise ValueError(
"Strategy names must not be empty strings. Remove entries with empty names before saving."
)
# A strategy's overrides are applied with dataclasses.replace() at retain
# time, so a wrong-shaped value there wedges the bank exactly as a
# top-level one would. Same contract, same door.
for strategy_name, strategy_overrides in normalized_updates["retain_strategies"].items():
if not isinstance(strategy_overrides, dict):
raise ValueError(f"Invalid retain strategy {strategy_name!r}: must be an object")
try:
_validate_config_value_types(normalize_config_dict(strategy_overrides))
except ValueError as e:
raise ValueError(f"Invalid retain strategy {strategy_name!r}: {e}") from e
# Validate recall budget fields
_validate_recall_budget_updates(normalized_updates)
@@ -496,7 +523,7 @@ class ConfigResolver:
# (The Oracle wrapper reshapes rowcount into the same "UPDATE <n>" form.)
updated = int(result.split()[-1]) if isinstance(result, str) and result.startswith("UPDATE") else 0
if updated == 0:
raise ValueError(f"Cannot update config for bank '{bank_id}': the bank does not exist")
raise BankConfigPersistenceConflictError(bank_id)
logger.info(f"Updated bank config for {bank_id}: {list(normalized_updates.keys())}")
@@ -521,6 +548,147 @@ class ConfigResolver:
logger.info(f"Reset bank config for {bank_id} to defaults")
# Fields whose accepted input shape is deliberately wider than the dataclass
# annotation, because a dedicated structural validator normalizes them later.
_WIDENED_FIELD_TYPES: dict[str, tuple[type, ...]] = {
# parse_entity_labels() accepts both the bare list of label groups and the
# {"attributes": [...]} envelope, though the field is annotated `list | None`.
"entity_labels": (list, dict),
}
def _runtime_types(declared: Any) -> tuple[type, ...]:
"""Runtime-checkable base classes for a dataclass field annotation.
Unwraps unions (``str | None``) and generic aliases (``list[str]`` -> ``list``);
``None`` is dropped because callers handle the tombstone separately. Returns an
empty tuple for anything not reducible to concrete classes, which the callers
read as "no type contract to enforce".
"""
if declared is type(None):
return ()
origin = get_origin(declared)
if origin in (Union, UnionType):
return tuple(t for arg in get_args(declared) for t in _runtime_types(arg))
if origin is not None:
return (origin,) if isinstance(origin, type) else ()
return (declared,) if isinstance(declared, type) else ()
@lru_cache(maxsize=1)
def _configurable_field_types() -> dict[str, tuple[type, ...]]:
"""Map each configurable field to the value types it accepts."""
configurable = HindsightConfig.get_configurable_fields()
field_types: dict[str, tuple[type, ...]] = {}
for field in fields(HindsightConfig):
if field.name not in configurable:
continue
allowed = _WIDENED_FIELD_TYPES.get(field.name) or _runtime_types(field.type)
if allowed:
field_types[field.name] = allowed
return field_types
def _value_matches_type(value: Any, allowed: tuple[type, ...]) -> bool:
"""Whether ``value`` satisfies a field's declared type contract."""
if isinstance(value, bool):
# bool is an int subclass; it must not slip into a numeric field.
return bool in allowed
if isinstance(value, int) and float in allowed:
# JSON draws no int/float distinction: 1 is a valid ratio.
return True
return isinstance(value, allowed)
# Field types are reported to API clients, so name them the way the JSON payload
# reads rather than by their Python class.
_TYPE_DESCRIPTIONS: dict[type, str] = {
bool: "a boolean",
int: "an integer",
float: "a number",
str: "a string",
list: "a list",
dict: "an object",
}
def _describe_types(allowed: tuple[type, ...]) -> str:
return " or ".join(dict.fromkeys(_TYPE_DESCRIPTIONS.get(t, t.__name__) for t in allowed))
def _validate_config_value_types(updates: dict[str, Any]) -> None:
"""Reject values whose type contradicts the declared HindsightConfig type.
Without this, the bank-config API happily stores e.g. a JSON object in
``observations_mission``; the write succeeds and the bank then fails every
consolidation with ``expected string or bytes-like object, got 'dict'`` from
deep inside prompt assembly (issue #3218). Reject at the door instead, naming
the field and the expected type.
"""
field_types = _configurable_field_types()
for key, value in updates.items():
allowed = field_types.get(key)
# None is the "clear this override" tombstone; unknown keys are rejected
# elsewhere as non-configurable.
if allowed is None or value is None:
continue
if not _value_matches_type(value, allowed):
raise ValueError(f"{key} must be {_describe_types(allowed)}, got {type(value).__name__}")
def _coerce_stored_bank_overrides(bank_id: str, overrides: dict[str, Any], where: str = "") -> dict[str, Any]:
"""Make stored bank overrides safe to consume, tolerating pre-validation shapes.
``_validate_config_value_types`` rejects bad types at write time, but banks
configured before that landed can still hold e.g. a JSON object in a
string-typed field. Every consumer that treats such a value as text blows up
identically on every run (``escape_for_prompt`` -> ``re.sub`` ->
"expected string or bytes-like object, got 'dict'"), so the bank's
consolidation never recovers on its own (issue #3218).
String fields are JSON-encoded, which preserves the author's intent — the
structure still reaches the prompt, as text. Anything else is dropped so the
bank falls back to the tenant/global value rather than wedging.
``where`` labels the location in warnings; it is set when recursing into a
retain strategy, whose overrides reach the same fields via ``apply_strategy``.
"""
field_types = _configurable_field_types()
coerced: dict[str, Any] = {}
for key, value in overrides.items():
allowed = field_types.get(key)
# None passes through: the caller has already dropped top-level tombstones,
# and inside a retain strategy a null is a deliberate override to None.
if allowed is None or value is None or _value_matches_type(value, allowed):
coerced[key] = value
continue
if str in allowed:
coerced[key] = json.dumps(value, ensure_ascii=False)
logger.warning(
f"Bank {bank_id} config field '{key}'{where} holds a {type(value).__name__} but is a string field; "
f"using its JSON encoding. Re-save this field as a string to silence this warning."
)
else:
logger.warning(
f"Bank {bank_id} config field '{key}'{where} holds a {type(value).__name__} but must be "
f"{_describe_types(allowed)}; ignoring the override and falling back to the server default."
)
# Strategy overrides are spliced onto the resolved config by apply_strategy(),
# so a bad value nested there wedges the bank just as a top-level one does.
strategies = coerced.get("retain_strategies")
if isinstance(strategies, dict):
coerced["retain_strategies"] = {
name: (
_coerce_stored_bank_overrides(bank_id, strategy, where=f" in retain strategy {name!r}")
if isinstance(strategy, dict)
else strategy
)
for name, strategy in strategies.items()
}
return coerced
_RECALL_BUDGET_FIXED_KEYS = (
"recall_budget_fixed_low",
"recall_budget_fixed_mid",
+4 -30
View File
@@ -14,10 +14,7 @@ import subprocess
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import IO
from typing import IO
logger = logging.getLogger(__name__)
@@ -42,37 +39,28 @@ class IdleTimeoutMiddleware:
self.app = app
self.idle_timeout = idle_timeout
self.last_activity = time.time()
self._checker_task = None
async def __call__(self, scope, receive, send):
# Update activity timestamp on each request
self.last_activity = time.time()
await self.app(scope, receive, send)
def start_idle_checker(self):
"""Start the background task that checks for idle timeout."""
self._checker_task = asyncio.create_task(self._check_idle())
async def _check_idle(self):
"""Background task that exits the process after idle timeout."""
# If idle_timeout is 0, don't auto-exit
"""Exit the daemon after the configured period without requests."""
if self.idle_timeout <= 0:
return
while True:
await asyncio.sleep(30) # Check every 30 seconds
await asyncio.sleep(30)
idle_time = time.time() - self.last_activity
if idle_time > self.idle_timeout:
logger.info(f"Idle timeout reached ({self.idle_timeout}s), shutting down daemon")
# Give a moment for any in-flight requests
await asyncio.sleep(1)
# Send SIGTERM to ourselves to trigger graceful shutdown
import signal
os.kill(os.getpid(), signal.SIGTERM)
def _detach_popen_kwargs(log_handle: "IO[bytes]") -> dict:
def _detach_popen_kwargs(log_handle: IO[bytes]) -> dict:
"""Cross-platform kwargs to spawn a subprocess detached from the caller.
On POSIX, ``start_new_session=True`` calls ``setsid(2)`` so the child
@@ -169,17 +157,3 @@ def daemonize():
subprocess.Popen(cmd, env=env, **_detach_popen_kwargs(log_handle))
sys.exit(0)
def check_daemon_running(port: int = DEFAULT_DAEMON_PORT) -> bool:
"""Check if a daemon is running and responsive on the given port."""
import socket
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(1)
result = sock.connect_ex(("127.0.0.1", port))
sock.close()
return result == 0
except Exception:
return False
@@ -3,7 +3,7 @@ Memory Engine - Core implementation of the memory system.
This package contains all the implementation details of the memory engine:
- MemoryEngine: Main class for memory operations
- Utility modules: embedding_utils, link_utils, think_utils, bank_utils
- Utility modules: embedding_utils, link_utils, bank_utils
- Supporting modules: embeddings, cross_encoder, entity_resolver, etc.
"""
@@ -0,0 +1,191 @@
"""Server-side prompt-cache affinity hints for OpenAI-compatible providers.
Prompt caching only pays off when the same conversation reaches the same backend
cache, and providers expose different mechanisms for that:
- xAI stores prompt-cache entries **per backend server** and routes requests
carrying the same ``x-grok-conv-id`` to one server (docs.x.ai, "Maximizing
Cache Hits"). Without it, consecutive calls of one agentic loop can each land
on a cache-cold replica.
- OpenAI accepts a ``prompt_cache_key`` request field that improves its own
cache routing.
Hindsight already does provider-specific cache work for its first-class
providers (``anthropic_llm`` sets ``cache_control`` breakpoints; ``gemini_llm``
runs an explicit ``CachedContent`` manager). This module is the equivalent for
the OpenAI-compatible family — ``OpenAICompatibleLLM`` and its ``fireworks``
and ``nous`` subclasses — which sent no affinity hint at all.
Default ``auto`` per member (``cache_affinity``). ``auto`` is an allowlist, not a
best-effort probe: it emits a hint only for hosts documented to accept one and
resolves to ``none`` for everything else, so an unknown OpenAI-compatible backend
never receives an unfamiliar field. Every helper here is fail-open — when no id
can be derived the request goes out byte-identical to before. Set ``none`` to
disable entirely.
"""
from __future__ import annotations
import hashlib
import json
import logging
from enum import StrEnum
from typing import Any
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
# xAI's documented cache-pinning header, and OpenAI's cache-routing field.
XAI_CONV_ID_HEADER = "x-grok-conv-id"
OPENAI_PROMPT_CACHE_KEY_PARAM = "prompt_cache_key"
# Hosts (exact or parent domain) whose backends implement the xAI header.
_XAI_DOMAINS = ("x.ai", "grok.com")
# Hosts (exact or parent domain) that accept OpenAI's prompt_cache_key field.
# Deliberately excludes openai.azure.com: Azure OpenAI itself accepts the field
# on GPT deployments, but the same *.openai.azure.com endpoint also fronts
# non-OpenAI Foundry models (DeepSeek, Llama, Mistral) that reject it with
# `unrecognized_request_argument` (#3518). The host says nothing about which
# model family the deployment serves, so `auto` stays off there and an Azure
# GPT operator opts in with openai_prompt_cache_key.
_OPENAI_DOMAINS = ("openai.com",)
class CacheAffinityMode(StrEnum):
"""How (and whether) to pin a request to a backend prompt cache."""
NONE = "none"
XAI_CONV_ID = "xai_conv_id"
OPENAI_PROMPT_CACHE_KEY = "openai_prompt_cache_key"
AUTO = "auto"
def parse_cache_affinity(value: str | None) -> CacheAffinityMode:
"""Validate a configured cache-affinity mode, defaulting to ``none``.
Raises ``ValueError`` on an unrecognized value so a typo fails loudly at
provider construction rather than silently disabling the feature — the whole
point of the setting is that its effect is invisible in the response.
"""
if not value:
return CacheAffinityMode.NONE
try:
return CacheAffinityMode(value.strip().lower())
except ValueError as e:
valid = ", ".join(mode.value for mode in CacheAffinityMode)
raise ValueError(f"Invalid cache_affinity {value!r}. Must be one of: {valid}.") from e
def _host_matches(hostname: str, domain: str) -> bool:
"""True when ``hostname`` is ``domain`` itself or a subdomain of it.
Parsed-host suffix matching, never a substring test: a bare
``"x.ai" in base_url`` also matches ``vertex.ai`` and
``https://x.ai.evil.example``. The in-tree Azure check
(``".openai.azure.com" in self.base_url``) gets away with a substring only
because its needle is long and dotted; ``x.ai`` is four characters.
"""
return hostname == domain or hostname.endswith(f".{domain}")
def resolve_cache_affinity(mode: CacheAffinityMode, provider: str, base_url: str | None) -> CacheAffinityMode:
"""Resolve ``auto`` to a concrete mode from the provider and base-URL host.
Non-``auto`` modes are returned unchanged. ``auto`` resolves to
``xai_conv_id`` for an x.ai / grok.com host, ``openai_prompt_cache_key`` for
native OpenAI (no base URL) or an openai.com host, and
``none`` for everything else — an unknown backend gets no unfamiliar field.
The xAI check is host-only and deliberately provider-independent: the
documented setup for an xAI endpoint is ``provider=openai`` plus an x.ai base
URL, exactly like Azure OpenAI, so keying on the provider name would miss it.
"""
if mode is not CacheAffinityMode.AUTO:
return mode
hostname = (urlparse(base_url).hostname or "") if base_url else ""
if hostname and any(_host_matches(hostname, domain) for domain in _XAI_DOMAINS):
return CacheAffinityMode.XAI_CONV_ID
if provider.lower() == "openai":
if not hostname:
return CacheAffinityMode.OPENAI_PROMPT_CACHE_KEY
if any(_host_matches(hostname, domain) for domain in _OPENAI_DOMAINS):
return CacheAffinityMode.OPENAI_PROMPT_CACHE_KEY
return CacheAffinityMode.NONE
def _first_message_fingerprint(messages: Any) -> str | None:
"""Hash the first message into a 32-hex id, or None if the shape is wrong.
Used only when no trace context is bound (direct provider use, tests). The
first message is the system prompt, so the id is stable as the message list
grows through an agent loop — which is the property cache pinning needs —
while differing across conversations whose first messages differ.
Shape-checked rather than truthiness-checked: a bare string ``messages``
would index to its first character and mint an id from garbage. Anything
unexpected returns None and the request goes out with no affinity hint.
"""
if not isinstance(messages, list) or not messages or not isinstance(messages[0], dict):
return None
try:
canonical = json.dumps(messages[0], sort_keys=True, ensure_ascii=False, default=str)
except (TypeError, ValueError):
logger.debug("Cache affinity: first message not serializable; sending no hint", exc_info=True)
return None
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32]
def cache_affinity_id(messages: Any) -> str | None:
"""Return the affinity id for the in-flight call, or None to send nothing.
Primary source is the operation's ``trace_id`` — one uuid per
retain/reflect/consolidation run, generated in ``LLMProvider.with_config``
and bound around every underlying provider call, so every LLM call of one
run shares it. That is engine identity rather than payload hashing: it stays
constant across a run even when the first message changes mid-run.
The value is always 32 lowercase hex characters, including for the trace_id
path (hashed rather than passed through) so the wire format is uniform and
carries no uuid semantics.
"""
from .llm_trace import current_trace_context
trace_ctx = current_trace_context()
if trace_ctx is not None and trace_ctx.trace_id:
return hashlib.sha256(str(trace_ctx.trace_id).encode("utf-8")).hexdigest()[:32]
return _first_message_fingerprint(messages)
def apply_cache_affinity(request: dict[str, Any], mode: CacheAffinityMode) -> None:
"""Add this request's cache-affinity hint to ``request`` in place.
``mode`` must already be resolved (see :func:`resolve_cache_affinity`);
``none`` — and an unresolved ``auto`` — add nothing.
User-wins semantics throughout, matching the file's ``setdefault`` precedent
in ``_apply_provider_extra_body_defaults``: an ``x-grok-conv-id`` the caller
already placed in ``extra_headers`` is kept, and a ``prompt_cache_key`` in
the operator's configured ``extra_body`` (the escape hatch for a backend
that wants its own value) suppresses ours entirely.
Never raises: when no id can be derived the request is left byte-identical
to a pre-affinity one.
"""
affinity_id = cache_affinity_id(request.get("messages"))
if affinity_id is None:
return
if mode is CacheAffinityMode.XAI_CONV_ID:
extra_headers = request.setdefault("extra_headers", {})
extra_headers.setdefault(XAI_CONV_ID_HEADER, affinity_id)
elif mode is CacheAffinityMode.OPENAI_PROMPT_CACHE_KEY:
# prompt_cache_key is a first-class named parameter on
# chat.completions.create() in the resolved openai SDK, so it goes at the
# top level rather than through extra_body. An operator value in
# extra_body would still reach the same wire field, so honour it and
# send nothing rather than sending both.
extra_body = request.get("extra_body")
if isinstance(extra_body, dict) and OPENAI_PROMPT_CACHE_KEY_PARAM in extra_body:
return
request.setdefault(OPENAI_PROMPT_CACHE_KEY_PARAM, affinity_id)
@@ -114,19 +114,17 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
return NO_TEMPORAL_CONSTRAINT
return constraint(start, end)
def subtract_months(months: int) -> datetime:
month_index = reference_date.month - months - 1
year = reference_date.year + month_index // 12
month = month_index % 12 + 1
day = min(reference_date.day, calendar.monthrange(year, month)[1])
return reference_date.replace(year=year, month=month, day=day)
def subtract_months(months: int) -> datetime | None:
return add_months(reference_date, -months)
def month_end(year: int, month: int) -> datetime:
return datetime(year, month, calendar.monthrange(year, month)[1])
def add_months(base_date: datetime, months: int) -> datetime:
def add_months(base_date: datetime, months: int) -> datetime | None:
month_index = base_date.month + months - 1
year = base_date.year + month_index // 12
if year < datetime.min.year or year > datetime.max.year:
return None
month = month_index % 12 + 1
day = min(base_date.day, calendar.monthrange(year, month)[1])
return base_date.replace(year=year, month=month, day=day)
@@ -373,7 +371,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
sat = start + timedelta(days=5)
return constraint(sat, sat + timedelta(days=1))
def relative_month_start(period: str | None) -> datetime:
def relative_month_start(period: str | None) -> datetime | None:
return add_months(reference_date.replace(day=1), relative_period_offset(period))
def exact_day_constraint(year: int, month_text: str, day_text: str) -> DateRange | None:
@@ -418,6 +416,8 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
if day is None:
return None
start = relative_month_start(period)
if start is None:
return None
if day > calendar.monthrange(start.year, start.month)[1]:
return None
return datetime(start.year, start.month, day)
@@ -472,9 +472,9 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
def relative_offset_datetime(amount: int, unit: str, direction: int) -> datetime | None:
if unit in ("", ""):
return reference_date + timedelta(days=direction * amount)
return add_days(reference_date, direction * amount)
if unit in ("", "星期", "礼拜"):
return reference_date + timedelta(weeks=direction * amount)
return add_days(reference_date, direction * amount * 7)
if unit == "":
return add_months(reference_date, direction * amount)
return add_years(reference_date, direction * amount)
@@ -616,6 +616,8 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
if relative_month_range_match:
first = relative_month_start(relative_month_range_match.group(1))
second = relative_month_start(relative_month_range_match.group(2))
if first is None or second is None:
return NO_TEMPORAL_CONSTRAINT
start = min(first, second)
end = max(first, second)
return constraint(start, month_end(end.year, end.month))
@@ -839,7 +841,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
rf"(?<![上下大小])(上上|大上|上|这|本|当|下下|大下|下){_CHINESE_OPTIONAL_PERIOD_MARKER}{chinese_since_suffix_pattern}"
)
if month_since_match:
return since_constraint(relative_month_start(month_since_match.group(1)))
return safe_since_constraint(relative_month_start(month_since_match.group(1)))
absolute_year_month_since_match = chinese_search(
rf"({chinese_year_pattern})\s*年\s*({chinese_month_pattern})\s*月{chinese_since_suffix_pattern}"
@@ -1035,7 +1037,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
if chinese_search(r"一年半前"):
d = subtract_months(18)
return constraint(d, d)
return safe_constraint(d, d)
if chinese_search(r"([一二两三四五六七八九十]+)年半前"):
match = chinese_search(r"([一二两三四五六七八九十]+)年半前")
@@ -1043,15 +1045,15 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
years = parse_chinese_number(match.group(1))
if years is not None:
d = subtract_months(years * 12 + 6)
return constraint(d, d)
return safe_constraint(d, d)
if chinese_search(r"([0-9]+|[一二两三四五六七八九十]+)个?半月前"):
match = chinese_search(r"([0-9]+|[一二两三四五六七八九十]+)个?半月前")
if match is not None:
months = parse_chinese_number(match.group(1))
if months is not None:
d = subtract_months(months) - timedelta(days=15)
return constraint(d, d)
d = add_days(subtract_months(months), -15)
return safe_constraint(d, d)
if chinese_search(r"半个?月前"):
d = reference_date - timedelta(days=15)
@@ -1059,7 +1061,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
if chinese_search(r"半年前"):
d = subtract_months(6)
return constraint(d, d)
return safe_constraint(d, d)
future_year_half_match = chinese_search(
rf"([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)年半{chinese_relative_future_suffix_pattern}"
@@ -1068,7 +1070,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
years = parse_chinese_number(future_year_half_match.group(1))
if years is not None:
d = add_months(reference_date, years * 12 + 6)
return constraint(d, d)
return safe_constraint(d, d)
future_half_month_match = chinese_search(
rf"([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)个?半月{chinese_relative_future_suffix_pattern}"
@@ -1076,8 +1078,8 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
if future_half_month_match:
months = parse_chinese_number(future_half_month_match.group(1))
if months is not None:
d = add_months(reference_date, months) + timedelta(days=15)
return constraint(d, d)
d = add_days(add_months(reference_date, months), 15)
return safe_constraint(d, d)
if chinese_search(rf"半个?月{chinese_relative_future_suffix_pattern}"):
d = reference_date + timedelta(days=15)
@@ -1085,7 +1087,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
if chinese_search(rf"半年{chinese_relative_future_suffix_pattern}"):
d = add_months(reference_date, 6)
return constraint(d, d)
return safe_constraint(d, d)
adjacent_fuzzy_future_match = chinese_search(
r"(?<![一二三四五六七八九十百千万零\d后])"
@@ -1232,14 +1234,14 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
unit = rolling_past_half_match.group(2)
if unit == "":
return constraint(reference_date - timedelta(days=15), reference_date)
return constraint(subtract_months(6), reference_date)
return safe_constraint(subtract_months(6), reference_date)
within_half_match = chinese_search(r"半个?(月|年)(?:以内|之内|内)")
if within_half_match:
unit = within_half_match.group(1)
if unit == "":
return constraint(reference_date - timedelta(days=15), reference_date)
return constraint(subtract_months(6), reference_date)
return safe_constraint(subtract_months(6), reference_date)
within_count_match = chinese_search(
rf"([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)(个?)(天|日|周|星期|礼拜|月|年)(?:以内|之内|内)"
@@ -1302,7 +1304,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
unit = rolling_future_half_match.group(2)
if unit == "":
return constraint(reference_date, reference_date + timedelta(days=15))
return constraint(reference_date, add_months(reference_date, 6))
return safe_constraint(reference_date, add_months(reference_date, 6))
absolute_year_quarter_since_match = chinese_search(
rf"({chinese_year_pattern})\s*年\s*(第?[一二三四1-4])季(?:度)?{chinese_since_suffix_pattern}"
@@ -1439,6 +1441,8 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
)
if next_month_phase_since_match:
start = add_months(reference_date.replace(day=1), 1)
if start is None:
return NO_TEMPORAL_CONSTRAINT
return since_from_period(month_phase_period(start.year, start.month, next_month_phase_since_match.group(1)))
second_next_month_phase_since_match = chinese_search(
@@ -1447,6 +1451,8 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
)
if second_next_month_phase_since_match:
start = add_months(reference_date.replace(day=1), 2)
if start is None:
return NO_TEMPORAL_CONSTRAINT
return since_from_period(
month_phase_period(start.year, start.month, second_next_month_phase_since_match.group(2))
)
@@ -1465,7 +1471,10 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
rf"({chinese_month_phase_pattern}){chinese_since_suffix_pattern}"
)
if second_previous_month_phase_since_match:
start = subtract_months(2).replace(day=1)
start = subtract_months(2)
if start is None:
return NO_TEMPORAL_CONSTRAINT
start = start.replace(day=1)
return since_from_period(
month_phase_period(start.year, start.month, second_previous_month_phase_since_match.group(2))
)
@@ -1590,6 +1599,8 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
)
if next_month_phase_match:
start = add_months(reference_date.replace(day=1), 1)
if start is None:
return NO_TEMPORAL_CONSTRAINT
return month_phase_period(start.year, start.month, next_month_phase_match.group(1))
second_next_month_phase_match = chinese_search(
@@ -1597,6 +1608,8 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
)
if second_next_month_phase_match:
start = add_months(reference_date.replace(day=1), 2)
if start is None:
return NO_TEMPORAL_CONSTRAINT
return month_phase_period(start.year, start.month, second_next_month_phase_match.group(2))
previous_month_phase_match = chinese_search(
@@ -1611,7 +1624,10 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
rf"(?<![上大])(上上|大上){_CHINESE_OPTIONAL_PERIOD_MARKER}月份?\s*({chinese_month_phase_pattern})"
)
if second_previous_month_phase_match:
start = subtract_months(2).replace(day=1)
start = subtract_months(2)
if start is None:
return NO_TEMPORAL_CONSTRAINT
start = start.replace(day=1)
return month_phase_period(start.year, start.month, second_previous_month_phase_match.group(2))
bare_specific_month_phase_match = chinese_search(
@@ -1730,10 +1746,14 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
rf"(?<![下大])(下下|大下){_CHINESE_OPTIONAL_PERIOD_MARKER}月(?!{chinese_month_boundary_suffix_pattern})"
):
start = add_months(reference_date.replace(day=1), 2)
if start is None:
return NO_TEMPORAL_CONSTRAINT
return constraint(start, month_end(start.year, start.month))
if chinese_search(rf"(?<![下大])下{_CHINESE_OPTIONAL_PERIOD_MARKER}月(?!{chinese_month_boundary_suffix_pattern})"):
start = add_months(reference_date.replace(day=1), 1)
if start is None:
return NO_TEMPORAL_CONSTRAINT
return constraint(start, month_end(start.year, start.month))
if chinese_search(rf"(下一个年度|下一年度|下年度|下一年|明年)(?!{chinese_boundary_suffix_pattern})"):
@@ -1763,7 +1783,10 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
if chinese_search(
rf"(?<![上大])(上上|大上){_CHINESE_OPTIONAL_PERIOD_MARKER}月(?!{chinese_month_boundary_suffix_pattern})"
):
start = subtract_months(2).replace(day=1)
start = subtract_months(2)
if start is None:
return NO_TEMPORAL_CONSTRAINT
start = start.replace(day=1)
return constraint(start, month_end(start.year, start.month))
if chinese_search(rf"前一个?(周|星期|礼拜)(?!{chinese_boundary_suffix_pattern})"):
File diff suppressed because it is too large Load Diff
@@ -17,6 +17,20 @@ _MISSION_PRIORITY_NOTE = (
"DECISION GUIDE, or OUTPUT FORMAT below, the MISSION takes priority."
)
# Default language rule — used only when HINDSIGHT_API_LLM_OUTPUT_LANGUAGE is
# unset. Without it the whole prompt is English and multilingual models drift:
# Chinese source facts intermittently produce English observations. Retain's
# fact extraction carries the equivalent rule (see _BASE_FACT_EXTRACTION_PROMPT),
# so this makes "preserve the source language" the pipeline-wide default. When an
# output language IS configured, this section is omitted and
# output_language_directive() takes over — the two must never both be present or
# they contradict each other.
_DEFAULT_LANGUAGE_RULE = """## LANGUAGE
Write every observation in the language of its own source facts — never translate them. Per observation, not per batch: when one merges facts of several languages, the majority wins. Proper nouns, identifiers, and units stay verbatim.
When an existing observation is written in a different language from the new facts updating it, do NOT edit its wording in place — that is what produces an English sentence with a Chinese detail bolted on. Discard the old phrasing and compose the merged observation from scratch in the new facts' language."""
_PROCESSING_RULES = """## PROCESSING RULES
1. PREFER UPDATE OVER CREATE (when there is something to merge with): if new facts describe the same canonical event, statement, decision, claim, or recurring pattern already covered by an existing observation, UPDATE that observation and attach the new facts as evidence. Do NOT create a near-duplicate sibling. One canonical observation with many source facts is always better than many siblings with one source fact each. Merge aggressively on: same named event, same diagnostic finding, same architectural decision, same recurring claim. **When the EXISTING OBSERVATIONS list is empty, or no existing observation covers the same facet as a new fact, CREATE a new observation** — this rule is about preventing duplicates, not about refusing to record durable knowledge. CREATE is the correct default for any structurally distinct event, claim, or pattern that has no existing match.
@@ -37,10 +51,8 @@ _PROCESSING_RULES = """## PROCESSING RULES
9. KEEP DISTINCT TOPICS DISTINCT: do not merge observations about different people, entities, or unrelated topics. Merging is for the same canonical fact recurring — not for related-but-distinct claims."""
# Field-by-field definitions of the input shape, shared by the cached system
# prefix (_INPUT_FORMAT_NOTE) and the single-message prompt (_INPUT_SECTION) so
# the two descriptions cannot drift apart. Both call sites run .format(), so
# these strings must contain no braces.
# Field-by-field definitions of the input shape used by the cached system
# prefix. The call site runs .format(), so these strings must contain no braces.
_FACT_FIELDS = """One per line, formatted as `[uuid] fact text (temporal fields)`:
- `[uuid]`: the fact's identifier — copy it verbatim into `source_fact_ids`
- `occurred_start` / `occurred_end`: when the described event happened. This can be long before the fact was stated — a fact recorded today may describe a 2019 event.
@@ -83,24 +95,6 @@ _SPLIT_INPUT_SECTION = """## INPUT
{observations_text}"""
# Data section — format placeholders {facts_text} and {observations_text} are substituted at call time
_INPUT_SECTION = f"""## INPUT
Every temporal field below is optional and is omitted when unknown.
### New facts
{_FACT_FIELDS}
{{facts_text}}
### Existing observations
JSON array, pooled from recalls across all new facts above. Each entry has:
{_OBSERVATION_FIELDS}
{{observations_text}}"""
_DECISION_GUIDE = """## DECISION GUIDE
- **Same canonical event, decision, claim, or facet as an existing observation → UPDATE** (use `observation_id` + new `source_fact_ids`).
@@ -161,39 +155,6 @@ Expected output (UPDATE for the state change; CREATE for the unrelated work-hour
- Return `{{"creates": [], "updates": [], "deletes": []}}` if nothing durable is found."""
def build_batch_consolidation_prompt(
observations_mission: str | None = None,
observation_capacity_note: str | None = None,
llm_output_language: str | None = None,
) -> str:
"""
Build the consolidation prompt for batch mode (multiple facts per LLM call).
The mission defines *what* to track (customisable per bank) and takes
priority over the built-in processing rules when the two conflict.
Processing rules, decision guide, and output format are always present.
When ``llm_output_language`` is set, observations are emitted in that
language.
"""
mission = escape_for_prompt(observations_mission or _DEFAULT_MISSION)
capacity_section = ""
if observation_capacity_note:
capacity_section = f"\n\n## CAPACITY CONSTRAINT\n\n{escape_for_prompt(observation_capacity_note)}"
return (
"You are a memory consolidation system. Synthesize new facts into "
"observations, merging with existing observations when appropriate.\n\n"
f"## MISSION\n\n{mission}\n\n"
f"{_MISSION_PRIORITY_NOTE}"
f"{capacity_section}\n\n"
f"{_PROCESSING_RULES}\n\n"
f"{_INPUT_SECTION}\n\n"
f"{_DECISION_GUIDE}\n\n"
f"{_OUTPUT_SECTION}" + output_language_directive(llm_output_language)
)
def build_consolidation_system_prompt(
llm_output_language: str | None = None,
) -> str:
@@ -208,11 +169,17 @@ def build_consolidation_system_prompt(
bank and a single CachedContent serves them all. Returns final text
(brace-escaped examples already unescaped) for verbatim use as system message
and cached prefix.
``llm_output_language`` picks between two mutually exclusive language rules:
unset keeps each observation in the language of its own source facts (the
default), set forces every observation into that one configured language.
"""
language_section = "" if llm_output_language else f"{_DEFAULT_LANGUAGE_RULE}\n\n"
template = (
"You are a memory consolidation system. Synthesize new facts into "
"observations, merging with existing observations when appropriate.\n\n"
f"{_MISSION_PRIORITY_NOTE}\n\n"
f"{language_section}"
f"{_PROCESSING_RULES}\n\n"
f"{_INPUT_FORMAT_NOTE}\n\n"
f"{_DECISION_GUIDE}\n\n"
@@ -8,7 +8,6 @@ Configuration via environment variables - see hindsight_api.config for all env v
import asyncio
import logging
import os
import warnings
from abc import ABC, abstractmethod
from concurrent.futures import ThreadPoolExecutor
@@ -20,8 +19,8 @@ from ..config import (
DEFAULT_LITELLM_API_BASE,
DEFAULT_RERANKER_ALIBABA_MODEL,
DEFAULT_RERANKER_COHERE_MODEL,
DEFAULT_RERANKER_FLASHRANK_BATCH_SIZE,
DEFAULT_RERANKER_FLASHRANK_CACHE_DIR,
DEFAULT_RERANKER_FLASHRANK_CPU_MEM_ARENA,
DEFAULT_RERANKER_FLASHRANK_MODEL,
DEFAULT_RERANKER_GOOGLE_MODEL,
DEFAULT_RERANKER_LITELLM_MAX_TOKENS_PER_DOC,
@@ -35,16 +34,7 @@ from ..config import (
DEFAULT_RERANKER_TEI_MAX_CONCURRENT,
DEFAULT_RERANKER_ZEROENTROPY_MODEL,
DEFAULT_ZEROENTROPY_BASE_URL,
ENV_RERANKER_ALIBABA_API_KEY,
ENV_RERANKER_COHERE_API_KEY,
ENV_RERANKER_FLASHRANK_CACHE_DIR,
ENV_RERANKER_FLASHRANK_CPU_MEM_ARENA,
ENV_RERANKER_FLASHRANK_MODEL,
ENV_RERANKER_GOOGLE_PROJECT_ID,
ENV_RERANKER_PROVIDER,
ENV_RERANKER_SILICONFLOW_API_KEY,
ENV_RERANKER_TEI_URL,
ENV_RERANKER_ZEROENTROPY_API_KEY,
RerankerMemberConfig,
)
from .bank_attribution import reranker_bank_attribution_headers
from .local_device import (
@@ -52,6 +42,7 @@ from .local_device import (
resolve_model_device_type,
select_local_device,
)
from .tei_retry import tei_retry_delay
logger = logging.getLogger(__name__)
@@ -69,6 +60,15 @@ class CrossEncoderModel(ABC):
"""Return a human-readable name for this provider (e.g., 'local', 'tei')."""
pass
@property
def blocking_init(self) -> bool:
"""Whether ``initialize()`` blocks the event loop (loads a model in-process).
Callers run those in a thread pool. Remote providers leave this False, and
so does :class:`MultiCrossEncoder` — it offloads its own members.
"""
return False
@abstractmethod
async def initialize(self) -> None:
"""
@@ -161,6 +161,10 @@ class LocalSTCrossEncoder(CrossEncoderModel):
def provider_name(self) -> str:
return "local"
@property
def blocking_init(self) -> bool:
return True
async def initialize(self) -> None:
"""Load the cross-encoder model and initialize the executor."""
if self._model is not None:
@@ -389,14 +393,20 @@ class RemoteTEICrossEncoder(CrossEncoderModel):
await asyncio.sleep(delay)
delay *= 2 # Exponential backoff
except httpx.HTTPStatusError as e:
# Retry on 5xx server errors
if e.response.status_code >= 500 and attempt < self.max_retries:
# TEI uses 429 as normal overload backpressure. Retry it with
# the same bounded budget as transient server errors.
if (e.response.status_code == 429 or e.response.status_code >= 500) and attempt < self.max_retries:
last_error = e
logger.warning(
f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. "
f"Retrying in {delay}s..."
sleep_delay = tei_retry_delay(
e.response,
delay,
request_timeout=self.timeout,
)
await asyncio.sleep(delay)
logger.warning(
f"TEI transient error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. "
f"Retrying in {sleep_delay:.2f}s..."
)
await asyncio.sleep(sleep_delay)
delay *= 2
else:
raise
@@ -863,6 +873,7 @@ class FlashRankCrossEncoder(CrossEncoderModel):
max_length: int = 512,
max_concurrent: int = 4,
cpu_mem_arena: bool = False,
batch_size: int = DEFAULT_RERANKER_FLASHRANK_BATCH_SIZE,
):
"""
Initialize FlashRank cross-encoder.
@@ -876,11 +887,16 @@ class FlashRankCrossEncoder(CrossEncoderModel):
When True, ONNX pre-allocates a memory arena that never
shrinks, causing RSS to grow monotonically. False trades
slightly slower per-call allocation for bounded RSS.
batch_size: Passages per forward pass. Default: 32. See
``_predict_sync`` for why this must stay bounded.
"""
self.model_name = model_name or DEFAULT_RERANKER_FLASHRANK_MODEL
self.cache_dir = cache_dir or DEFAULT_RERANKER_FLASHRANK_CACHE_DIR
self.max_length = max_length
self.cpu_mem_arena = cpu_mem_arena
# A non-positive size would mean "one pass for everything", which is the
# unbounded behaviour this batching exists to prevent.
self.batch_size = max(1, batch_size)
self._ranker = None
self._device_type: str = "cpu" # FlashRank runs on CPU via ONNX Runtime
FlashRankCrossEncoder._max_concurrent = max_concurrent
@@ -953,7 +969,21 @@ class FlashRankCrossEncoder(CrossEncoderModel):
logger.info("Reranker: FlashRank provider initialized (using existing executor)")
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Synchronous predict - processes each query group."""
"""Synchronous predict each query group, in bounded batches.
FlashRank scores every passage of a request in one ONNX forward pass, and
that pass allocates attention tensors sized ``batch * heads * seq^2``. At
the default reranker candidate cap that is gigabytes per call, which OOM-
killed containers on large banks (issue #3355): the burst scales with the
candidate pool the retrieval arms produce, not with how much work the
caller asked for. FlashRank also pads a request to its longest passage, so
one long candidate inflates the sequence length for every other one.
Splitting into ``batch_size`` chunks bounds the peak the same way the
local and TEI providers already do. Scores are identical either way —
passages are scored independently, so batching changes only the
allocation profile.
"""
if not pairs:
return []
@@ -970,20 +1000,25 @@ class FlashRankCrossEncoder(CrossEncoderModel):
all_scores = [0.0] * len(pairs)
for query, indexed_texts in query_groups.items():
# Build passages list for FlashRank
passages = [{"id": i, "text": text} for i, (_, text) in enumerate(indexed_texts)]
global_indices = [idx for idx, _ in indexed_texts]
# Create rerank request
request = RerankRequest(query=query, passages=passages)
results = self._ranker.rerank(request)
for start in range(0, len(indexed_texts), self.batch_size):
batch = indexed_texts[start : start + self.batch_size]
# Map scores back to original positions
for result in results:
local_idx = result["id"]
score = result["score"]
global_idx = global_indices[local_idx]
all_scores[global_idx] = score
# Build passages list for FlashRank. Ids are batch-local, so
# `start` shifts them back onto the query group's indices.
passages = [{"id": i, "text": text} for i, (_, text) in enumerate(batch)]
# Create rerank request
request = RerankRequest(query=query, passages=passages)
results = self._ranker.rerank(request)
# Map scores back to original positions
for result in results:
local_idx = result["id"]
score = result["score"]
global_idx = global_indices[start + local_idx]
all_scores[global_idx] = score
return all_scores
finally:
@@ -1568,132 +1603,247 @@ class AlibabaCloudCrossEncoder(CrossEncoderModel):
return await self._client.predict(pairs)
def create_cross_encoder_from_env() -> CrossEncoderModel:
"""
Create a CrossEncoderModel instance based on configuration.
class MultiCrossEncoder(CrossEncoderModel):
"""Failover across an ordered chain of cross-encoders.
Reads configuration via get_config() to ensure consistency across the codebase.
Member 0 is the primary (the unindexed ``HINDSIGHT_API_RERANKER_*`` config);
members 1..N are the indexed fallbacks. Each ``predict`` tries members in order
and returns the first usable set of scores, so an unreachable reranker costs
ranking quality (whatever the next member gives) instead of the whole recall.
Put ``rrf`` last to degrade to the fusion order rather than failing.
Each member keeps its own retry budget, so we only advance after a member has
exhausted its retries and raised. A member that fails to initialize is not
fatal — that is the point of the chain — it is retried lazily on the next
request that reaches it.
"""
def __init__(self, members: list[CrossEncoderModel]) -> None:
if len(members) < 2:
raise ValueError("MultiCrossEncoder requires at least two members")
self._members = members
self._ready = [False] * len(members)
self._locks = [asyncio.Lock() for _ in members]
self._active = 0
@property
def provider_name(self) -> str:
"""The provider of the member that last served a request (primary before any).
Callers use this to detect a passthrough reranker, so it has to track the
member actually serving rather than name the chain: a chain that has
degraded to its ``rrf`` member is passthrough. Concurrent requests share it,
so a request that fails over can briefly mislabel a neighbour — this only
tunes downstream scoring, never correctness.
"""
return self._members[self._active].provider_name
async def _initialize_member(self, index: int) -> None:
"""Initialize one member, off the event loop when it loads a model in-process."""
member = self._members[index]
if member.blocking_init:
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, lambda: asyncio.run(member.initialize()))
else:
await member.initialize()
self._ready[index] = True
async def _ensure_member_ready(self, index: int) -> None:
async with self._locks[index]:
if not self._ready[index]:
await self._initialize_member(index)
async def initialize(self) -> None:
"""Initialize every member, tolerating members that are down.
Members initialize concurrently so one unreachable member cannot eat the
startup budget the others need. Failures are logged and retried on use.
"""
results = await asyncio.gather(
*(self._ensure_member_ready(i) for i in range(len(self._members))),
return_exceptions=True,
)
for index, result in enumerate(results):
if isinstance(result, BaseException):
logger.warning(
"Reranker member %d (%s) failed to initialize: %s; it will be retried on use",
index,
self._members[index].provider_name,
result,
)
if not any(self._ready):
logger.error("Reranker: no member of the failover chain initialized; recall will retry them per request")
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Score ``pairs`` with the first member that answers usably."""
last_exc: BaseException | None = None
for index, member in enumerate(self._members):
try:
if not self._ready[index]:
await self._ensure_member_ready(index)
scores = await member.predict(pairs)
if len(scores) != len(pairs):
raise RuntimeError(f"returned {len(scores)} scores for {len(pairs)} pairs")
except Exception as e: # noqa: BLE001 - re-raised below if no member answers
last_exc = e
remaining = len(self._members) - index - 1
logger.warning(
"Reranker member %d (%s) failed: %s%s",
index,
member.provider_name,
e,
f"; trying next member ({remaining} left)" if remaining else "; no members left",
)
continue
if index != self._active:
logger.info(
"Reranker: now serving from member %d (%s)",
index,
member.provider_name,
)
self._active = index
return scores
# All members failed; surface the last error (loop ran at least once).
assert last_exc is not None
raise last_exc
def create_cross_encoder(member: RerankerMemberConfig) -> CrossEncoderModel:
"""
Create a CrossEncoderModel for one member of the reranker chain.
``member`` is the primary (index 0, the unindexed ``HINDSIGHT_API_RERANKER_*``
config) or an indexed fallback. Missing-setting errors name the member's own
env var, so a chain misconfiguration points at the exact indexed variable.
Args:
member: Resolved settings for this member
Returns:
Configured CrossEncoderModel instance
"""
from ..config import get_config
config = get_config()
provider = config.reranker_provider.lower()
provider = member.provider.lower()
if provider == "tei":
url = config.reranker_tei_url
url = member.tei_url
if not url:
raise ValueError(f"{ENV_RERANKER_TEI_URL} is required when {ENV_RERANKER_PROVIDER} is 'tei'")
raise ValueError(f"{member.env_name('TEI_URL')} is required when {member.env_name('PROVIDER')} is 'tei'")
return RemoteTEICrossEncoder(
base_url=url,
timeout=config.reranker_tei_http_timeout,
batch_size=config.reranker_tei_batch_size,
max_concurrent=config.reranker_tei_max_concurrent,
timeout=member.tei_http_timeout,
batch_size=member.tei_batch_size,
max_concurrent=member.tei_max_concurrent,
)
elif provider == "local":
return LocalSTCrossEncoder(
model_name=config.reranker_local_model,
max_concurrent=config.reranker_local_max_concurrent,
force_cpu=config.reranker_local_force_cpu,
trust_remote_code=config.reranker_local_trust_remote_code,
fp16=config.reranker_local_fp16,
bucket_batching=config.reranker_local_bucket_batching,
batch_size=config.reranker_local_batch_size,
allow_mps=config.reranker_local_allow_mps,
model_name=member.local_model,
max_concurrent=member.local_max_concurrent,
force_cpu=member.local_force_cpu,
trust_remote_code=member.local_trust_remote_code,
fp16=member.local_fp16,
bucket_batching=member.local_bucket_batching,
batch_size=member.local_batch_size,
allow_mps=member.local_allow_mps,
)
elif provider == "cohere":
api_key = config.reranker_cohere_api_key
if not api_key:
raise ValueError(f"{ENV_RERANKER_COHERE_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'cohere'")
return CohereCrossEncoder(
api_key=api_key,
model=config.reranker_cohere_model,
base_url=config.reranker_cohere_base_url,
timeout=config.reranker_cohere_timeout,
)
elif provider == "openrouter":
api_key = config.reranker_openrouter_api_key
api_key = member.cohere_api_key
if not api_key:
raise ValueError(
"HINDSIGHT_API_RERANKER_OPENROUTER_API_KEY, HINDSIGHT_API_OPENROUTER_API_KEY, "
f"or HINDSIGHT_API_LLM_API_KEY is required when {ENV_RERANKER_PROVIDER} is 'openrouter'"
f"{member.env_name('COHERE_API_KEY')} is required when {member.env_name('PROVIDER')} is 'cohere'"
)
return CohereCrossEncoder(
api_key=api_key,
model=config.reranker_openrouter_model,
base_url=config.reranker_openrouter_base_url,
timeout=config.reranker_openrouter_timeout,
model=member.cohere_model,
base_url=member.cohere_base_url,
timeout=member.cohere_timeout,
)
elif provider == "openrouter":
api_key = member.openrouter_api_key
if not api_key:
shared = ", HINDSIGHT_API_OPENROUTER_API_KEY, or HINDSIGHT_API_LLM_API_KEY" if member.index == 0 else ""
raise ValueError(
f"{member.env_name('OPENROUTER_API_KEY')}{shared} is required "
f"when {member.env_name('PROVIDER')} is 'openrouter'"
)
return CohereCrossEncoder(
api_key=api_key,
model=member.openrouter_model,
base_url=member.openrouter_base_url,
timeout=member.openrouter_timeout,
)
elif provider == "flashrank":
model = os.environ.get(ENV_RERANKER_FLASHRANK_MODEL, DEFAULT_RERANKER_FLASHRANK_MODEL)
cache_dir = os.environ.get(ENV_RERANKER_FLASHRANK_CACHE_DIR, DEFAULT_RERANKER_FLASHRANK_CACHE_DIR)
cpu_mem_arena = os.environ.get(
ENV_RERANKER_FLASHRANK_CPU_MEM_ARENA, str(DEFAULT_RERANKER_FLASHRANK_CPU_MEM_ARENA)
).lower() in ("true", "1", "yes")
return FlashRankCrossEncoder(model_name=model, cache_dir=cache_dir, cpu_mem_arena=cpu_mem_arena)
return FlashRankCrossEncoder(
model_name=member.flashrank_model,
cache_dir=member.flashrank_cache_dir,
cpu_mem_arena=member.flashrank_cpu_mem_arena,
batch_size=member.flashrank_batch_size,
)
elif provider == "litellm":
return LiteLLMCrossEncoder(
api_base=config.reranker_litellm_api_base,
api_key=config.reranker_litellm_api_key,
model=config.reranker_litellm_model,
max_tokens_per_doc=config.reranker_litellm_max_tokens_per_doc,
timeout=config.reranker_litellm_timeout,
api_base=member.litellm_api_base,
api_key=member.litellm_api_key,
model=member.litellm_model,
max_tokens_per_doc=member.litellm_max_tokens_per_doc,
timeout=member.litellm_timeout,
)
elif provider == "litellm-sdk":
return LiteLLMSDKCrossEncoder(
api_key=config.reranker_litellm_sdk_api_key or None,
model=config.reranker_litellm_sdk_model,
api_base=config.reranker_litellm_sdk_api_base,
max_tokens_per_doc=config.reranker_litellm_max_tokens_per_doc,
timeout=config.reranker_litellm_sdk_timeout,
api_key=member.litellm_sdk_api_key or None,
model=member.litellm_sdk_model,
api_base=member.litellm_sdk_api_base,
max_tokens_per_doc=member.litellm_max_tokens_per_doc,
timeout=member.litellm_sdk_timeout,
)
elif provider == "zeroentropy":
api_key = config.reranker_zeroentropy_api_key
api_key = member.zeroentropy_api_key
if not api_key:
raise ValueError(
f"{ENV_RERANKER_ZEROENTROPY_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'zeroentropy'"
f"{member.env_name('ZEROENTROPY_API_KEY')} is required "
f"when {member.env_name('PROVIDER')} is 'zeroentropy'"
)
return ZeroEntropyCrossEncoder(
api_key=api_key,
model=config.reranker_zeroentropy_model,
base_url=config.reranker_zeroentropy_base_url,
timeout=config.reranker_zeroentropy_timeout,
model=member.zeroentropy_model,
base_url=member.zeroentropy_base_url,
timeout=member.zeroentropy_timeout,
)
elif provider == "siliconflow":
api_key = config.reranker_siliconflow_api_key
api_key = member.siliconflow_api_key
if not api_key:
raise ValueError(
f"{ENV_RERANKER_SILICONFLOW_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'siliconflow'"
f"{member.env_name('SILICONFLOW_API_KEY')} is required "
f"when {member.env_name('PROVIDER')} is 'siliconflow'"
)
return SiliconFlowCrossEncoder(
api_key=api_key,
model=config.reranker_siliconflow_model,
base_url=config.reranker_siliconflow_base_url,
timeout=config.reranker_siliconflow_timeout,
model=member.siliconflow_model,
base_url=member.siliconflow_base_url,
timeout=member.siliconflow_timeout,
)
elif provider == "google":
project_id = config.reranker_google_project_id
project_id = member.google_project_id
if not project_id:
shared = " (or HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID)" if member.index == 0 else ""
raise ValueError(
f"{ENV_RERANKER_GOOGLE_PROJECT_ID} (or HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID) "
f"is required when {ENV_RERANKER_PROVIDER} is 'google'"
f"{member.env_name('GOOGLE_PROJECT_ID')}{shared} "
f"is required when {member.env_name('PROVIDER')} is 'google'"
)
return GoogleCrossEncoder(
project_id=project_id,
model=config.reranker_google_model,
service_account_key=config.reranker_google_service_account_key,
timeout=config.reranker_google_timeout,
model=member.google_model,
service_account_key=member.google_service_account_key,
timeout=member.google_timeout,
)
elif provider == "alibaba":
api_key = config.reranker_alibaba_api_key
api_key = member.alibaba_api_key
if not api_key:
raise ValueError(f"{ENV_RERANKER_ALIBABA_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'alibaba'")
raise ValueError(
f"{member.env_name('ALIBABA_API_KEY')} is required when {member.env_name('PROVIDER')} is 'alibaba'"
)
return AlibabaCloudCrossEncoder(
api_key=api_key,
model=config.reranker_alibaba_model,
timeout=config.reranker_alibaba_timeout,
model=member.alibaba_model,
timeout=member.alibaba_timeout,
)
elif provider == "rrf":
return RRFPassthroughCrossEncoder()
@@ -1703,3 +1853,23 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
raise ValueError(
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'zeroentropy', 'siliconflow', 'alibaba', 'google', 'flashrank', 'litellm', 'litellm-sdk', 'rrf', 'jina-mlx'"
)
def create_cross_encoder_from_env() -> CrossEncoderModel:
"""
Create the configured reranker, based on configuration.
Reads configuration via get_config() to ensure consistency across the codebase.
With no ``HINDSIGHT_API_RERANKER_<n>_*`` members configured (the default) this
is the single configured reranker; otherwise the chain is wrapped in a
:class:`MultiCrossEncoder` that fails over across members in order.
Returns:
Configured CrossEncoderModel instance
"""
from ..config import get_config
chain = get_config().reranker_chain()
if len(chain) == 1:
return create_cross_encoder(chain[0])
return MultiCrossEncoder([create_cross_encoder(member) for member in chain])
@@ -67,16 +67,28 @@ def create_database_backend(backend_type: str) -> DatabaseBackend:
return _get_backend_class(backend_type)()
_OPS_CACHE: dict[str, DataAccessOps] = {}
def create_data_access_ops(backend_type: str) -> DataAccessOps:
"""Factory: create a DataAccessOps by backend name.
"""Factory: the DataAccessOps for a backend name.
Returns a per-dialect SINGLETON: ``DataAccessOps`` is stateless (it only builds and runs SQL),
so one shared instance per dialect is correct — and it means the database backend and the
memories store hold the *same* ops object, so a test that patches a method on it (e.g.
``enqueue_graph_maintenance``) observes every caller regardless of which layer issued it.
Args:
backend_type: One of "postgresql" or "oracle".
Returns:
A DataAccessOps instance.
The shared DataAccessOps instance for that backend.
Raises:
ValueError: If backend_type is not recognized.
"""
return _get_ops_class(backend_type)()
ops = _OPS_CACHE.get(backend_type)
if ops is None:
ops = _get_ops_class(backend_type)()
_OPS_CACHE[backend_type] = ops
return ops
@@ -112,6 +112,23 @@ class DatabaseConnection(ABC):
"""
...
async def execute_rows_affected(self, query: str, *args: Any, timeout: float | None = None) -> int:
"""Execute a DML statement and return the number of rows it affected.
Normalizes the dialect-specific execute result into a plain int so callers
never hand-parse an ``"UPDATE <n>"`` / ``"DELETE <n>"`` command tag in
business logic (mirrors ``parse_json`` above, which normalizes the other
dialect-divergent result shape). asyncpg returns the tag directly; the
Oracle connection reshapes ``cursor.rowcount`` into the same trailing-count
form, so parsing the last token is dialect-safe. Returns 0 when the status
has no trailing count (e.g. a non-DML statement).
"""
status = await self.execute(query, *args, timeout=timeout)
if not isinstance(status, str):
return 0
parts = status.split()
return int(parts[-1]) if parts and parts[-1].isdigit() else 0
@abstractmethod
async def executemany(self, query: str, args: list[tuple[Any, ...]], *, timeout: float | None = None) -> None:
"""Execute a query for each set of arguments.
@@ -25,6 +25,121 @@ from .base import DatabaseConnection
from .result import ResultRow
def document_serialization_sql(table: str, alias: str) -> str:
"""SQL predicate keeping one document to a single in-flight retain.
A retain that targets exactly one document carries it in
``serialization_key``. Appending to a document is a read-modify-write over
its whole text, so two concurrent retains for one document can only produce
a lost update or a wasted extraction — never more throughput. This
predicate makes the queue reflect that: a candidate is claimable only when
no peer for the same document is already ``processing``, and only when it
is the oldest claimable pending peer for that document.
Ordering, not just exclusion, is the point. Appends are cumulative, so the
order they commit in is the order the document ends up in; claiming them by
``(created_at, operation_id)`` makes that the submission order. It also
stops a single claim batch from taking several peers at once, which
excluding busy documents alone would not prevent.
Rows with a NULL ``serialization_key`` — multi-document batches, and every
non-retain operation — are unaffected, and documents are independent of one
another, so this costs no parallelism across a busy bank: only the retains
that were racing each other for one document are put in a line.
A peer wedged in 'processing' holds its document until claim recovery
releases it, the same caveat ``graph_maintenance_bank_serialization_sql``
carries and the same general gap.
The candidate row is always 'pending' and the 'pending' branch is
strictly-older, so the subquery can never match the candidate itself. The
fragment carries no SQL comments on purpose — it is rewritten for Oracle by
regex (``db/oracle.py``).
Args:
table: Fully-qualified async_operations table.
alias: Alias of the outer candidate row in the calling query.
"""
return f"""
({alias}.serialization_key IS NULL OR NOT EXISTS (
SELECT 1 FROM {table} doc_peer
WHERE doc_peer.bank_id = {alias}.bank_id
AND doc_peer.serialization_key = {alias}.serialization_key
AND (
doc_peer.status = 'processing'
OR (doc_peer.status = 'pending'
AND doc_peer.task_payload IS NOT NULL
AND (doc_peer.next_retry_at IS NULL OR doc_peer.next_retry_at <= NOW())
AND (doc_peer.created_at < {alias}.created_at
OR (doc_peer.created_at = {alias}.created_at
AND doc_peer.operation_id < {alias}.operation_id)))
)
))
"""
def graph_maintenance_bank_serialization_sql(table: str, alias: str) -> str:
"""SQL predicate serialising ``graph_maintenance`` claims per bank (#3230).
Every graph_maintenance run for a bank is interchangeable — the payload
carries only ``bank_id``, and ``run_graph_maintenance_job`` drains that bank's
queues — so a second concurrent run for one bank adds no work. It is worse than
useless: ``claim_graph_maintenance_batch`` locks queue rows ``FOR UPDATE``
*without* ``SKIP LOCKED`` (it is written assuming a single runner per bank),
so the runs convoy on each other's row locks while each holds a worker slot.
Same guarantee ``consolidation`` already gets from its ``bank_id != ALL(busy)``
exclusion, and the same caveat: a row wedged in 'processing' holds its bank
until something releases it (``hindsight-admin recover``, or a restart with a
stable ``HINDSIGHT_API_WORKER_ID`` so ``recover_own_tasks`` matches it). That
is a general gap in claim recovery, not specific to graph_maintenance.
Two differences from the consolidation form, both forced by the shape of this
problem:
* It is a **predicate**, not a separate claim phase. Pulling graph_maintenance
into its own phase after the generic shared-pool query would drop it below
every other operation type: it has no reserved-slot floor
(``WORKER_SLOT_TYPE_DEFAULTS`` gives consolidation 2 and graph_maintenance
0), and the poller's fairness pass calls ``claim_tasks`` with
``shared_limit=1``, so a single pending retain would starve it indefinitely.
As a predicate it keeps competing by ``created_at``.
* It also suppresses every same-bank row but the oldest **within one batch**.
Excluding busy banks alone does not: with several pending rows and nothing
yet processing, one batch claims them all — the convoy, unchanged. Several
pending rows per bank are reachable through the recovery paths
(``_reclaim_own_processing_tasks`` resets *all* of a worker's processing
rows in one statement, from ``recover_own_tasks`` at startup and
``release_own_tasks`` at shutdown, plus ``_schedule_retry`` /
``_defer_operation`` / ``hindsight-admin recover``).
The candidate row is always 'pending' and the 'pending' branch is
strictly-older, so the subquery can never match the candidate itself. The
fragment carries no SQL comments on purpose — it is rewritten for Oracle by
regex (``db/oracle.py``).
Args:
table: Fully-qualified async_operations table.
alias: Alias of the outer candidate row in the calling query.
"""
return f"""
({alias}.operation_type <> 'graph_maintenance' OR NOT EXISTS (
SELECT 1 FROM {table} gm_peer
WHERE gm_peer.bank_id = {alias}.bank_id
AND gm_peer.operation_type = 'graph_maintenance'
AND (
gm_peer.status = 'processing'
OR (gm_peer.status = 'pending'
AND gm_peer.task_payload IS NOT NULL
AND (gm_peer.next_retry_at IS NULL OR gm_peer.next_retry_at <= NOW())
AND (gm_peer.created_at < {alias}.created_at
OR (gm_peer.created_at = {alias}.created_at
AND gm_peer.operation_id < {alias}.operation_id)))
)
))
"""
@dataclass
class TagListingParts:
"""Backend-specific SQL fragments for the tag listing query."""
@@ -35,6 +150,57 @@ class TagListingParts:
bank_prefix: str
@dataclass(frozen=True)
class UpdatedWindow:
"""Recall's ``created_after``/``created_before`` bounds, as SQL for graph expansion.
Recall applies the window to ``updated_at`` — a consolidation touch makes a
fact current again — so link expansion has to bound the same column its seed
query does. Filtering only the seeds is not enough: a single in-window seed
would otherwise drag its whole neighbourhood (shared entities, semantic kNN
links, causal links) into the results no matter how old those neighbours are.
``first_param_index`` is where the bounds land in the owning query's param
list, so each call site keeps the placeholder numbering next to the params it
binds. Rendering is per-alias because the same window is applied to several
correlation names within one query.
"""
after: datetime | None
before: datetime | None
first_param_index: int
def clause(self, alias: str) -> str:
"""``AND <alias>.updated_at > $n ...`` — empty when the window is unbounded."""
parts: list[str] = []
index = self.first_param_index
if self.after is not None:
parts.append(f" AND {alias}.updated_at > ${index}")
index += 1
if self.before is not None:
parts.append(f" AND {alias}.updated_at < ${index}")
return "".join(parts)
@property
def params(self) -> list[datetime]:
"""The bound values, in placeholder order. Append to the owning param list."""
return [bound for bound in (self.after, self.before) if bound is not None]
@dataclass(frozen=True)
class LinkExpansionRows:
"""The three link-expansion signals, kept apart until they are scored.
They cannot be concatenated at the SQL layer: each carries a different score
scale (shared-entity count, kNN weight, causal weight) and the caller applies
a different transformation to each before summing them.
"""
entity: list[ResultRow]
semantic: list[ResultRow]
causal: list[ResultRow]
class DataAccessOps(ABC):
"""Backend-specific multi-statement data access operations.
@@ -150,9 +316,14 @@ class DataAccessOps(ABC):
bank_id: str,
entity_names: list[str],
entity_dates: list,
entity_kinds: list[str],
) -> dict[str, str]:
"""Bulk insert entities with ON CONFLICT DO NOTHING, returning id-by-lowercase-name.
``entity_kinds`` ("regular"/"label", parallel to ``entity_names``) is
stored on the row so label entities stay out of the partial trigram
index (#3208).
PG uses INSERT ... SELECT FROM unnest() with RETURNING.
Non-PG inserts row-by-row then SELECTs.
"""
@@ -181,6 +352,7 @@ class DataAccessOps(ABC):
bank_id: str,
entity_ids: list[str],
canonical_names: list[str],
entity_kinds: list[str],
) -> None:
"""Lock resolved parents and re-create any pruned since Phase-1 resolution.
@@ -250,12 +422,16 @@ class DataAccessOps(ABC):
mu_table: str,
ue_table: str,
per_entity_limit: int,
window: UpdatedWindow,
) -> str:
"""Build entity expansion CTE for link expansion retrieval.
PG uses DISTINCT ON with CROSS JOIN LATERAL and GROUP BY.
Non-PG splits into entity_scores subquery then JOINs for full columns
(can't GROUP BY CLOB).
``window`` narrows candidates *before* the per-entity cap, so out-of-window
neighbours don't consume an entity's bounded fan-out.
"""
...
@@ -264,6 +440,7 @@ class DataAccessOps(ABC):
self,
ml_table: str,
mu_table: str,
window: UpdatedWindow,
) -> str:
"""Build semantic + causal expansion CTEs.
@@ -282,7 +459,8 @@ class DataAccessOps(ABC):
seed_ids: list,
budget: int,
per_entity_limit: int,
) -> tuple[list[ResultRow], list[ResultRow], list[ResultRow]]:
window: UpdatedWindow,
) -> LinkExpansionRows:
"""Observation-specific graph expansion.
PG uses native array ops (source_memory_ids column) for performance.
@@ -468,6 +646,43 @@ class DataAccessOps(ABC):
"""
...
@abstractmethod
async def enqueue_entity_maintenance(
self,
conn: DatabaseConnection,
table: str,
ue_table: str,
bank_id: str,
unit_ids: list,
) -> int:
"""Enqueue the entities referenced by ``unit_ids`` as prune candidates.
Reads the entity ids out of ``unit_entities`` and inserts them into
entity_maintenance_queue, deduplicating on the (bank_id, entity_id)
primary key. Returns the number of rows the insert added.
Must run inside the triggering transaction and BEFORE the rows go —
once the unit_entities rows are deleted (or cascaded away) there is
nothing left to read the entity ids from.
"""
...
@abstractmethod
async def claim_entity_maintenance_batch(
self,
conn: DatabaseConnection,
table: str,
bank_id: str,
limit: int,
) -> list:
"""Atomically claim a batch of rows from entity_maintenance_queue and
remove them from the table.
Returns the claimed entity ids. Empty list when the queue for
``bank_id`` is drained.
"""
...
@abstractmethod
async def prune_orphan_entities(
self,
@@ -475,9 +690,10 @@ class DataAccessOps(ABC):
entities_table: str,
ue_table: str,
bank_id: str,
entity_ids: list,
) -> int:
"""Delete entities in ``bank_id`` that no longer have any unit_entities
rows referencing them. Returns the number of rows deleted.
"""Delete those of ``entity_ids`` in ``bank_id`` that no longer have any
unit_entities rows referencing them. Returns the number of rows deleted.
FK ON DELETE CASCADE on entity_cooccurrences then removes any
cooccurrence row pointing at the pruned entities.
@@ -490,11 +706,10 @@ class DataAccessOps(ABC):
conn: DatabaseConnection,
ec_table: str,
ue_table: str,
entities_table: str,
bank_id: str,
entity_ids: list,
) -> int:
"""Delete entity_cooccurrences rows in ``bank_id`` where the two
entities still exist but no current unit references both of them.
"""Delete entity_cooccurrences rows incident to ``entity_ids`` where the
two entities still exist but no current unit references both of them.
These are stale-count rows: cooccurrence was real at the time it was
recorded, but every memory_unit that witnessed both entities has
@@ -538,6 +753,12 @@ class DataAccessOps(ABC):
Oracle implementation uses two-step claims (query busy banks first, then
claim excluding them) to avoid ORA-02014.
Implementations must apply :func:`graph_maintenance_bank_serialization_sql`
to every query that can return a ``graph_maintenance`` row, so at most one
such row per bank is ever in flight, and :func:`document_serialization_sql`
to every query that can return a ``retain`` row, so at most one retain per
document is ever in flight.
Args:
consolidation_bank_priority: Per-bank priority for consolidation scheduling.
Maps bank name patterns to integer priorities (higher = claimed first).
@@ -546,8 +767,48 @@ class DataAccessOps(ABC):
When set, consolidation tasks are claimed in priority tiers.
None preserves current behavior (pure created_at ordering).
Returns claimed rows with operation_id, operation_type, task_payload, retry_count.
The caller is responsible for building ClaimedTask objects.
Returns claimed rows with operation_id, operation_type, task_payload,
retry_count, bank_id and serialization_key. The caller is responsible for
building ClaimedTask objects.
"""
...
@abstractmethod
async def fetch_foldable_retain_peers(
self,
conn: DatabaseConnection,
table: str,
bank_id: str,
serialization_key: str,
limit: int,
) -> list[ResultRow]:
"""Lock the pending retains queued behind a just-claimed one, in order.
Called inside the claim transaction, so the rows come back locked and
the caller can fold some of them into the claimed execution and leave
the rest pending simply by not marking them (their locks release with
the transaction).
``SKIP LOCKED`` matters here for liveness, not just speed: a peer some
other worker is already looking at must never stall this claim.
Returns rows with operation_id, task_payload and retry_count, ordered by
``(created_at, operation_id)`` — the order the fold planner requires.
"""
...
@abstractmethod
async def mark_operations_processing(
self,
conn: DatabaseConnection,
table: str,
worker_id: str,
operation_ids: list,
) -> None:
"""Claim the given pending operations for ``worker_id``.
Used to fold peers into an execution that has already been claimed;
runs in the same transaction that locked them.
"""
...
@@ -10,7 +10,14 @@ import uuid as uuid_mod
from datetime import UTC, datetime
from .base import DatabaseConnection
from .ops import DataAccessOps, TagListingParts
from .ops import (
DataAccessOps,
LinkExpansionRows,
TagListingParts,
UpdatedWindow,
document_serialization_sql,
graph_maintenance_bank_serialization_sql,
)
from .result import DictResultRow as ResultRow
ORACLE_IN_LIST_LIMIT = 1000
@@ -174,22 +181,24 @@ class OracleOps(DataAccessOps):
bank_id: str,
entity_names: list[str],
entity_dates: list,
entity_kinds: list[str],
) -> dict[str, str]:
# Row-by-row insert with duplicate suppression.
# Can't use RETURNING with ON CONFLICT DO NOTHING reliably,
# so INSERT (ignoring dups) then SELECT all IDs at the end.
id_by_name: dict[str, str] = {}
for name, event_date in zip(entity_names, entity_dates):
for name, event_date, kind in zip(entity_names, entity_dates, entity_kinds):
ts = event_date if event_date else datetime.now(UTC)
await conn.execute(
f"""
INSERT INTO {table} (bank_id, canonical_name, first_seen, last_seen, mention_count)
VALUES ($1, $2, $3, $3, 0)
INSERT INTO {table} (bank_id, canonical_name, first_seen, last_seen, mention_count, entity_kind)
VALUES ($1, $2, $3, $3, 0, $4)
ON CONFLICT (bank_id, LOWER(canonical_name)) DO NOTHING
""",
bank_id,
name,
ts,
kind,
)
# Now SELECT all the entities we just inserted (or that already existed)
for name in entity_names:
@@ -236,6 +245,7 @@ class OracleOps(DataAccessOps):
bank_id: str,
entity_ids: list[str],
canonical_names: list[str],
entity_kinds: list[str],
) -> None:
# Oracle has no FOR KEY SHARE; FOR UPDATE is the row-lock equivalent that
# blocks a concurrent prune DELETE until this transaction commits. Lock
@@ -250,11 +260,14 @@ class OracleOps(DataAccessOps):
)
await conn.executemany(
f"""
INSERT INTO {table} (id, bank_id, canonical_name)
VALUES ($1, $2, $3)
INSERT INTO {table} (id, bank_id, canonical_name, entity_kind)
VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING
""",
[(entity_id, bank_id, canonical_name) for entity_id, canonical_name in zip(entity_ids, canonical_names)],
[
(entity_id, bank_id, canonical_name, kind)
for entity_id, canonical_name, kind in zip(entity_ids, canonical_names, entity_kinds)
],
)
async def bulk_insert_unit_entities(
@@ -282,22 +295,29 @@ class OracleOps(DataAccessOps):
) -> None:
if not unit_ids:
return
# 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.
# Locking upsert (#3034), the Oracle analogue of the PG
# ``ON CONFLICT DO UPDATE``. The old IGNORE_ROW_ON_DUPKEY_INDEX insert
# skipped duplicates WITHOUT locking the existing row, so a mutation
# re-enqueueing an already-queued unit could not block a worker from
# concurrently claiming (deleting) that row and processing the unit's
# pre-mutation state — the re-enqueue signal was silently lost. MERGE
# WHEN MATCHED takes an exclusive row lock on the existing queue row
# (the SET is a deliberate no-op that preserves enqueued_at); WHEN NOT
# MATCHED inserts a fresh row. That serialises the mutation against the
# worker's claim for the same (bank_id, unit_id).
#
# 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.
# Sort to enforce a global (bank_id, unit_id) lock-acquisition order,
# matching claim_graph_maintenance_batch's delete order, so overlapping
# mutation/worker sets acquire the shared row locks ascending and cannot
# cycle.
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)
MERGE INTO {table} q
USING (SELECT $1 AS bank_id, $2 AS unit_id FROM dual) s
ON (q.bank_id = s.bank_id AND q.unit_id = s.unit_id)
WHEN MATCHED THEN UPDATE SET q.enqueued_at = q.enqueued_at
WHEN NOT MATCHED THEN INSERT (bank_id, unit_id) VALUES (s.bank_id, s.unit_id)
""",
[(bank_id, uid) for uid in sorted_unit_ids],
)
@@ -322,7 +342,15 @@ class OracleOps(DataAccessOps):
bank_id,
limit,
)
claimed = [str(row["unit_id"]) for row in rows]
# Ordered locking (#3034): the per-row DELETE takes the queue rows'
# exclusive locks in executemany array order. Sort the claimed keys by
# unit_id so those locks are acquired in the same (bank_id, unit_id)
# order the enqueue MERGE uses — overlapping mutation/worker sets then
# lock the shared rows ascending and cannot cycle. (The batch is still
# *chosen* oldest-first by enqueued_at above; only the lock/delete order
# is normalised.) The Pass 1 retry wrap in run_graph_maintenance_job is
# the ORA-00060 backstop for any residual interleaving.
claimed = sorted(str(row["unit_id"]) for row in rows)
if claimed:
await conn.executemany(
f"DELETE FROM {table} WHERE bank_id = $1 AND unit_id = $2",
@@ -330,13 +358,80 @@ class OracleOps(DataAccessOps):
)
return claimed
async def enqueue_entity_maintenance(
self,
conn: DatabaseConnection,
table: str,
ue_table: str,
bank_id: str,
unit_ids: list,
) -> int:
if not unit_ids:
return 0
rows = await conn.fetch(
f"SELECT DISTINCT entity_id FROM {ue_table} WHERE unit_id = ANY($1::uuid[])",
unit_ids,
)
# Sorted for the same reason as enqueue_graph_maintenance: the MERGE
# takes the (bank_id, entity_id) row locks in executemany array order,
# and claim_entity_maintenance_batch deletes in that same order, so
# overlapping mutation/worker sets cannot cycle.
candidates = sorted(str(row["entity_id"]) for row in rows)
if not candidates:
return 0
# MERGE is the Oracle analogue of ON CONFLICT DO UPDATE: WHEN MATCHED
# locks the existing queue row (the SET is a no-op preserving
# enqueued_at) so a re-enqueue serialises against a concurrent claim
# instead of being silently dropped (#3034).
await conn.executemany(
f"""
MERGE INTO {table} q
USING (SELECT $1 AS bank_id, $2 AS entity_id FROM dual) s
ON (q.bank_id = s.bank_id AND q.entity_id = s.entity_id)
WHEN MATCHED THEN UPDATE SET q.enqueued_at = q.enqueued_at
WHEN NOT MATCHED THEN INSERT (bank_id, entity_id) VALUES (s.bank_id, s.entity_id)
""",
[(bank_id, eid) for eid in candidates],
)
return len(candidates)
async def claim_entity_maintenance_batch(
self,
conn: DatabaseConnection,
table: str,
bank_id: str,
limit: int,
) -> list:
# Two-step claim, same as claim_graph_maintenance_batch: Oracle's
# DELETE ... RETURNING doesn't accept a multi-row subquery.
rows = await conn.fetch(
f"""
SELECT entity_id FROM {table}
WHERE bank_id = $1
ORDER BY enqueued_at
FETCH FIRST $2 ROWS ONLY
""",
bank_id,
limit,
)
claimed = sorted(str(row["entity_id"]) for row in rows)
if claimed:
await conn.executemany(
f"DELETE FROM {table} WHERE bank_id = $1 AND entity_id = $2",
[(bank_id, eid) for eid in claimed],
)
return claimed
async def prune_orphan_entities(
self,
conn: DatabaseConnection,
entities_table: str,
ue_table: str,
bank_id: str,
entity_ids: list,
) -> int:
if not entity_ids:
return 0
# The Oracle DatabaseConnection wrapper reshapes ``cursor.rowcount`` into
# the same ``"DELETE N"`` status string asyncpg returns, so the same
# ``int(deleted.split()[-1])`` parsing works on both dialects.
@@ -344,9 +439,11 @@ class OracleOps(DataAccessOps):
f"""
DELETE FROM {entities_table}
WHERE bank_id = $1
AND id = ANY($2::uuid[])
AND id NOT IN (SELECT DISTINCT entity_id FROM {ue_table})
""",
bank_id,
entity_ids,
)
return int(deleted.split()[-1]) if isinstance(deleted, str) and deleted.startswith("DELETE") else 0
@@ -355,26 +452,33 @@ class OracleOps(DataAccessOps):
conn: DatabaseConnection,
ec_table: str,
ue_table: str,
entities_table: str,
bank_id: str,
entity_ids: list,
) -> int:
if not entity_ids:
return 0
# NB: the Postgres path additionally selects victims FOR UPDATE in sorted
# (entity_id_1, entity_id_2) order to prevent the #2529 deadlock against
# retain's sorted cooccurrence upsert. Oracle's DELETE can't carry that
# ordered-lock CTE the same way, so here we rely on the Pass 2/3 retry
# wrap in run_graph_maintenance_job (retry_with_backoff is ORA-00060
# deadlock-aware) to recover instead. Deliberate dialect asymmetry.
#
# Scoped to the claimed candidates on either endpoint (#3222). The OR is
# safe to write directly here, unlike on Postgres: both endpoint columns
# are indexed and Oracle's optimizer expands an OR of two index-driven
# predicates into a concatenation, rather than the whole-table scan the
# PG planner picks (which is why the PG side spells it as a UNION).
deleted = await conn.execute(
f"""
DELETE FROM {ec_table}
WHERE entity_id_1 IN (SELECT id FROM {entities_table} WHERE bank_id = $1)
WHERE (entity_id_1 = ANY($1::uuid[]) OR entity_id_2 = ANY($1::uuid[]))
AND (entity_id_1, entity_id_2) NOT IN (
SELECT u1.entity_id, u2.entity_id
FROM {ue_table} u1
JOIN {ue_table} u2 ON u1.unit_id = u2.unit_id
)
""",
bank_id,
entity_ids,
)
return int(deleted.split()[-1]) if isinstance(deleted, str) and deleted.startswith("DELETE") else 0
@@ -465,6 +569,7 @@ class OracleOps(DataAccessOps):
mu_table: str,
ue_table: str,
per_entity_limit: int,
window: UpdatedWindow,
) -> str:
# Oracle: can't GROUP BY CLOB columns (text, context).
# Restructure: count entities per unit_id in a subquery, then join to get full columns.
@@ -483,12 +588,14 @@ class OracleOps(DataAccessOps):
WHERE ue_target.entity_id = se.entity_id
AND ue_target.unit_id != ALL($1::uuid[])
-- Filter before applying the cap: candidates from other fact
-- types must not consume this entity's bounded fan-out.
-- types, or outside the recall window, must not consume this
-- entity's bounded fan-out.
AND EXISTS (
SELECT 1
FROM {mu_table} mu_target
WHERE mu_target.id = ue_target.unit_id
AND mu_target.fact_type = $2
{window.clause("mu_target")}
)
ORDER BY ue_target.unit_id DESC
FETCH FIRST {per_entity_limit} ROWS ONLY
@@ -510,6 +617,7 @@ class OracleOps(DataAccessOps):
self,
ml_table: str,
mu_table: str,
window: UpdatedWindow,
) -> str:
# Non-PG: can't GROUP BY CLOB columns, no DISTINCT ON.
# Restructure semantic: compute max weight per id, then join for full columns.
@@ -524,6 +632,7 @@ class OracleOps(DataAccessOps):
AND ml.link_type = 'semantic'
AND mu.fact_type = $2
AND mu.id != ALL($1::uuid[])
{window.clause("mu")}
UNION ALL
SELECT mu.id, ml.weight
FROM {ml_table} ml
@@ -532,6 +641,7 @@ class OracleOps(DataAccessOps):
AND ml.link_type = 'semantic'
AND mu.fact_type = $2
AND mu.id != ALL($1::uuid[])
{window.clause("mu")}
) sem_raw
GROUP BY id
),
@@ -558,6 +668,7 @@ class OracleOps(DataAccessOps):
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type IN ('causes', 'caused_by', 'enables', 'prevents')
AND mu.fact_type = $2
{window.clause("mu")}
),
causal_expanded AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at,
@@ -576,7 +687,8 @@ class OracleOps(DataAccessOps):
seed_ids: list,
budget: int,
per_entity_limit: int,
) -> tuple[list[ResultRow], list[ResultRow], list[ResultRow]]:
window: UpdatedWindow,
) -> LinkExpansionRows:
import logging
logger = logging.getLogger(__name__)
@@ -630,11 +742,13 @@ class OracleOps(DataAccessOps):
WHERE os3.observation_id = mu.id
AND os3.source_id IN (SELECT source_id FROM connected_sources)
)
{window.clause("mu")}
ORDER BY score DESC
FETCH FIRST $2 ROWS ONLY
""",
seed_ids,
budget,
*window.params,
)
logger.debug(f"[LinkExpansion] observation graph (Oracle): found {len(entity_rows)} connected observations")
@@ -650,12 +764,14 @@ class OracleOps(DataAccessOps):
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type = 'semantic' AND mu.fact_type = 'observation'
AND mu.id != ALL($1::uuid[])
{window.clause("mu")}
UNION ALL
SELECT mu.id, ml.weight
FROM {ml_table} ml JOIN {mu_table} mu ON mu.id = ml.from_unit_id
WHERE ml.to_unit_id = ANY($1::uuid[])
AND ml.link_type = 'semantic' AND mu.fact_type = 'observation'
AND mu.id != ALL($1::uuid[])
{window.clause("mu")}
) sem_raw
GROUP BY id
),
@@ -681,6 +797,7 @@ class OracleOps(DataAccessOps):
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type IN ('causes', 'caused_by', 'enables', 'prevents')
AND mu.fact_type = 'observation'
{window.clause("mu")}
),
causal_expanded AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at,
@@ -695,11 +812,12 @@ class OracleOps(DataAccessOps):
""",
seed_ids,
budget,
*window.params,
)
semantic_rows = [r for r in sem_causal_rows if r["source"] == "semantic"]
causal_rows = [r for r in sem_causal_rows if r["source"] == "causal"]
return list(entity_rows), semantic_rows, causal_rows
return LinkExpansionRows(entity=list(entity_rows), semantic=semantic_rows, causal=causal_rows)
def build_tag_listing_parts(self, mu_table: str) -> TagListingParts:
return TagListingParts(
@@ -1103,7 +1221,7 @@ class OracleOps(DataAccessOps):
if exclude_ids:
return await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
SELECT operation_id, operation_type, task_payload, retry_count, serialization_key, bank_id
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
@@ -1122,7 +1240,7 @@ class OracleOps(DataAccessOps):
else:
return await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
SELECT operation_id, operation_type, task_payload, retry_count, serialization_key, bank_id
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
@@ -1140,7 +1258,7 @@ class OracleOps(DataAccessOps):
if exclude_ids:
return await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
SELECT operation_id, operation_type, task_payload, retry_count, serialization_key, bank_id
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
@@ -1157,7 +1275,7 @@ class OracleOps(DataAccessOps):
else:
return await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
SELECT operation_id, operation_type, task_payload, retry_count, serialization_key, bank_id
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
@@ -1198,7 +1316,7 @@ class OracleOps(DataAccessOps):
extra = " AND ".join(conditions)
return await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
SELECT operation_id, operation_type, task_payload, retry_count, serialization_key, bank_id
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
@@ -1245,7 +1363,7 @@ class OracleOps(DataAccessOps):
extra_clause = (" AND " + " AND ".join(conditions)) if conditions else ""
return await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
SELECT operation_id, operation_type, task_payload, retry_count, serialization_key, bank_id
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
@@ -1297,13 +1415,15 @@ class OracleOps(DataAccessOps):
else:
rows = await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type = $1
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY created_at
SELECT o.operation_id, o.operation_type, o.task_payload, o.retry_count, o.serialization_key, o.bank_id
FROM {table} o
WHERE o.status = 'pending'
AND o.task_payload IS NOT NULL
AND o.operation_type = $1
AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW())
AND {graph_maintenance_bank_serialization_sql(table, "o")}
AND {document_serialization_sql(table, "o")}
ORDER BY o.created_at
LIMIT $2
FOR UPDATE SKIP LOCKED
""",
@@ -1318,18 +1438,22 @@ class OracleOps(DataAccessOps):
# --- Phase 2: claim from shared pool ---
remaining_shared = shared_limit
if remaining_shared > 0:
# 2a. Non-consolidation tasks
# 2a. Non-consolidation tasks. graph_maintenance stays in this
# created_at-ordered query — see graph_maintenance_bank_serialization_sql
# for why it is a predicate rather than a phase of its own.
if claimed_ids:
rows = await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type != 'consolidation'
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
AND operation_id != ALL($1::uuid[])
ORDER BY created_at
SELECT o.operation_id, o.operation_type, o.task_payload, o.retry_count, o.serialization_key, o.bank_id
FROM {table} o
WHERE o.status = 'pending'
AND o.task_payload IS NOT NULL
AND o.operation_type != 'consolidation'
AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW())
AND o.operation_id != ALL($1::uuid[])
AND {graph_maintenance_bank_serialization_sql(table, "o")}
AND {document_serialization_sql(table, "o")}
ORDER BY o.created_at
LIMIT $2
FOR UPDATE SKIP LOCKED
""",
@@ -1339,13 +1463,15 @@ class OracleOps(DataAccessOps):
else:
rows = await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type != 'consolidation'
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY created_at
SELECT o.operation_id, o.operation_type, o.task_payload, o.retry_count, o.serialization_key, o.bank_id
FROM {table} o
WHERE o.status = 'pending'
AND o.task_payload IS NOT NULL
AND o.operation_type != 'consolidation'
AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW())
AND {graph_maintenance_bank_serialization_sql(table, "o")}
AND {document_serialization_sql(table, "o")}
ORDER BY o.created_at
LIMIT $1
FOR UPDATE SKIP LOCKED
""",
@@ -1385,6 +1511,19 @@ class OracleOps(DataAccessOps):
# Mark all claimed rows as processing
operation_ids = [row["operation_id"] for row in all_rows]
await self.mark_operations_processing(conn, table, worker_id, operation_ids)
return all_rows
async def mark_operations_processing(
self,
conn: DatabaseConnection,
table: str,
worker_id: str,
operation_ids: list,
) -> None:
if not operation_ids:
return
await conn.execute(
f"""
UPDATE {table}
@@ -1395,4 +1534,34 @@ class OracleOps(DataAccessOps):
operation_ids,
)
return all_rows
async def fetch_foldable_retain_peers(
self,
conn: DatabaseConnection,
table: str,
bank_id: str,
serialization_key: str,
limit: int,
) -> list[ResultRow]:
if limit <= 0:
return []
# Same ``LIMIT $n ... FOR UPDATE SKIP LOCKED`` shape the claim queries
# above use, which the Oracle SQL translation layer rewrites into the
# row-limited form Oracle accepts (a bare one raises ORA-02014).
return await conn.fetch(
f"""
SELECT operation_id, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type = 'retain'
AND bank_id = $1
AND serialization_key = $2
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY created_at, operation_id
LIMIT $3
FOR UPDATE SKIP LOCKED
""",
bank_id,
serialization_key,
limit,
)
@@ -4,10 +4,18 @@ Uses unnest(), LATERAL, DISTINCT ON, and native array operations for
efficient batch operations.
"""
import asyncio
from datetime import datetime
from .base import DatabaseConnection
from .ops import DataAccessOps, TagListingParts
from .ops import (
DataAccessOps,
LinkExpansionRows,
TagListingParts,
UpdatedWindow,
document_serialization_sql,
graph_maintenance_bank_serialization_sql,
)
from .result import ResultRow
@@ -16,24 +24,34 @@ def pg_search_vector_expr(
*,
text_col: str = "text",
context_col: str = "context",
signals_col: str = "text_signals",
signals_col: str | None = "text_signals",
native_inline: bool = True,
) -> str | None:
"""SQL expression that builds ``search_vector`` for the configured PG text-search backend.
Single source of truth shared by the batch insert (over the ``input_data``
CTE columns) and the curation revert recompute (over a ``memory_units`` row),
so the two can never drift. Returns ``None`` for backends that leave
``search_vector`` unpopulated — pgroonga / pg_textsearch / pg_search index the
base text columns directly and keep only a dummy column, so there is nothing
to build.
Single source of truth shared by ``memory_units`` (the batch insert over the
``input_data`` CTE columns and the curation-revert recompute) and
``mental_models`` (the knowledge-page writes), so the per-backend tokenization
can never drift between the two tables. Returns ``None`` for backends that
leave ``search_vector`` unpopulated — pgroonga / pg_textsearch / pg_search
index the base text columns directly and keep only a dummy column, so there is
nothing to build.
The ``*_col`` arguments are the SQL for each text source (a column name or a
bind placeholder); pass ``signals_col=None`` for a two-column table like
``mental_models`` (name + content). Pass ``native_inline=False`` when the
table's native ``search_vector`` is a GENERATED column that populates itself
(``mental_models``) — writing it inline would fail; only vchord's plain
bm25vector column then needs an explicit value.
``text_search_extension_native_language`` is validated as a PG identifier in
``HindsightConfig.validate()``, so embedding it as a SQL literal is safe.
"""
combined = f"COALESCE({text_col}, '') || ' ' || COALESCE({context_col}, '') || ' ' || COALESCE({signals_col}, '')"
cols = [text_col, context_col] + ([signals_col] if signals_col is not None else [])
combined = " || ' ' || ".join(f"COALESCE({c}, '')" for c in cols)
if config.text_search_extension == "vchord":
return f"tokenize({combined}, 'llmlingua2')::bm25_catalog.bm25vector"
if config.text_search_extension == "native":
if config.text_search_extension == "native" and native_inline:
return f"to_tsvector('{config.text_search_extension_native_language}'::regconfig, {combined})"
return None
@@ -41,6 +59,23 @@ def pg_search_vector_expr(
class PostgreSQLOps(DataAccessOps):
"""PostgreSQL-specific data access operations using unnest and LATERAL."""
def __init__(self) -> None:
# Per-table serialization of per-bank vector-index DDL within this
# process. Concurrent index DDL on one relation deadlocks by design:
# DROP INDEX CONCURRENTLY holds ShareUpdateExclusive while it waits out
# every transaction whose snapshot could still see the index — including
# other sessions' index DDL queued on that same lock — so many banks
# deleted at once form a wait cycle Postgres resolves by killing one.
# A session advisory lock would serialize this across processes too, but
# advisory locks are banned here (poolers hand sessions around; see the
# Database Locking standard). In-process the asyncio lock removes the
# cycle outright; across processes the callers' retry-with-backoff
# absorbs the (now much rarer) collisions.
self._index_ddl_locks: dict[str, asyncio.Lock] = {}
def _index_ddl_lock(self, table: str) -> asyncio.Lock:
return self._index_ddl_locks.setdefault(table, asyncio.Lock())
@property
def uses_observation_sources_table(self) -> bool:
return False # PG uses native array ops on source_memory_ids
@@ -252,6 +287,7 @@ class PostgreSQLOps(DataAccessOps):
bank_id: str,
entity_names: list[str],
entity_dates: list,
entity_kinds: list[str],
) -> dict[str, str]:
# ORDER BY LOWER(name) so every concurrent batch inserts in the same order
# as the conflict target (bank_id, LOWER(canonical_name)). ON CONFLICT DO
@@ -263,9 +299,9 @@ class PostgreSQLOps(DataAccessOps):
# the database's own collation the single arbiter for all writers.
inserted_rows = await conn.fetch(
f"""
INSERT INTO {table} (bank_id, canonical_name, first_seen, last_seen, mention_count)
SELECT $1, name, COALESCE(event_date, now()), COALESCE(event_date, now()), 0
FROM unnest($2::text[], $3::timestamptz[]) AS t(name, event_date)
INSERT INTO {table} (bank_id, canonical_name, first_seen, last_seen, mention_count, entity_kind)
SELECT $1, name, COALESCE(event_date, now()), COALESCE(event_date, now()), 0, kind
FROM unnest($2::text[], $3::timestamptz[], $4::text[]) AS t(name, event_date, kind)
ORDER BY LOWER(name)
ON CONFLICT (bank_id, LOWER(canonical_name))
DO NOTHING
@@ -274,6 +310,7 @@ class PostgreSQLOps(DataAccessOps):
bank_id,
entity_names,
entity_dates,
entity_kinds,
)
return {row["name_lower"]: row["id"] for row in inserted_rows}
@@ -305,6 +342,7 @@ class PostgreSQLOps(DataAccessOps):
bank_id: str,
entity_ids: list[str],
canonical_names: list[str],
entity_kinds: list[str],
) -> None:
# One statement, one round-trip (same shape as bulk_insert_links):
# * the CTE takes FOR KEY SHARE on every parent that still exists,
@@ -323,15 +361,16 @@ class PostgreSQLOps(DataAccessOps):
ORDER BY id
FOR KEY SHARE
)
INSERT INTO {table} (id, bank_id, canonical_name)
SELECT t.entity_id, $1, t.canonical_name
FROM unnest($2::uuid[], $3::text[]) AS t(entity_id, canonical_name)
INSERT INTO {table} (id, bank_id, canonical_name, entity_kind)
SELECT t.entity_id, $1, t.canonical_name, t.entity_kind
FROM unnest($2::uuid[], $3::text[], $4::text[]) AS t(entity_id, canonical_name, entity_kind)
WHERE t.entity_id NOT IN (SELECT id FROM locked)
ON CONFLICT DO NOTHING
""",
bank_id,
entity_ids,
canonical_names,
entity_kinds,
)
async def bulk_insert_unit_entities(
@@ -369,11 +408,24 @@ class PostgreSQLOps(DataAccessOps):
# concurrent caller the same lock order, so conflicting inserts
# queue cleanly instead of cycling.
sorted_unit_ids = sorted(unit_ids)
# DO UPDATE (not DO NOTHING) on a duplicate enqueue — #3034. The SET is a
# deliberate no-op that preserves enqueued_at; its only purpose is to take
# the existing row's lock. DO NOTHING does NOT lock the conflicting row, so
# a mutation that re-enqueues an already-queued unit could not block a
# worker from concurrently claiming (deleting) that row and processing the
# unit's pre-mutation state; the re-enqueue signal was then silently lost
# and the unit's derived links stayed stale with an empty queue. Locking
# the row serialises the mutation against the worker's claim for that
# (bank_id, unit_id): the worker either waits for the committed post-mutation
# state, or (if it claimed first) this INSERT lands a fresh row after the
# worker's delete commits. Row locks are acquired in sorted unit_id order,
# matching claim_graph_maintenance_batch, so the two never cycle.
await conn.execute(
f"""
INSERT INTO {table} (bank_id, unit_id)
SELECT $1, v FROM unnest($2::uuid[]) AS t(v)
ON CONFLICT (bank_id, unit_id) DO NOTHING
ON CONFLICT (bank_id, unit_id)
DO UPDATE SET enqueued_at = {table}.enqueued_at
""",
bank_id,
sorted_unit_ids,
@@ -386,42 +438,161 @@ class PostgreSQLOps(DataAccessOps):
bank_id: str,
limit: int,
) -> list[str]:
# Ordered locking (#3034). Choose the oldest batch by enqueued_at, but
# acquire the row locks in (bank_id, unit_id) order — the same order the
# enqueue upsert takes them — so a foreground mutation re-enqueueing an
# overlapping unit set can never cycle against a worker draining it. The
# `chosen` CTE is MATERIALIZED so the enqueued_at pick is fenced from the
# locking clause; `FOR UPDATE OF q ... ORDER BY q.unit_id` then puts
# LockRows above the Sort, so locks are taken ascending by unit_id (same
# idiom as prune_stale_cooccurrences' #2529 ordered lock). A concurrent
# enqueue holding one of these rows blocks this claim until it commits, at
# which point the worker deletes and processes the committed state.
rows = await conn.fetch(
f"""
DELETE FROM {table}
WHERE (bank_id, unit_id) IN (
WITH chosen AS MATERIALIZED (
SELECT bank_id, unit_id FROM {table}
WHERE bank_id = $1
ORDER BY enqueued_at
LIMIT $2
),
locked AS (
SELECT q.bank_id, q.unit_id
FROM {table} q
JOIN chosen c ON c.bank_id = q.bank_id AND c.unit_id = q.unit_id
ORDER BY q.unit_id
FOR UPDATE OF q
)
RETURNING unit_id
DELETE FROM {table} q
USING locked l
WHERE q.bank_id = l.bank_id AND q.unit_id = l.unit_id
RETURNING q.unit_id
""",
bank_id,
limit,
)
return [str(row["unit_id"]) for row in rows]
async def enqueue_entity_maintenance(
self,
conn: DatabaseConnection,
table: str,
ue_table: str,
bank_id: str,
unit_ids: list,
) -> int:
# Read the candidates straight out of unit_entities rather than making
# callers pass entity ids: every caller runs this immediately before the
# rows go, and the join they'd have to write is this one.
#
# The inner ORDER BY is load-bearing, not cosmetic: it makes the INSERT
# take the (bank_id, entity_id) row locks ascending, the same order
# claim_entity_maintenance_batch takes them, so a mutation enqueueing an
# overlapping candidate set cannot cycle against a worker draining it.
# (Same protocol as enqueue_graph_maintenance, which sorts in Python
# because its ids arrive as a bind array.)
#
# DO UPDATE (not DO NOTHING) on a duplicate — #3034. The SET is a
# deliberate no-op preserving enqueued_at; its only purpose is to lock
# the conflicting row. DO NOTHING does not lock it, so a delete
# re-enqueueing an already-queued entity could not block a worker from
# claiming that row and evaluating the entity's pre-delete state — it
# would find the entity still referenced, keep it, and the re-enqueue
# signal would be lost, stranding the orphan until some later delete
# happened to name it again.
result = await conn.execute(
f"""
INSERT INTO {table} (bank_id, entity_id)
SELECT $1, s.entity_id
FROM (
SELECT DISTINCT ue.entity_id
FROM {ue_table} ue
WHERE ue.unit_id = ANY($2::uuid[])
ORDER BY 1
) s
ON CONFLICT (bank_id, entity_id)
DO UPDATE SET enqueued_at = {table}.enqueued_at
""",
bank_id,
unit_ids,
)
return int(result.split()[-1]) if isinstance(result, str) and result.startswith("INSERT") else 0
async def claim_entity_maintenance_batch(
self,
conn: DatabaseConnection,
table: str,
bank_id: str,
limit: int,
) -> list:
# Same claim shape as claim_graph_maintenance_batch: pick the oldest
# batch by enqueued_at, but acquire the row locks in (bank_id, entity_id)
# order — the order enqueue_entity_maintenance takes them — so a
# concurrent enqueue can never cycle against this claim. `chosen` is
# MATERIALIZED so the enqueued_at pick is fenced from the locking clause,
# and `FOR UPDATE OF q ... ORDER BY q.entity_id` puts LockRows above the
# Sort.
rows = await conn.fetch(
f"""
WITH chosen AS MATERIALIZED (
SELECT bank_id, entity_id FROM {table}
WHERE bank_id = $1
ORDER BY enqueued_at
LIMIT $2
),
locked AS (
SELECT q.bank_id, q.entity_id
FROM {table} q
JOIN chosen c ON c.bank_id = q.bank_id AND c.entity_id = q.entity_id
ORDER BY q.entity_id
FOR UPDATE OF q
)
DELETE FROM {table} q
USING locked l
WHERE q.bank_id = l.bank_id AND q.entity_id = l.entity_id
RETURNING q.entity_id
""",
bank_id,
limit,
)
return [row["entity_id"] for row in rows]
async def prune_orphan_entities(
self,
conn: DatabaseConnection,
entities_table: str,
ue_table: str,
bank_id: str,
entity_ids: list,
) -> int:
# Scoped by entities.bank_id (indexed). The NOT EXISTS subquery is
# backed by idx_ue_entity on unit_entities(entity_id), so this stays
# linear in the number of entities in the bank — not in the size of
# unit_entities globally.
# Scoped to the claimed candidates: primary-key lookups, with the
# NOT EXISTS backed by idx_unit_entities_entity_unit. Cost tracks the
# batch, not the bank (#3222) — the bank-wide form this replaces probed
# once per entity in the bank on every single run.
#
# Victims are locked in id order before the delete so the locks are
# acquired the same way retain's entity upsert takes them
# (bulk_upsert_entities locks `ORDER BY id FOR KEY SHARE`), which is what
# keeps a prune and a concurrent re-assert from cycling.
result = await conn.execute(
f"""
WITH victims AS (
SELECT e.id
FROM {entities_table} e
WHERE e.bank_id = $1
AND e.id = ANY($2::uuid[])
AND NOT EXISTS (
SELECT 1 FROM {ue_table} ue WHERE ue.entity_id = e.id
)
ORDER BY e.id
FOR UPDATE
)
DELETE FROM {entities_table} e
WHERE e.bank_id = $1
AND NOT EXISTS (
SELECT 1 FROM {ue_table} ue WHERE ue.entity_id = e.id
)
USING victims v
WHERE e.id = v.id
""",
bank_id,
entity_ids,
)
# asyncpg returns "DELETE N"
return int(result.split()[-1]) if isinstance(result, str) and result.startswith("DELETE") else 0
@@ -431,12 +602,18 @@ class PostgreSQLOps(DataAccessOps):
conn: DatabaseConnection,
ec_table: str,
ue_table: str,
entities_table: str,
bank_id: str,
entity_ids: list,
) -> int:
# Scope by joining through entities.bank_id (entity_cooccurrences itself
# has no bank_id column — entities don't span banks, so scoping via
# entity_id_1 is sufficient).
# Scoped to cooccurrence rows incident to the claimed candidates. The
# two arms are a UNION rather than
# `WHERE entity_id_1 = ANY(...) OR entity_id_2 = ANY(...)`: an OR across
# two columns of the same table cannot be driven from either index, so
# the planner would make entity_cooccurrences the outer relation and
# scan it whole — the #3387 shape. As a UNION each arm is an index scan
# (the PK for arm 1, idx_entity_cooccurrences_entity2 for arm 2).
#
# No bank predicate: entities don't span banks and the candidates came
# off a bank-scoped queue, so both endpoints are already this bank's.
#
# Ordered locking (deadlock avoidance, #2529): retain's concurrent
# cooccurrence upsert (entity_resolver._flush_pending) locks rows in
@@ -452,26 +629,58 @@ class PostgreSQLOps(DataAccessOps):
# retry wrap in run_graph_maintenance_job stays as a backstop for the
# residual paths (FK cascade from prune_orphan_entities, Oracle).
#
# The staleness predicate is an INTERSECT of the two entities' unit sets
# rather than the equivalent `unit_entities u1 JOIN u2 ON u1.unit_id =
# u2.unit_id` self-join (#2473): both INTERSECT branches resolve as Index
# Only Scans on idx_unit_entities_entity_unit (entity_id, unit_id), so the
# per-pair cost is bounded by the two entities' degrees. The self-join let
# the planner pick an anti-join that rescanned a high-degree hub entity's
# membership set for every pair — 28-30min on a bank with a ~100K-membership
# hub, even when zero rows were stale. Don't "simplify" it back.
# Staleness is decided against a SET of currently-live pairs, not with a
# per-cooccurrence-row check (#3367). The old form ran a correlated
# `NOT EXISTS (… INTERSECT …)` per row, and each evaluation re-scanned a
# hub entity's full membership set — cost scaled as (rows judged) x (hub
# degree), 88-140s on a real bank with a ~22K-degree hub. #2473 had
# swapped an earlier hub-rescanning self-join to that INTERSECT, but only
# made each per-row check cheaper; it kept the per-row structure, so the
# product blew up again at scale.
#
# `live` groups unit_entities by unit (self-join on unit_id) to emit every
# co-occurring (e1<e2) pair in one materialised pass: its cost is driven
# by unit degree (entities per unit — small), never by entity degree, so a
# hub contributes only its per-unit membership rather than a rescan per
# edge. The victims anti-join then hashes against it. MATERIALIZED keeps
# the planner from inlining `live` back into a per-row correlated plan.
#
# `live` is seeded from the *candidates'* units rather than the whole
# bank's (#3222 composed with #3367): graph maintenance is queue-driven,
# so this only has to decide about pairs in `incident`, and every such
# pair has a candidate as at least one endpoint. Any unit still
# witnessing such a pair therefore references a candidate, and so is in
# the seeded set — the scoped build cannot miss a live pair. That keeps
# the whole statement proportional to the batch instead of re-deriving
# every pair in the bank on every run.
result = await conn.execute(
f"""
WITH victims AS (
WITH incident AS MATERIALIZED (
SELECT c.entity_id_1, c.entity_id_2
FROM {ec_table} c
JOIN {entities_table} e ON e.id = c.entity_id_1
WHERE e.bank_id = $1
AND NOT EXISTS (
SELECT unit_id FROM {ue_table} WHERE entity_id = c.entity_id_1
INTERSECT
SELECT unit_id FROM {ue_table} WHERE entity_id = c.entity_id_2
)
WHERE c.entity_id_1 = ANY($1::uuid[])
UNION
SELECT c.entity_id_1, c.entity_id_2
FROM {ec_table} c
WHERE c.entity_id_2 = ANY($1::uuid[])
),
live AS MATERIALIZED (
SELECT u1.entity_id AS e1, u2.entity_id AS e2
FROM {ue_table} seed
JOIN {ue_table} u1 ON u1.unit_id = seed.unit_id
JOIN {ue_table} u2 ON u2.unit_id = u1.unit_id
AND u2.entity_id > u1.entity_id
WHERE seed.entity_id = ANY($1::uuid[])
),
victims AS (
SELECT c.entity_id_1, c.entity_id_2
FROM incident i
JOIN {ec_table} c
ON c.entity_id_1 = i.entity_id_1 AND c.entity_id_2 = i.entity_id_2
WHERE NOT EXISTS (
SELECT 1 FROM live l
WHERE l.e1 = c.entity_id_1 AND l.e2 = c.entity_id_2
)
ORDER BY c.entity_id_1, c.entity_id_2
FOR UPDATE OF c
)
@@ -480,7 +689,7 @@ class PostgreSQLOps(DataAccessOps):
WHERE c.entity_id_1 = v.entity_id_1
AND c.entity_id_2 = v.entity_id_2
""",
bank_id,
entity_ids,
)
return int(result.split()[-1]) if isinstance(result, str) and result.startswith("DELETE") else 0
@@ -573,6 +782,7 @@ class PostgreSQLOps(DataAccessOps):
mu_table: str,
ue_table: str,
per_entity_limit: int,
window: UpdatedWindow,
) -> str:
return f"""
seed_entities AS (
@@ -593,12 +803,14 @@ class PostgreSQLOps(DataAccessOps):
WHERE ue_target.entity_id = se.entity_id
AND ue_target.unit_id != ALL($1::uuid[])
-- Filter before applying the cap: candidates from other fact
-- types must not consume this entity's bounded fan-out.
-- types, or outside the recall window, must not consume this
-- entity's bounded fan-out.
AND EXISTS (
SELECT 1
FROM {mu_table} mu_target
WHERE mu_target.id = ue_target.unit_id
AND mu_target.fact_type = $2
{window.clause("mu_target")}
)
ORDER BY ue_target.unit_id DESC
LIMIT {per_entity_limit}
@@ -613,6 +825,7 @@ class PostgreSQLOps(DataAccessOps):
self,
ml_table: str,
mu_table: str,
window: UpdatedWindow,
) -> str:
# Exact v0.5.6 query shape: GROUP BY + MAX(weight) for semantic,
# DISTINCT ON for causal.
@@ -636,6 +849,7 @@ class PostgreSQLOps(DataAccessOps):
AND ml.link_type = 'semantic'
AND mu.fact_type = $2
AND mu.id != ALL($1::uuid[])
{window.clause("mu")}
UNION ALL
SELECT
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
@@ -648,6 +862,7 @@ class PostgreSQLOps(DataAccessOps):
AND ml.link_type = 'semantic'
AND mu.fact_type = $2
AND mu.id != ALL($1::uuid[])
{window.clause("mu")}
) sem_raw
GROUP BY id, text, context, event_date, occurred_start,
occurred_end, mentioned_at,
@@ -667,6 +882,7 @@ class PostgreSQLOps(DataAccessOps):
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type IN ('causes', 'caused_by', 'enables', 'prevents')
AND mu.fact_type = $2
{window.clause("mu")}
ORDER BY mu.id, ml.weight DESC
LIMIT $3
)"""
@@ -680,8 +896,24 @@ class PostgreSQLOps(DataAccessOps):
seed_ids: list,
budget: int,
per_entity_limit: int,
) -> tuple[list[ResultRow], list[ResultRow], list[ResultRow]]:
window: UpdatedWindow,
) -> LinkExpansionRows:
# v0.5.6 array ops: unnest, &&, COUNT(DISTINCT) on source_memory_ids.
#
# The window bounds the observations that come *back*, not the source facts
# traversed to reach them: an observation is in the window when it was itself
# written or refreshed there, regardless of how old the facts underneath it are.
#
# The shared-source count is scored set-wise (`scored`), not per candidate row.
# It used to be a correlated subquery — COUNT(DISTINCT s) over
# unnest(mu.source_memory_ids) filtered by `= ANY(ca.source_ids)` — which
# re-scanned the connected-source array linearly for every element of every
# candidate's array. Because consolidation appends to source_memory_ids and
# never prunes it (issue #1725), that product grows with the bank's age: at
# 5k observations averaging 113 sources against ~3k connected sources it was
# ~1.7B element comparisons, 2.6s of one saturated backend (issue #3085).
# Unnesting once and hash-joining connected_sources makes the work linear in
# the number of source ids instead.
entity_rows = await conn.fetch(
f"""
@@ -712,22 +944,40 @@ class PostgreSQLOps(DataAccessOps):
),
connected_array AS (
SELECT array_agg(source_id) AS source_ids FROM connected_sources
),
candidates AS (
SELECT
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count,
mu.source_memory_ids
FROM {mu_table} mu, connected_array ca
WHERE mu.fact_type = 'observation'
AND mu.id != ALL($1::uuid[])
AND ca.source_ids IS NOT NULL
AND mu.source_memory_ids && ca.source_ids
{window.clause("mu")}
),
scored AS (
SELECT c.id, COUNT(DISTINCT cs.source_id)::float AS score
FROM candidates c
CROSS JOIN LATERAL unnest(c.source_memory_ids) AS s(source_id)
JOIN connected_sources cs ON cs.source_id = s.source_id
GROUP BY c.id
)
SELECT
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count,
(SELECT COUNT(DISTINCT s) FROM unnest(mu.source_memory_ids) s WHERE s = ANY(ca.source_ids))::float AS score
FROM {mu_table} mu, connected_array ca
WHERE mu.fact_type = 'observation'
AND mu.id != ALL($1::uuid[])
AND ca.source_ids IS NOT NULL
AND mu.source_memory_ids && ca.source_ids
ORDER BY score DESC
c.id, c.text, c.context, c.event_date, c.occurred_start,
c.occurred_end, c.mentioned_at,
c.fact_type, c.document_id, c.chunk_id, c.tags, c.proof_count,
sc.score
FROM candidates c
JOIN scored sc ON sc.id = c.id
ORDER BY sc.score DESC
LIMIT $2
""",
seed_ids,
budget,
*window.params,
)
# Exact v0.5.6 query shape: GROUP BY + MAX(weight) for semantic,
@@ -749,6 +999,7 @@ class PostgreSQLOps(DataAccessOps):
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type = 'semantic' AND mu.fact_type = 'observation'
AND mu.id != ALL($1::uuid[])
{window.clause("mu")}
UNION ALL
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id,
@@ -757,6 +1008,7 @@ class PostgreSQLOps(DataAccessOps):
WHERE ml.to_unit_id = ANY($1::uuid[])
AND ml.link_type = 'semantic' AND mu.fact_type = 'observation'
AND mu.id != ALL($1::uuid[])
{window.clause("mu")}
) sem_raw
GROUP BY id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, fact_type, document_id, chunk_id, tags, proof_count
@@ -771,6 +1023,7 @@ class PostgreSQLOps(DataAccessOps):
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type IN ('causes', 'caused_by', 'enables', 'prevents')
AND mu.fact_type = 'observation'
{window.clause("mu")}
ORDER BY mu.id, ml.weight DESC LIMIT $2
)
SELECT * FROM semantic_expanded
@@ -779,11 +1032,12 @@ class PostgreSQLOps(DataAccessOps):
""",
seed_ids,
budget,
*window.params,
)
semantic_rows = [r for r in sem_causal_rows if r["source"] == "semantic"]
causal_rows = [r for r in sem_causal_rows if r["source"] == "causal"]
return list(entity_rows), semantic_rows, causal_rows
return LinkExpansionRows(entity=list(entity_rows), semantic=semantic_rows, causal=causal_rows)
def build_tag_listing_parts(self, mu_table: str) -> TagListingParts:
return TagListingParts(
@@ -803,14 +1057,15 @@ class PostgreSQLOps(DataAccessOps):
fact_types: dict[str, str],
) -> None:
escaped = bank_id.replace("'", "''")
for ft, suffix in fact_types.items():
uid = str(internal_id).replace("-", "")[:16]
idx = f"idx_mu_emb_{suffix}_{uid}"
await conn.execute(
f"CREATE INDEX IF NOT EXISTS {idx} "
f"ON {table} {index_clause} "
f"WHERE fact_type = '{ft}' AND bank_id = '{escaped}'"
)
async with self._index_ddl_lock(table):
for ft, suffix in fact_types.items():
uid = str(internal_id).replace("-", "")[:16]
idx = f"idx_mu_emb_{suffix}_{uid}"
await conn.execute(
f"CREATE INDEX IF NOT EXISTS {idx} "
f"ON {table} {index_clause} "
f"WHERE fact_type = '{ft}' AND bank_id = '{escaped}'"
)
async def drop_bank_vector_indexes(
self,
@@ -825,10 +1080,13 @@ class PostgreSQLOps(DataAccessOps):
# table; CONCURRENTLY does not conflict with DML. The caller
# (delete_bank) runs this on an autocommit connection after its delete
# transaction has committed — CONCURRENTLY cannot run inside a tx.
for ft, suffix in fact_types.items():
uid = str(internal_id).replace("-", "")[:16]
idx = f"idx_mu_emb_{suffix}_{uid}"
await conn.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}.{idx}")
# The lock key must match create_bank_vector_indexes', whose `table`
# is the fq name this reconstructs from `schema`.
async with self._index_ddl_lock(f"{schema}.memory_units"):
for ft, suffix in fact_types.items():
uid = str(internal_id).replace("-", "")[:16]
idx = f"idx_mu_emb_{suffix}_{uid}"
await conn.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}.{idx}")
def get_entity_resolution_strategy(self) -> str:
return "trigram"
@@ -1142,7 +1400,7 @@ class PostgreSQLOps(DataAccessOps):
if exclude_ids:
return await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
SELECT operation_id, operation_type, task_payload, retry_count, serialization_key, bank_id
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
@@ -1161,7 +1419,7 @@ class PostgreSQLOps(DataAccessOps):
else:
return await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
SELECT operation_id, operation_type, task_payload, retry_count, serialization_key, bank_id
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
@@ -1179,7 +1437,7 @@ class PostgreSQLOps(DataAccessOps):
if exclude_ids:
return await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
SELECT operation_id, operation_type, task_payload, retry_count, serialization_key, bank_id
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
@@ -1196,7 +1454,7 @@ class PostgreSQLOps(DataAccessOps):
else:
return await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
SELECT operation_id, operation_type, task_payload, retry_count, serialization_key, bank_id
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
@@ -1237,7 +1495,7 @@ class PostgreSQLOps(DataAccessOps):
extra = " AND ".join(conditions)
return await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
SELECT operation_id, operation_type, task_payload, retry_count, serialization_key, bank_id
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
@@ -1284,7 +1542,7 @@ class PostgreSQLOps(DataAccessOps):
extra_clause = (" AND " + " AND ".join(conditions)) if conditions else ""
return await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
SELECT operation_id, operation_type, task_payload, retry_count, serialization_key, bank_id
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
@@ -1335,13 +1593,15 @@ class PostgreSQLOps(DataAccessOps):
else:
rows = await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type = $1
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY created_at
SELECT o.operation_id, o.operation_type, o.task_payload, o.retry_count, o.serialization_key, o.bank_id
FROM {table} o
WHERE o.status = 'pending'
AND o.task_payload IS NOT NULL
AND o.operation_type = $1
AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW())
AND {graph_maintenance_bank_serialization_sql(table, "o")}
AND {document_serialization_sql(table, "o")}
ORDER BY o.created_at
LIMIT $2
FOR UPDATE SKIP LOCKED
""",
@@ -1356,18 +1616,22 @@ class PostgreSQLOps(DataAccessOps):
# --- Phase 2: claim from shared pool ---
remaining_shared = shared_limit
if remaining_shared > 0:
# 2a. Non-consolidation tasks
# 2a. Non-consolidation tasks. graph_maintenance stays in this
# created_at-ordered query — see graph_maintenance_bank_serialization_sql
# for why it is a predicate rather than a phase of its own.
if claimed_ids:
rows = await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type != 'consolidation'
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
AND operation_id != ALL($1::uuid[])
ORDER BY created_at
SELECT o.operation_id, o.operation_type, o.task_payload, o.retry_count, o.serialization_key, o.bank_id
FROM {table} o
WHERE o.status = 'pending'
AND o.task_payload IS NOT NULL
AND o.operation_type != 'consolidation'
AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW())
AND o.operation_id != ALL($1::uuid[])
AND {graph_maintenance_bank_serialization_sql(table, "o")}
AND {document_serialization_sql(table, "o")}
ORDER BY o.created_at
LIMIT $2
FOR UPDATE SKIP LOCKED
""",
@@ -1377,13 +1641,15 @@ class PostgreSQLOps(DataAccessOps):
else:
rows = await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type != 'consolidation'
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY created_at
SELECT o.operation_id, o.operation_type, o.task_payload, o.retry_count, o.serialization_key, o.bank_id
FROM {table} o
WHERE o.status = 'pending'
AND o.task_payload IS NOT NULL
AND o.operation_type != 'consolidation'
AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW())
AND {graph_maintenance_bank_serialization_sql(table, "o")}
AND {document_serialization_sql(table, "o")}
ORDER BY o.created_at
LIMIT $1
FOR UPDATE SKIP LOCKED
""",
@@ -1423,6 +1689,19 @@ class PostgreSQLOps(DataAccessOps):
# Mark all claimed rows as processing
operation_ids = [row["operation_id"] for row in all_rows]
await self.mark_operations_processing(conn, table, worker_id, operation_ids)
return all_rows
async def mark_operations_processing(
self,
conn: DatabaseConnection,
table: str,
worker_id: str,
operation_ids: list,
) -> None:
if not operation_ids:
return
await conn.execute(
f"""
UPDATE {table}
@@ -1433,4 +1712,31 @@ class PostgreSQLOps(DataAccessOps):
operation_ids,
)
return all_rows
async def fetch_foldable_retain_peers(
self,
conn: DatabaseConnection,
table: str,
bank_id: str,
serialization_key: str,
limit: int,
) -> list[ResultRow]:
if limit <= 0:
return []
return await conn.fetch(
f"""
SELECT operation_id, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type = 'retain'
AND bank_id = $1
AND serialization_key = $2
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY created_at, operation_id
LIMIT $3
FOR UPDATE SKIP LOCKED
""",
bank_id,
serialization_key,
limit,
)
@@ -80,7 +80,9 @@ _LIKE_ANY_RE = re.compile(r"(\w+)\s+LIKE\s+ANY\s*\(\s*:(\d+)\s*\)", re.IGNORECAS
_NOT_LIKE_ALL_RE = re.compile(r"(\w+)\s+NOT\s+LIKE\s+ALL\s*\(\s*:(\d+)\s*\)", re.IGNORECASE)
_JSON_ARROW_TEXT_RE = re.compile(r'("?\w+"?)\s*->>\s*\'(\w+)\'') # handles both col and "col"
_JSON_HAS_KEY_RE = re.compile(r"(\w+)\s*\?\s*'(\w+)'")
# Reserved-word columns ("trigger") are already quoted by the time this runs, so the
# column group must accept the quoted form too — same shape as the arrow regex above.
_JSON_HAS_KEY_RE = re.compile(r"(\"?\w+\"?)\s*\?\s*'(\w+)'")
_JSONB_CONTAINS_RE = re.compile(r"(\w+)\s*@>\s*:(\d+)")
# ---------------------------------------------------------------------------
@@ -447,9 +449,6 @@ def _rewrite_pg_to_oracle(query: str) -> RewriteResult:
if has_for_update:
# FOR UPDATE path: use ROWNUM instead of FETCH FIRST.
# Extract and remove LIMIT clause, inject ROWNUM into WHERE.
def _limit_to_rownum(m):
return "" # Remove the LIMIT clause; we'll add ROWNUM below
limit_val = None
limit_match = re.search(r"\bLIMIT\s+(\d+|:\w+)\b", query, re.IGNORECASE)
if limit_match:
@@ -690,7 +689,6 @@ class OracleConnection(DatabaseConnection):
"max_tokens",
"priority",
"proof_count",
"access_count",
"importance_score",
"decay_factor",
"chunk_index",
@@ -8,9 +8,10 @@ avoiding Python-level wrapping overhead (~570K __getitem__ calls per
"""
import logging
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from typing import Any
from urllib.parse import parse_qs, urlparse
import asyncpg # noqa: F401
@@ -20,6 +21,42 @@ from .pool_instrumentation import PoolStats, instrument_acquire
logger = logging.getLogger(__name__)
async def apply_session_settings(conn: asyncpg.Connection, settings: list[tuple[str, str]]) -> None:
"""Apply session-scoped GUCs to ``conn`` in a single round trip.
Unless ``HINDSIGHT_API_DB_SESSION_SETUP_ON_ACQUIRE=false``, the pool passes
its init callback as ``setup=`` too, so this runs on *every* acquire, not
just on connection creation. Issued as N separate ``SET`` statements that
was N round trips and behind a transaction-mode pooler N server-side
transactions per acquire, which the worker's per-schema acquires
multiplied into a sustained commit-rate burn (#3499). One
``SELECT set_config(...)`` collapses them into one statement.
Some of the settings are extension-provided (``hnsw.ef_search``,
``pg_trgm.similarity_threshold``) and may not exist on the cluster; a single
statement fails as a whole, so on error fall back to applying them one by
one, skipping only the ones the server rejects.
"""
if not settings:
return
args: list[str] = [value for pair in settings for value in pair]
projection = ", ".join(f"set_config(${2 * i + 1}, ${2 * i + 2}, false)" for i in range(len(settings)))
try:
await conn.execute(f"SELECT {projection}", *args)
return
except asyncpg.exceptions.PostgresError:
# Narrow to PostgresError so genuine bugs in the pool/conn layer surface
# instead of being silently retried statement-by-statement.
logger.debug("Batched session setup failed — applying settings individually")
for name, value in settings:
try:
await conn.execute("SELECT set_config($1, $2, false)", name, value)
except asyncpg.exceptions.PostgresError:
logger.debug("Could not set %s — the server may not know this setting", name)
class PostgresConnection(DatabaseConnection):
"""DatabaseConnection wrapper around an asyncpg.Connection."""
@@ -64,6 +101,54 @@ class PostgresConnection(DatabaseConnection):
await self._conn.copy_records_to_table(table_name, records=records, columns=columns, timeout=timeout)
def application_name_from_dsn(dsn: str) -> str | None:
"""Extract the ``application_name`` query parameter from a PostgreSQL DSN.
asyncpg already forwards this to the server in the startup packet (it
passes unrecognized DSN query parameters through as ``server_settings``),
so a direct connection is labelled correctly in ``pg_stat_activity``.
The value is extracted here so it can be re-applied per acquire see
``_application_name_setup``.
"""
try:
values = parse_qs(urlparse(dsn).query).get("application_name")
except ValueError:
return None
if not values:
return None
# libpq semantics: the last occurrence of a repeated parameter wins.
return values[-1] or None
def _application_name_setup(app_name: str, init_callback: Any | None) -> Callable[[Any], Awaitable[None]]:
"""Wrap ``init_callback`` so every acquire re-asserts ``application_name``.
asyncpg runs ``RESET ALL`` when a connection is released back to the pool.
Connected straight to PostgreSQL that is harmless: ``RESET ALL`` restores
the value from the startup packet, which carried the DSN's name.
Behind a connection pooler (pgbouncer) it is not. The server connection's
startup packet is the *pooler's*, with no application_name; pgbouncer
applies the client's value with a ``SET`` when it links client to server.
``RESET ALL`` therefore resets it to empty, and pgbouncer which already
believes the value is applied does not re-issue it. Only the first
acquire on each server connection is attributed; every later one reports
an empty application_name, which is exactly the sort of gap that shows up
in production but never under psql.
Re-asserting it on every acquire fixes both topologies. ``set_config``
rather than ``SET`` because the name is operator-supplied and ``SET`` does
not accept bind parameters.
"""
async def _setup(conn: Any) -> None:
await conn.execute("SELECT set_config('application_name', $1, false)", app_name)
if init_callback is not None:
await init_callback(conn)
return _setup
class PostgreSQLBackend(DatabaseBackend):
"""DatabaseBackend implementation wrapping an asyncpg connection pool."""
@@ -93,7 +178,8 @@ class PostgreSQLBackend(DatabaseBackend):
) -> None:
from ...config import get_config
self._acquire_warn_threshold_s = get_config().db_acquire_warn_threshold_ms / 1000.0
config = get_config()
self._acquire_warn_threshold_s = config.db_acquire_warn_threshold_ms / 1000.0
# Kept for acquire() below: asyncpg's ``timeout`` create_pool kwarg is a
# *connect* kwarg (how long establishing a new connection may take), and
# ``Pool.acquire()`` defaults to waiting for a free connection forever.
@@ -101,6 +187,30 @@ class PostgreSQLBackend(DatabaseBackend):
# the wait it names: a pool-exhaustion stall never surfaced as an error,
# it just hung (#3002). 0 restores the unbounded behaviour.
self._acquire_timeout_s = acquire_timeout if acquire_timeout > 0 else None
# The DSN's application_name survives RESET ALL only on a direct
# connection; behind pgbouncer it has to be re-asserted per acquire
# (see _application_name_setup).
app_name = application_name_from_dsn(dsn)
pool_init = _application_name_setup(app_name, init_callback) if app_name else init_callback
# init runs once per new connection; setup runs on every acquire, after
# asyncpg's release-time RESET ALL. Re-running the session GUCs
# (hnsw.ef_search, statement_timeout, …) there is what keeps a *reused*
# connection from silently falling back to server defaults, so it is the
# default. Deployments that pin those GUCs server-side (ALTER ROLE /
# ALTER DATABASE ... SET) get them back from RESET ALL anyway, making the
# re-apply a wasted round trip on every acquire — and behind a
# transaction-mode pooler, a wasted transaction too (#3499); they can
# drop it with HINDSIGHT_API_DB_SESSION_SETUP_ON_ACQUIRE=false.
# application_name is NOT part of that trade-off: pgbouncer never
# re-issues it after RESET ALL, so it keeps its per-acquire hook either
# way (#3491).
setup_on_acquire = config.db_session_setup_on_acquire
if setup_on_acquire:
pool_setup = pool_init
else:
pool_setup = _application_name_setup(app_name, None) if app_name else None
self._pool = await asyncpg.create_pool(
dsn,
min_size=min_size,
@@ -108,16 +218,13 @@ class PostgreSQLBackend(DatabaseBackend):
command_timeout=command_timeout,
statement_cache_size=statement_cache_size,
timeout=acquire_timeout,
# init runs once per new connection; setup runs on every acquire,
# after asyncpg's release-time RESET ALL. Passing init_callback as
# both keeps the per-connection session GUCs (hnsw.ef_search, etc.)
# applied after a connection is reused, not just on first creation.
init=init_callback,
setup=init_callback,
init=pool_init,
setup=pool_setup,
)
logger.info(
f"PostgreSQL pool created (min={min_size}, max={max_size}, "
f"cmd_timeout={command_timeout}s, acquire_timeout={acquire_timeout}s)"
f"cmd_timeout={command_timeout}s, acquire_timeout={acquire_timeout}s, "
f"session_setup_on_acquire={setup_on_acquire})"
)
async def shutdown(self) -> None:
@@ -164,35 +164,6 @@ class BudgetedOperation:
"""
return BudgetedPool(pool, self)
async def acquire_many(
self,
pool: Any,
count: int,
) -> AsyncIterator[list[Any]]:
"""
Acquire multiple connections within the budget.
Note: This acquires connections sequentially to respect the budget.
For parallel acquisition, use multiple acquire() calls with asyncio.gather().
This method is intended for use with raw asyncpg pools only, not DatabaseBackend.
Args:
pool: asyncpg connection pool (raw pool only)
count: Number of connections to acquire
Yields:
List of database connections
"""
connections = []
try:
for _ in range(count):
conn = await pool.acquire()
connections.append(conn)
yield connections
finally:
for conn in connections:
await pool.release(conn)
# Global default manager instance
_default_manager: ConnectionBudgetManager | None = None
@@ -53,6 +53,7 @@ from .local_device import (
resolve_model_device_type,
select_local_device,
)
from .tei_retry import tei_retry_delay
logger = logging.getLogger(__name__)
@@ -81,25 +82,6 @@ class _ZeroEntropyEmbedResponse(BaseModel):
results: list[_ZeroEntropyEmbedResult]
def _truncate_to_tokens(text: str, max_tokens: int) -> tuple[str, int]:
"""Truncate ``text`` to at most ``max_tokens`` cl100k_base tokens.
tiktoken is an approximation of any given provider's tokenizer, so set
``max_tokens`` with a little headroom below the model's real limit.
Returns the (possibly truncated) text and the original token count (so the
caller can report how much was dropped); the count equals ``len(tokens)``
whether or not truncation occurred.
"""
from .token_encoding import get_token_encoding
enc = get_token_encoding()
tokens = enc.encode(text)
if len(tokens) <= max_tokens:
return text, len(tokens)
return enc.decode(tokens[:max_tokens]), len(tokens)
class Embeddings(ABC):
"""
Abstract base class for embedding generation.
@@ -259,11 +241,38 @@ class LocalSTEmbeddings(Embeddings):
Returns:
List of embedding vectors
"""
return self._encode_local(texts)
def encode_query(self, texts: list[str]) -> list[list[float]]:
return self._encode_local(texts, input_type="query")
def encode_documents(self, texts: list[str]) -> list[list[float]]:
return self._encode_local(texts, input_type="document")
def _encode_local(
self, texts: list[str], input_type: Literal["query", "document"] | None = None
) -> list[list[float]]:
if self._model is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
try:
embeddings = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
# Delegate to SentenceTransformers' own asymmetric entry points rather than
# prefixing here: they apply whatever prompts the model ships with (and route
# the task for models exposing a Router module), so asymmetric models such as
# Qwen3-Embedding get their configured query prompt without Hindsight carrying
# per-model prefix config the way the ONNX provider has to. Models that declare
# no prompts are unaffected — SentenceTransformers defaults them to empty
# strings and skips prompt handling entirely, so this is byte-identical to
# encode() for e.g. the default BAAI/bge-small-en-v1.5.
# encode_query/encode_document exist only in sentence-transformers >= 5.0,
# which is why local-ml pins that floor.
if input_type == "query":
encode = self._model.encode_query
elif input_type == "document":
encode = self._model.encode_document
else:
encode = self._model.encode
embeddings = encode(texts, convert_to_numpy=True, show_progress_bar=False)
return [emb.tolist() for emb in embeddings]
finally:
# Only reclaim the GPU allocator pool here, and only when actually on a
@@ -505,7 +514,7 @@ class RemoteTEIEmbeddings(Embeddings):
response = self._client.post(url, **kwargs)
response.raise_for_status()
return response
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as e:
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.WriteTimeout) as e:
last_error = e
if attempt < self.max_retries:
logger.warning(
@@ -514,13 +523,20 @@ class RemoteTEIEmbeddings(Embeddings):
time.sleep(delay)
delay *= 2 # Exponential backoff
except httpx.HTTPStatusError as e:
# Retry on 5xx server errors
if e.response.status_code >= 500 and attempt < self.max_retries:
# TEI uses 429 as normal overload backpressure. Retry it with
# the same bounded budget as transient server errors.
if (e.response.status_code == 429 or e.response.status_code >= 500) and attempt < self.max_retries:
last_error = e
logger.warning(
f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s..."
sleep_delay = tei_retry_delay(
e.response,
delay,
request_timeout=self.timeout,
)
time.sleep(delay)
logger.warning(
f"TEI transient error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. "
f"Retrying in {sleep_delay:.2f}s..."
)
time.sleep(sleep_delay)
delay *= 2
else:
raise
@@ -1229,7 +1245,6 @@ class LiteLLMSDKEmbeddings(Embeddings):
batch_size: int = 100,
timeout: float = 60.0,
encoding_format: str | None = "float",
max_input_tokens: int | None = None,
):
"""
Initialize LiteLLM SDK embeddings client.
@@ -1244,10 +1259,6 @@ class LiteLLMSDKEmbeddings(Embeddings):
timeout: Request timeout in seconds (default: 60.0)
encoding_format: Encoding format for embeddings (default: "float").
Set to None or empty string to omit (needed for Voyage AI, Gemini).
max_input_tokens: If set, truncate each input text to this many tokens
(tiktoken cl100k_base) before embedding. Needed for models with a
fixed input-token limit (e.g. Bedrock Titan V2's hard 8192 cap),
where an oversized text would otherwise fail permanently (#2501).
"""
self.api_key = api_key
self.model = model
@@ -1256,7 +1267,6 @@ class LiteLLMSDKEmbeddings(Embeddings):
self.batch_size = batch_size
self.timeout = timeout
self.encoding_format = encoding_format or None
self.max_input_tokens = max_input_tokens
self._litellm = None # Will be set during initialization
self._dimension: int | None = None
@@ -1333,33 +1343,6 @@ class LiteLLMSDKEmbeddings(Embeddings):
if not texts:
return []
# Truncate oversized inputs before hitting the provider. Models with a
# fixed input-token limit (e.g. Bedrock Titan V2, 8192) reject an
# oversized text with a permanent error rather than truncating it
# server-side, which strands the caller (e.g. a delta mental model whose
# content grew past the cap) with no recovery path. See #2501.
if self.max_input_tokens is not None:
truncated_texts = []
original_token_counts = []
for t in texts:
new_text, original_tokens = _truncate_to_tokens(t, self.max_input_tokens)
truncated_texts.append(new_text)
if original_tokens > self.max_input_tokens:
original_token_counts.append(original_tokens)
texts = truncated_texts
if original_token_counts:
logger.warning(
"Embeddings: truncated %d of %d input(s) to %d tokens for model %s "
"(largest was ~%d tokens); embedded content is incomplete. "
"This usually means a mental model's content has grown past the model's "
"input limit — see issue #2501.",
len(original_token_counts),
len(texts),
self.max_input_tokens,
self.model,
max(original_token_counts),
)
all_embeddings = []
# Process in batches
@@ -1752,7 +1735,6 @@ def create_embeddings_from_env() -> Embeddings:
api_base=config.embeddings_litellm_sdk_api_base,
output_dimensions=config.embeddings_litellm_sdk_output_dimensions,
encoding_format=config.embeddings_litellm_sdk_encoding_format,
max_input_tokens=config.embeddings_litellm_sdk_max_input_tokens,
)
elif provider == "google":
vertexai_project_id = config.embeddings_vertexai_project_id
@@ -6,8 +6,10 @@ to disambiguate entities across memory units.
"""
import asyncio
import heapq
import json
import logging
import re
from collections import defaultdict
from collections.abc import Iterator
from dataclasses import dataclass, field
@@ -38,6 +40,119 @@ class _EntityToCreate:
idx: int
name: str
event_date: datetime | None
# Label entities (from entity_labels config) are never fuzzy-merged in-batch — their
# canonical names are user-defined (e.g. "use:use-001") and must stay distinct (GH-1558).
# Also stored on the row as entities.entity_kind so label rows stay out of the
# partial trigram index (#3208).
is_label: bool = False
@dataclass
class _SimilarNamePair:
"""A pair of in-batch new-entity names judged similar enough to be the same entity."""
name_a: str
name_b: str
# The in-batch dedup pass is O(N^2) over the batch's *new* names. It is sub-millisecond for a
# normal retain (a handful of new entities) but scales quadratically — measured on the retain hot
# path: ~0.8ms at 100 names, ~5ms at 250, ~22ms at 500, ~81ms at 1000. So skip it past this many
# unique new names and log rather than silently degrade; the cap sits well above any realistic
# single-retain new-entity count while bounding the tail.
_INTRABATCH_MAX_NAMES = 250
# A pg_trgm "word" is a maximal run of alphanumerics (Unicode letters/digits, underscore excluded);
# everything else (space, punctuation, emoji) is a separator. This is why decoration variants like
# "Wren <emoji>" collapse to the same trigram set.
_TRGM_WORD = re.compile(r"[^\W_]+", re.UNICODE)
def _trigram_set(text: str) -> set[str]:
"""Trigrams of ``text`` the way PostgreSQL pg_trgm generates them: lowercase, split into words,
pad each word with two leading + one trailing blank, and take every 3-char window."""
trigrams: set[str] = set()
for word in _TRGM_WORD.findall(text.lower()):
padded = f" {word} "
for i in range(len(padded) - 2):
trigrams.add(padded[i : i + 3])
return trigrams
def _trigram_similarity(a: str, b: str) -> float:
"""pg_trgm ``similarity(a, b)`` computed in-memory — the Jaccard index of the trigram sets.
Verified byte-for-byte against Postgres pg_trgm across emoji / accent / CJK / hyphen /
apostrophe cases (issue #3107), so the merge cutoff calibrated on pg_trgm transfers exactly.
Doing it in Python keeps the in-batch dedup off the retain transaction's DB connection and makes
it backend-agnostic (Postgres, Oracle, and the pg_trgm-absent "full" fallback all behave alike).
"""
ta, tb = _trigram_set(a), _trigram_set(b)
intersection = len(ta & tb)
union = len(ta) + len(tb) - intersection
return intersection / union if union else 0.0
def _find_intrabatch_similar_pairs(names: list[str], threshold: float) -> list[_SimilarNamePair]:
"""Every pair of ``names`` whose in-memory trigram similarity meets ``threshold``. O(N^2) over a
small, capped set of new names pure CPU, no DB round-trip."""
trigrams = [_trigram_set(n) for n in names]
pairs: list[_SimilarNamePair] = []
for i in range(len(names)):
ti = trigrams[i]
for j in range(i + 1, len(names)):
tj = trigrams[j]
intersection = len(ti & tj)
union = len(ti) + len(tj) - intersection
if union and intersection / union >= threshold:
pairs.append(_SimilarNamePair(name_a=names[i], name_b=names[j]))
return pairs
def _cluster_new_entity_names(
rep_by_lower: dict[str, str],
count_by_lower: dict[str, int],
pairs: list[_SimilarNamePair],
) -> dict[str, str]:
"""Union-find the similar-name pairs into clusters and pick one canonical name each.
Args:
rep_by_lower: lowercase name -> a representative original-case spelling of it.
count_by_lower: lowercase name -> how many mentions carry it (for canonical choice).
pairs: name pairs judged similar (order/case irrelevant; compared lowercased).
Returns:
lowercase name -> canonical original-case name for its cluster. Singletons map to
themselves, so the caller can look up every member uniformly.
"""
parent: dict[str, str] = {nl: nl for nl in rep_by_lower}
def find(x: str) -> str:
while parent[x] != x:
parent[x] = parent[parent[x]] # path halving
x = parent[x]
return x
for pair in pairs:
a, b = pair.name_a.lower(), pair.name_b.lower()
if a in parent and b in parent:
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
clusters: dict[str, list[str]] = {}
for nl in rep_by_lower:
clusters.setdefault(find(nl), []).append(nl)
canonical_by_member: dict[str, str] = {}
for members in clusters.values():
# Canonical = most-mentioned, then shortest, then lexicographically smallest — a
# deterministic pick that prefers the plainest spelling in the cluster.
canonical_lower = min(members, key=lambda nl: (-count_by_lower[nl], len(rep_by_lower[nl]), rep_by_lower[nl]))
canonical_name = rep_by_lower[canonical_lower]
for nl in members:
canonical_by_member[nl] = canonical_name
return canonical_by_member
@dataclass
@@ -110,6 +225,32 @@ class _CooccurrencePair:
_nlp = None
# Candidates scored between cooperative yields to the event loop. Scoring is
# synchronous CPU (one SequenceMatcher per candidate, ~50µs), so a batch with a
# large candidate set would otherwise hold the loop thread for minutes — health
# probes time out and the orchestrator kills the worker mid-op (GH-3211).
# 256 candidates ≈ 13ms of work between yields.
_SCORING_YIELD_EVERY: Final = 256
def _cheap_rank_key(entity_text_lower: str, candidate: tuple[Any, str, Any, datetime | None, int | None]) -> tuple:
"""Ordering key (not a multi-value return) approximating match quality cheaply.
Used only to truncate oversized candidate sets: the fuzzy strategies already
cap and pre-rank in SQL by real similarity, so this is the backstop for sets
built without a score (the "full" strategy's substring matching). Ranks an
exact match first, then a close name length, then a well-established entity
all O(1) per candidate, unlike the SequenceMatcher pass it protects.
"""
name_lower = candidate[1].lower()
return (
0 if name_lower == entity_text_lower else 1,
abs(len(name_lower) - len(entity_text_lower)),
-(candidate[4] or 0),
candidate[1],
)
class EntityResolver:
"""
Resolves entities to canonical IDs with disambiguation.
@@ -120,6 +261,8 @@ class EntityResolver:
pool: Any,
entity_lookup: str = "full",
entity_resolution_batch_size: int = 100,
intrabatch_merge_similarity: float = 0.5,
entity_resolution_max_candidates: int = 200,
):
"""
Initialize entity resolver.
@@ -131,12 +274,22 @@ class EntityResolver:
similar candidates per entity name (much faster for large banks).
entity_resolution_batch_size: Number of unique entity names to include
in each pg_trgm candidate lookup query.
intrabatch_merge_similarity: pg_trgm similarity at/above which two new
names created by the same retain are merged into one entity.
entity_resolution_max_candidates: Max candidates scored per entity
mention. Scoring is a synchronous SequenceMatcher call per
candidate, so an unbounded candidate set turns one resolution
batch into minutes of event-loop-blocking CPU (GH-3211).
"""
self.pool = pool
self.entity_lookup = entity_lookup
if entity_resolution_batch_size < 1:
raise ValueError("entity_resolution_batch_size must be >= 1")
self.entity_resolution_batch_size = entity_resolution_batch_size
self._intrabatch_merge_similarity = intrabatch_merge_similarity
if entity_resolution_max_candidates < 1:
raise ValueError("entity_resolution_max_candidates must be >= 1")
self.entity_resolution_max_candidates = entity_resolution_max_candidates
self._pg_trgm_checked = False
# Backend-specific operations — accessed via pool.ops (Django pattern).
self._ops = pool.ops if pool is not None else None
@@ -240,6 +393,19 @@ class EntityResolver:
"""Split values into fixed-size batches."""
return [values[i : i + size] for i in range(0, len(values), size)]
@staticmethod
def _label_texts(entity_texts: list[str], taxonomy_lookup: set[str] | None, labels_cfg) -> set[str]:
"""Subset of entity_texts that are label entities (resolved by exact match only).
Only gate on the config, not on the lookup set: text/map groups have no
fixed vocabulary, so a config with only those groups builds an EMPTY
lookup its labels are classified by key prefix inside
``is_label_entity``, and gating on the set would miss them entirely.
"""
if not labels_cfg:
return set()
return {t for t in entity_texts if _is_label_entity(t, labels_cfg, taxonomy_lookup or set())}
async def resolve_entities_batch(
self,
bank_id: str,
@@ -424,40 +590,78 @@ class EntityResolver:
"""
entity_texts = list(set(e["text"] for e in entities_data))
# Fetch candidates for unique entity texts in bounded batches.
# Label entities resolve by exact match only (their canonical names are
# user-defined and must not be fuzzy-merged). Probing them via the trigram
# index only returns similar-but-distinct label values that are always
# discarded, and that wasted work grows with the number of values a label
# accumulates. Resolve label texts with an exact lookup on the unique
# (bank_id, LOWER(canonical_name)) index and only fuzzy-match the rest.
label_set = self._label_texts(entity_texts, taxonomy_lookup, labels_cfg)
label_texts = [t for t in entity_texts if t in label_set]
fuzzy_texts = [t for t in entity_texts if t not in label_set]
rows = []
# Exact, index-only lookup for label texts.
for entity_text_batch in self._chunked(label_texts, self.entity_resolution_batch_size):
rows.extend(
await conn.fetch(
f"""
SELECT e.id, e.canonical_name, e.metadata, e.last_seen, e.mention_count,
q.query_text
FROM unnest($2::text[]) AS q(query_text)
JOIN {fq_table("entities")} e ON (
e.bank_id = $1
AND LOWER(e.canonical_name) = LOWER(q.query_text)
)
""",
bank_id,
entity_text_batch,
)
)
# Fetch candidates for the remaining texts in bounded batches.
# Uses the GIN trigram index on LOWER(canonical_name) for case-insensitive
# similarity lookup. Previous version also had LIKE '%...' substring fallbacks,
# but those forced full sequential scans of the entities table and caused
# TimeoutErrors on banks with 10k+ entities. Lowering the similarity threshold
# to 0.15 (from default 0.3) catches most substring relationships while
# staying fully index-based.
await conn.execute("SET pg_trgm.similarity_threshold = 0.15")
try:
rows = []
for entity_text_batch in self._chunked(entity_texts, self.entity_resolution_batch_size):
rows.extend(
await conn.fetch(
f"""
SELECT DISTINCT ON (e.id)
e.id, e.canonical_name, e.metadata, e.last_seen, e.mention_count,
q.query_text
FROM unnest($2::text[]) AS q(query_text)
JOIN {fq_table("entities")} e ON (
e.bank_id = $1
AND LOWER(e.canonical_name) % LOWER(q.query_text)
)
""",
bank_id,
entity_text_batch,
)
# TimeoutErrors on banks with 10k+ entities. The pg_trgm similarity threshold
# that governs the `%` operator is applied once at pool-connection setup
# (HINDSIGHT_API_ENTITY_TRGM_SIMILARITY_THRESHOLD), so it is not toggled here.
# ``entity_kind != 'label'`` matches the predicate of the partial trigram
# index (label rows are exact-match-only, so they can never be a
# legitimate fuzzy result — without the filter they only inflate the
# candidate set and get discarded in the bitmap recheck, #3208). The
# clause must textually match the index predicate for the planner to
# choose the partial index, so it stays inside the LATERAL's WHERE
# alongside the `%` operator rather than moving out to the outer join.
#
# The LATERAL keeps only the best `max_candidates` per query text: on a bank
# with many near-identical names a single probe can otherwise return
# thousands of rows, and every one of them costs a SequenceMatcher call in
# _resolve_from_candidates (GH-3211). Ranking by pg_trgm similarity — which
# the index scan computes anyway — keeps the truncation at the noise end.
for entity_text_batch in self._chunked(fuzzy_texts, self.entity_resolution_batch_size):
rows.extend(
await conn.fetch(
f"""
SELECT c.id, c.canonical_name, c.metadata, c.last_seen, c.mention_count,
q.query_text
FROM unnest($2::text[]) AS q(query_text)
CROSS JOIN LATERAL (
SELECT e.id, e.canonical_name, e.metadata, e.last_seen, e.mention_count
FROM {fq_table("entities")} e
WHERE e.bank_id = $1
AND e.entity_kind != 'label'
AND LOWER(e.canonical_name) % LOWER(q.query_text)
ORDER BY similarity(LOWER(e.canonical_name), LOWER(q.query_text)) DESC, e.id
LIMIT $3
) c
""",
bank_id,
entity_text_batch,
self.entity_resolution_max_candidates,
)
finally:
# asyncpg returns connections to the pool with session state intact,
# so the lowered threshold would leak to future borrowers without RESET.
try:
await conn.execute("RESET pg_trgm.similarity_threshold")
except Exception:
logger.warning("Failed to reset pg_trgm similarity threshold after candidate lookup", exc_info=True)
)
# Group candidates by query_text
all_candidates: dict[str, list] = {t: [] for t in entity_texts}
@@ -530,6 +734,14 @@ class EntityResolver:
entity_texts = list(set(e["text"] for e in entities_data))
entities_table = fq_table("entities")
# Label entities resolve by exact match only, so the fuzzy Jaro-Winkler
# join only returns similar-but-distinct label values that are always
# discarded. Resolve label texts with an exact lookup on the unique
# (bank_id, LOWER(canonical_name)) index and only fuzzy-match the rest.
label_set = self._label_texts(entity_texts, taxonomy_lookup, labels_cfg)
label_texts = [t for t in entity_texts if t in label_set]
fuzzy_texts = [t for t in entity_texts if t not in label_set]
try:
# Batch entity texts into bounded sub-queries using JSON_TABLE to
# expand the list into rows. UTL_MATCH.JARO_WINKLER_SIMILARITY
@@ -537,7 +749,7 @@ class EntityResolver:
# Bounded batches mirror the PG trigram path so very wide retain
# batches don't time out a single JOIN on large banks.
rows = []
for entity_text_batch in self._chunked(entity_texts, self.entity_resolution_batch_size):
for entity_text_batch in self._chunked(label_texts, self.entity_resolution_batch_size):
rows.extend(
await conn.fetch(
f"""
@@ -546,13 +758,46 @@ class EntityResolver:
FROM JSON_TABLE($2, '$[*]' COLUMNS (query_text VARCHAR2(4000) PATH '$')) q
JOIN {entities_table} e ON (
e.bank_id = $1
AND UTL_MATCH.JARO_WINKLER_SIMILARITY(LOWER(e.canonical_name), LOWER(q.query_text)) > 70
AND LOWER(e.canonical_name) = LOWER(q.query_text)
)
""",
bank_id,
json.dumps(entity_text_batch),
)
)
# Only the best `max_candidates` per query text are returned: each
# candidate costs a synchronous SequenceMatcher call downstream, so an
# unbounded fuzzy match set blocks the event loop for minutes
# (GH-3211). Ranking by the same Jaro-Winkler score the join already
# computes keeps the truncation at the noise end.
for entity_text_batch in self._chunked(fuzzy_texts, self.entity_resolution_batch_size):
rows.extend(
await conn.fetch(
f"""
SELECT id, canonical_name, metadata, last_seen, mention_count, query_text
FROM (
SELECT e.id, e.canonical_name, e.metadata, e.last_seen, e.mention_count,
q.query_text,
ROW_NUMBER() OVER (
PARTITION BY q.query_text
ORDER BY UTL_MATCH.JARO_WINKLER_SIMILARITY(
LOWER(e.canonical_name), LOWER(q.query_text)
) DESC, e.id
) AS rn
FROM JSON_TABLE($2, '$[*]' COLUMNS (query_text VARCHAR2(4000) PATH '$')) q
JOIN {entities_table} e ON (
e.bank_id = $1
AND e.entity_kind != 'label'
AND UTL_MATCH.JARO_WINKLER_SIMILARITY(LOWER(e.canonical_name), LOWER(q.query_text)) > 70
)
)
WHERE rn <= $3
""",
bank_id,
json.dumps(entity_text_batch),
self.entity_resolution_max_candidates,
)
)
except Exception as e:
# UTL_MATCH may not be available (ORA-06550, ORA-00904, etc.)
# Catch broadly because Oracle error types vary depending on driver.
@@ -616,6 +861,38 @@ class EntityResolver:
labels_cfg,
)
def _intrabatch_canonical_map(self, entities_to_create: list[_EntityToCreate]) -> dict[str, str]:
"""Map each non-label new name (lowercased) to its cluster's canonical spelling.
Uses in-memory trigram similarity (``_trigram_similarity``, verified equal to Postgres
pg_trgm), so it is backend-agnostic no DB round-trip on the retain hot path, and it runs
identically on PostgreSQL, Oracle, and the pg_trgm-absent "full" fallback. Label entities
are excluded so distinct label values stay separate (GH-1558).
"""
rep_by_lower: dict[str, str] = {}
count_by_lower: dict[str, int] = {}
for e in entities_to_create:
if e.is_label:
continue
name_lower = e.name.lower()
rep_by_lower.setdefault(name_lower, e.name)
count_by_lower[name_lower] = count_by_lower.get(name_lower, 0) + 1
if len(rep_by_lower) < 2:
return {} # nothing to compare
if len(rep_by_lower) > _INTRABATCH_MAX_NAMES:
logger.warning(
"Skipping in-batch entity dedup: %d unique new names exceeds the %d cap "
"(O(N^2) trigram comparison); same-batch surface variants may not be merged.",
len(rep_by_lower),
_INTRABATCH_MAX_NAMES,
)
return {}
pairs = _find_intrabatch_similar_pairs(list(rep_by_lower.values()), self._intrabatch_merge_similarity)
if not pairs:
return {}
return _cluster_new_entity_names(rep_by_lower, count_by_lower, pairs)
async def _resolve_from_candidates(
self,
conn,
@@ -636,6 +913,9 @@ class EntityResolver:
resolved: list[ResolvedEntity | None] = [None] * len(entities_data)
entities_to_update: list[_EntityStat] = []
entities_to_create: list[_EntityToCreate] = []
# Candidates scored since the last yield, counted across mentions so a
# batch of many small candidate sets yields as often as one large set.
scored_since_yield = 0
for idx, entity_data in enumerate(entities_data):
entity_text = entity_data["text"]
@@ -645,17 +925,37 @@ class EntityResolver:
candidates = all_candidates.get(entity_text, [])
# Backstop truncation for candidate sets that were not capped at the
# source (the "full" strategy matches substrings in Python). The fuzzy
# strategies already return at most this many rows per query text, so
# this is normally a no-op.
if len(candidates) > self.entity_resolution_max_candidates:
logger.debug(
"Truncating %d candidates to %d for entity text %r",
len(candidates),
self.entity_resolution_max_candidates,
entity_text,
)
entity_text_lower_for_rank = entity_text.lower()
candidates = heapq.nsmallest(
self.entity_resolution_max_candidates,
candidates,
key=lambda c: _cheap_rank_key(entity_text_lower_for_rank, c),
)
# Label entities (from entity_labels config) use exact matching only.
# Their canonical names are user-defined (e.g., "use:use-001"),
# so fuzzy resolution must NOT merge distinct label values that
# happen to be textually similar (GH-1558).
is_label = bool(
labels_cfg and taxonomy_lookup and _is_label_entity(entity_text, labels_cfg, taxonomy_lookup)
)
# happen to be textually similar (GH-1558). Don't gate on the
# lookup set — it is empty for text/map-only configs, whose labels
# classify by key prefix (see _label_texts).
is_label = bool(labels_cfg and _is_label_entity(entity_text, labels_cfg, taxonomy_lookup or set()))
if not candidates:
# Will create new entity
entities_to_create.append(_EntityToCreate(idx=idx, name=entity_text, event_date=entity_event_date))
entities_to_create.append(
_EntityToCreate(idx=idx, name=entity_text, event_date=entity_event_date, is_label=is_label)
)
continue
if is_label:
@@ -664,7 +964,9 @@ class EntityResolver:
entity_text_lower = entity_text.lower()
for candidate_id, canonical_name, metadata, last_seen, mention_count in candidates:
if canonical_name.lower() == entity_text_lower:
exact_match = ResolvedEntity(entity_id=candidate_id, canonical_name=canonical_name)
exact_match = ResolvedEntity(
entity_id=candidate_id, canonical_name=canonical_name, entity_kind="label"
)
break
if exact_match:
resolved[idx] = exact_match
@@ -672,7 +974,9 @@ class EntityResolver:
_EntityStat(entity_id=exact_match.entity_id, event_date=entity_event_date)
)
else:
entities_to_create.append(_EntityToCreate(idx=idx, name=entity_text, event_date=entity_event_date))
entities_to_create.append(
_EntityToCreate(idx=idx, name=entity_text, event_date=entity_event_date, is_label=True)
)
continue
# Score candidates
@@ -682,6 +986,24 @@ class EntityResolver:
nearby_entity_set = {e["text"].lower() for e in nearby_entities if e["text"] != entity_text}
for candidate_id, canonical_name, metadata, last_seen, mention_count in candidates:
# Hand the loop back periodically so /health (and every other task
# on this worker) still gets scheduled while a wide batch scores.
# Counted before the label skip below, so a candidate list that is
# entirely labels still yields — the skip runs _is_label_entity per
# row, which is cheap but not free.
scored_since_yield += 1
if scored_since_yield >= _SCORING_YIELD_EVERY:
scored_since_yield = 0
await asyncio.sleep(0)
# A label row can never be a fuzzy-match target (#1558): the
# trigram/UTL_MATCH probes exclude them in SQL via entity_kind,
# but the "full" fallback strategy loads every bank entity, so
# a textually-close label value could still outscore the 0.6
# threshold here (e.g. "topic empathy" vs "topic:empathy").
if labels_cfg and _is_label_entity(canonical_name, labels_cfg, taxonomy_lookup or set()):
continue
score = 0.0
# 1. Name similarity (0-0.5)
@@ -719,7 +1041,7 @@ class EntityResolver:
entities_to_update.append(_EntityStat(entity_id=best_candidate.entity_id, event_date=entity_event_date))
else:
entities_to_create.append(
_EntityToCreate(idx=idx, name=entity_data["text"], event_date=entity_event_date)
_EntityToCreate(idx=idx, name=entity_data["text"], event_date=entity_event_date, is_label=is_label)
)
# Existing entities: IDs already known from the candidate SELECT above.
@@ -731,24 +1053,43 @@ class EntityResolver:
# ON CONFLICT DO NOTHING returns nothing for rows that conflicted; we handle
# that rare case with a fallback SELECT.
if entities_to_create:
# Group by lowercase name — deduplicate within the batch.
# Fuzzy-cluster the NON-label names about to be created so same-batch surface
# variants (case/emoji/suffix/typo of one name) collapse to a single entity. Without
# this, resolution only compares against already-persisted rows, so the first sighting
# of each variant in a batch always creates a distinct entity (issue #3107). Labels are
# excluded and keep exact grouping.
canonical_by_member = self._intrabatch_canonical_map(entities_to_create)
@dataclass
class _NameGroup:
name: str
event_date: datetime | None
is_label: bool
indices: list[int] = field(default_factory=list)
groups: dict[str, _NameGroup] = {}
for e in entities_to_create:
name_lower = e.name.lower()
if name_lower not in groups:
groups[name_lower] = _NameGroup(name=e.name, event_date=e.event_date)
groups[name_lower].indices.append(e.idx)
# Non-label variants fold into their cluster's canonical name; everything else
# (labels, singletons) keys on itself, preserving the prior exact-match behavior.
canonical = canonical_by_member.get(e.name.lower(), e.name)
key = canonical.lower()
group = groups.get(key)
if group is None:
# Labels key on themselves and the dedup pass only clusters
# non-label names, so the first member's is_label holds for
# every member of the group.
group = _NameGroup(name=canonical, event_date=e.event_date, is_label=e.is_label)
groups[key] = group
elif e.event_date is not None and (group.event_date is None or e.event_date < group.event_date):
# Keep the earliest event_date across the cluster ("first seen").
group.event_date = e.event_date
group.indices.append(e.idx)
# Sort by lowercase name for deterministic ordering.
sorted_groups = sorted(groups.items())
entity_names = [g.name for _, g in sorted_groups]
entity_dates = [g.event_date for _, g in sorted_groups]
entity_kinds = ["label" if g.is_label else "regular" for _, g in sorted_groups]
# Stored canonical name per lowercase key, so a resurrected parent
# keeps the name it was created/matched with rather than a fallback.
canonical_by_name = {name_lower: g.name for name_lower, g in sorted_groups}
@@ -764,6 +1105,7 @@ class EntityResolver:
bank_id,
entity_names,
entity_dates,
entity_kinds,
)
# Fallback SELECT for names that conflicted (another worker won the race).
@@ -802,8 +1144,11 @@ class EntityResolver:
entity_id = id_by_name.get(name_lower)
if entity_id:
canonical_name = canonical_by_name.get(name_lower, g.name)
kind = "label" if g.is_label else "regular"
for original_idx in g.indices:
resolved[original_idx] = ResolvedEntity(entity_id=entity_id, canonical_name=canonical_name)
resolved[original_idx] = ResolvedEntity(
entity_id=entity_id, canonical_name=canonical_name, entity_kind=kind
)
pending.append(_EntityStat(entity_id=str(entity_id), event_date=g.event_date))
# Accumulate into the resolver's pending list; the orchestrator flushes
@@ -858,12 +1203,14 @@ class EntityResolver:
bank_id,
[entity.entity_id for entity in unique],
[entity.canonical_name for entity in unique],
[entity.entity_kind for entity in unique],
)
async def link_units_to_entities_batch(
self,
unit_entity_pairs: list[tuple[str, str]] | list[tuple[str, str, datetime | None]],
conn=None,
bank_id: str | None = None,
):
"""
Link multiple memory units to entities in batch (MUCH faster than sequential).
@@ -891,22 +1238,63 @@ class EntityResolver:
if conn is None:
async with acquire_with_retry(self.pool) as conn:
return await self._link_units_to_entities_batch_impl(conn, normalized)
return await self._link_units_to_entities_batch_impl(conn, normalized, bank_id)
else:
return await self._link_units_to_entities_batch_impl(conn, normalized)
return await self._link_units_to_entities_batch_impl(conn, normalized, bank_id)
async def _link_units_to_entities_batch_impl(self, conn, unit_entity_pairs: list[tuple[str, str, datetime | None]]):
async def record_unit_entity_postings(
self,
unit_entity_pairs: list[tuple[str, str]] | list[tuple[str, str, datetime | None]],
bank_id: str | None = None,
txn=None,
):
"""Store-owned variant of :meth:`link_units_to_entities_batch` that touches NO
Postgres connection.
For a memories store that OWNS its memory rows (an external backend), the unitentity
posting is recorded by the store ``record_unit_entities`` ignores the ``conn`` and
the co-occurrence update only accumulates in memory for the post-transaction flush.
Neither needs a database transaction, so the retain orchestrator can run the posting in
its connection-free store phase and never hold the data-plane connection across the
object-store write. NOT for the Postgres store, whose posting is a real ``unit_entities``
INSERT that requires the connection.
``txn`` is the caller's write-group handle. For a store that keeps the posting on the
memory, this re-writes rows the same write-group just created, so it belongs to that
group see :meth:`MemoriesExtension.record_unit_entities`.
"""
if not unit_entity_pairs:
return
normalized: list[tuple[str, str, datetime | None]] = [
(t[0], t[1], t[2] if len(t) >= 3 else None) # type: ignore[misc]
for t in unit_entity_pairs
]
return await self._link_units_to_entities_batch_impl(None, normalized, bank_id, txn=txn)
async def _link_units_to_entities_batch_impl(
self, conn, unit_entity_pairs: list[tuple[str, str, datetime | None]], bank_id: str | None = None, txn=None
):
# Sorted bulk insert to prevent deadlocks from inconsistent lock ordering
# across concurrent transactions on the unit_entities unique index.
sorted_pairs = sorted(unit_entity_pairs, key=lambda t: (t[0], t[1]))
unit_ids = [p[0] for p in sorted_pairs]
entity_ids = [p[1] for p in sorted_pairs]
await self._ops.bulk_insert_unit_entities(
conn,
fq_table("unit_entities"),
unit_ids,
entity_ids,
# The unit→entity posting belongs to whoever stores the memory, so the
# memories store records it. Co-occurrence below is separate and unaffected:
# it references only `entities`, which stays in Postgres either way, and is
# read by the entity-graph endpoint and by resolution's disambiguation signal.
from .memories import get_memories
await get_memories().record_unit_entities(
conn=conn,
ops=self._ops,
fq_table=fq_table,
bank_id=bank_id,
unit_ids=unit_ids,
entity_ids=entity_ids,
txn=txn,
)
# Build maps keyed by unit_id:
@@ -948,58 +1336,3 @@ class EntityResolver:
_CooccurrencePair(entity_id_1=e1, entity_id_2=e2, event_date=ed)
for (e1, e2), ed in cooccurrence_pairs.items()
)
async def get_units_by_entity(self, entity_id: str, limit: int = 100) -> list[str]:
"""
Get all units that mention an entity.
Args:
entity_id: Entity ID
limit: Max results
Returns:
List of unit IDs
"""
async with acquire_with_retry(self.pool) as conn:
rows = await conn.fetch(
f"""
SELECT unit_id
FROM {fq_table("unit_entities")}
WHERE entity_id = $1
ORDER BY unit_id
LIMIT $2
""",
entity_id,
limit,
)
return [row["unit_id"] for row in rows]
async def get_entity_by_text(
self,
bank_id: str,
entity_text: str,
) -> str | None:
"""
Find an entity by text (for query resolution).
Args:
bank_id: bank ID
entity_text: Entity text to search for
Returns:
Entity ID if found, None otherwise
"""
async with acquire_with_retry(self.pool) as conn:
row = await conn.fetchrow(
f"""
SELECT id FROM {fq_table("entities")}
WHERE bank_id = $1
AND canonical_name ILIKE $2
ORDER BY mention_count DESC
LIMIT 1
""",
bank_id,
entity_text,
)
return row["id"] if row else None
@@ -1,53 +1,62 @@
"""Async graph maintenance after document/unit deletes.
Three reconciliation passes run together on every worker invocation:
Two queue-driven passes run together on every worker invocation:
1. **Relink top-up.** Drain ``graph_maintenance_queue`` (units whose
outgoing temporal/semantic links lost a neighbour to a delete). For
each, count current outgoing links per type; if below cap, run the
same probes retain uses (:func:`fetch_temporal_neighbors`,
:func:`compute_semantic_links_ann`) and insert the missing links.
``bulk_insert_links`` has ``ON CONFLICT DO NOTHING`` on the uniqueness
key, so we can re-probe freely and the DB de-dupes.
same probes retain uses and insert the missing links.
2. **Orphan entity prune.** Delete ``entities`` rows in the bank that no
longer have any ``unit_entities`` references. FK ON DELETE CASCADE on
``entity_cooccurrences`` then removes any cooccurrence row pointing
at the pruned entities.
2. **Entity prune.** Drain ``entity_maintenance_queue`` (entities a delete
may have stranded). Per batch: delete the candidates no ``unit_entities``
row references any more FK ON DELETE CASCADE on ``entity_cooccurrences``
takes their cooccurrence rows with them then delete the cooccurrence rows
incident to the survivors that no current memory witnesses, the stale-count
case the cascade cannot see.
3. **Stale cooccurrence prune.** Defensive sweep for cooccurrence rows
where both endpoints still exist but no current memory_unit references
both of them the cooccurrence was real at the time it was recorded,
but every unit that witnessed it has since been deleted.
Both passes are *queued work*, not sweeps. Pass 2 used to be two bank-wide
statements re-evaluated on every invocation whether or not anything had
changed, so its cost tracked the size of the bank instead of the size of the
delete; on a multi-million-row bank neither statement could finish inside
asyncpg's command timeout and the job failed on every run, forever (#3222).
Both queues are now filled inside the deleting transaction, so each run only
looks at what that delete actually touched.
All three passes run on every invocation. The queue is the only source
of work for pass 1; passes 2 and 3 are bank-wide sweeps backed by indexes
on ``entities(bank_id)`` and ``unit_entities(entity_id)``, so they're
cheap when there's nothing to do.
Each pass is work the *memories store* owns, because each is a query over
`memory_links`, `unit_entities` and `entities` the slice the store carves
out. This module orchestrates them (pass ordering, the time budget, the timing
log) and asks the store to do the part that touches storage. A store whose
links travel inside its memories has no `memory_links` to dangle and no join
table to sweep, so both passes are no-ops for it.
The worker dedupes on bank: a second job for the same bank is dropped
while one is pending. Once processing starts, a new job becomes the
*next* pending slot so work enqueued during processing gets picked up
by the follow-up run.
That follow-up run is *deferred*, not parallel: ``claim_tasks`` will not claim a
graph_maintenance row for a bank that already has one in flight (#3230). Two
concurrent runs would do no extra work anyway each drains the same two
bank-scoped queues while convoying on each other's row locks and holding a
worker slot each.
"""
from __future__ import annotations
import logging
import time
import uuid as uuid_module
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from ..config import get_config
from ..models import RequestContext
from .db.base import DatabaseConnection
from .retain.link_utils import (
MAX_TEMPORAL_LINKS_PER_UNIT,
_bulk_insert_links,
_normalize_datetime,
compute_semantic_links_ann,
)
# Re-exported for callers and tests that import the link caps from here; the caps
# themselves live with the link builders the relink pass mirrors — the temporal one
# with the retain-time builders, the semantic one with the store's relink pass — so
# there is a single definition of each and the two cannot drift.
from .memories.pg.graph import MAX_SEMANTIC_LINKS_PER_UNIT # noqa: F401
from .retain.link_utils import MAX_TEMPORAL_LINKS_PER_UNIT # noqa: F401
from .schema import fq_table
if TYPE_CHECKING:
@@ -55,31 +64,14 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Mirrors the ``top_k`` default in ``compute_semantic_links_ann`` at retain
# time. If you change one, change the other — otherwise victims would either
# never reach the cap (probe returns less than the cap) or stay perpetually
# under it (cap is higher than retain creates).
MAX_SEMANTIC_LINKS_PER_UNIT = 50
# Worker fetches this many rows per relink-loop iteration. Bounds
# per-iteration probe/insert latency so a 10k-row backlog doesn't hold a
# worker slot for minutes. Chosen so the typical iteration runs in well
# under 1s.
_DRAIN_BATCH_SIZE = 50
# Retry budget for the idempotent Pass 2/3 entity/cooccurrence sweep. Higher
# than db_utils' default (3) because the sweep has no client waiting on it and
# is safe to rerun, so we'd rather spend a longer jittered-backoff tail than
# drop a maintenance pass and leak stale graph rows (see run_graph_maintenance_job).
_SWEEP_MAX_RETRIES = 8
@dataclass
class _SweepCounts:
"""Prune counts returned by the Pass 2/3 sweep (avoids a bare tuple return)."""
orphan_entities_pruned: int
stale_cooccurrences_pruned: int
# Wall-clock budget for one graph_maintenance run. Both passes commit per batch,
# so hitting the budget is not a failure: it stops claiming new work, reports
# what it did, and the follow-up run resumes from the queue rows still there.
# A backlog (a bulk delete, say) then converges over several runs instead of
# holding a worker slot for as long as it takes — the failure mode #3222
# describes, where the whole run was cancelled and every batch's work was
# retried from scratch.
_JOB_TIME_BUDGET_SECONDS = 240.0
@dataclass
@@ -88,15 +80,22 @@ class JobResult:
relink_units_processed: int = 0
relink_links_added: int = 0
entities_examined: int = 0
orphan_entities_pruned: int = 0
stale_cooccurrences_pruned: int = 0
# False when the time budget stopped a drain with work still queued. The
# caller re-submits so the backlog keeps draining without waiting for the
# next delete to trigger a run.
queues_drained: bool = True
def as_dict(self) -> dict[str, int]:
def as_dict(self) -> dict[str, int | bool]:
return {
"relink_units_processed": self.relink_units_processed,
"relink_links_added": self.relink_links_added,
"entities_examined": self.entities_examined,
"orphan_entities_pruned": self.orphan_entities_pruned,
"stale_cooccurrences_pruned": self.stale_cooccurrences_pruned,
"queues_drained": self.queues_drained,
}
@@ -104,7 +103,6 @@ async def enqueue_relink_victims(
conn: DatabaseConnection,
bank_id: str,
affected_unit_ids: list[str],
ops: Any,
include_affected_units: bool = False,
) -> int:
"""Enqueue surviving units whose outgoing temporal/semantic links pointed at
@@ -121,63 +119,81 @@ async def enqueue_relink_victims(
the drain skips queue rows with no live unit so callers should only set
it when the unit survives the transaction.
Delegated to the memories store: finding the victims is a `memory_links`
query, and a store whose links are inline has none, so it returns 0 and the
relink pass has nothing to do. The store resolves the dialect it needs from
``conn``.
Args:
conn: Database connection inside the active transaction.
bank_id: Bank owning the affected units.
affected_unit_ids: Memory_unit IDs whose incident temporal/semantic
links are about to be (or are being) removed.
ops: ``DataAccessOps`` instance, supplies the dialect-specific
bulk-insert path.
include_affected_units: Also enqueue ``affected_unit_ids`` themselves,
for callers that leave them live. One combined insert (rather than a
second call) keeps the queue's sorted lock ordering intact: two
transactions editing mutually linked units would otherwise take the
``(bank_id, unit_id)`` keys in opposite orders and deadlock.
for callers that leave them live.
Returns:
Number of distinct units passed to the queue insert.
Number of distinct victim units enqueued (0 for a store with no links).
"""
if not affected_unit_ids:
return 0
affected_uuids = [uuid_module.UUID(uid) if isinstance(uid, str) else uid for uid in affected_unit_ids]
affected_str_set = {str(uid) for uid in affected_uuids}
from .memories import get_memories
# Find units (other than the affected ones) that have an outgoing
# temporal/semantic link pointing at an affected unit. Entity links are
# intentionally excluded — they're scheduled for removal and would only
# add noise to the recompute job.
victim_rows = await conn.fetch(
f"""
SELECT DISTINCT from_unit_id
FROM {fq_table("memory_links")}
WHERE to_unit_id = ANY($1::uuid[])
AND bank_id = $2
AND link_type IN ('temporal', 'semantic')
""",
affected_uuids,
bank_id,
return await get_memories().enqueue_relink_victims(
conn=conn,
fq_table=fq_table,
bank_id=bank_id,
affected_unit_ids=affected_unit_ids,
include_affected_units=include_affected_units,
)
relink_ids = {row["from_unit_id"] for row in victim_rows if str(row["from_unit_id"]) not in affected_str_set}
if include_affected_units:
relink_ids.update(affected_uuids)
if not relink_ids:
async def enqueue_entity_prune_candidates(
conn: DatabaseConnection,
bank_id: str,
affected_unit_ids: list[str],
) -> int:
"""Enqueue the entities ``affected_unit_ids`` reference as prune candidates.
Must run inside the same transaction that removes those units (or replaces
their entity postings), *before* the delete or cascade fires: afterwards the
``unit_entities`` rows naming the entities are gone, and an entity nothing
points at is an orphan nothing will ever look at again.
Pair this with :func:`enqueue_relink_victims` at every delete site. They
capture different things that one records the *survivors* whose links now
dangle, this one the *entities* the doomed units were holding up and
neither substitutes for the other. A site that deletes units without calling
this leaks orphan entities and stale cooccurrences until something else
happens to enqueue the same entity.
Over-enqueueing costs nothing: the drain re-checks each candidate and keeps
the ones still referenced.
Delegated to the memories store: a store that never wrote ``unit_entities``
has no postings to lose and returns 0.
Args:
conn: Database connection inside the active transaction.
bank_id: Bank owning the affected units.
affected_unit_ids: Memory_unit IDs whose entity postings are about to
be (or are being) removed.
Returns:
Number of candidate entities enqueued (0 for a store with no postings).
"""
if not affected_unit_ids:
return 0
await ops.enqueue_graph_maintenance(
conn,
fq_table("graph_maintenance_queue"),
bank_id,
list(relink_ids),
)
from .memories import get_memories
logger.debug(
f"[GRAPH_MAINT] Enqueued {len(relink_ids)} units for relinking in "
f"bank={bank_id} ({len(affected_unit_ids)} units affected)"
return await get_memories().enqueue_entity_prune_candidates(
conn=conn,
fq_table=fq_table,
bank_id=bank_id,
affected_unit_ids=affected_unit_ids,
)
return len(relink_ids)
async def run_graph_maintenance_job(
@@ -185,237 +201,145 @@ async def run_graph_maintenance_job(
bank_id: str,
request_context: RequestContext,
operation_id: str | None = None,
) -> dict[str, int]:
"""Run all maintenance passes for ``bank_id`` until the relink queue is
drained, then sweep entities and cooccurrences once.
) -> dict[str, int | bool]:
"""Drain both maintenance queues for ``bank_id``, within a time budget.
Returns:
Per-pass counters from :class:`JobResult`.
Per-pass counters from :class:`JobResult`. ``queues_drained`` is False
when the budget ran out with work still queued the caller re-submits.
"""
del request_context # accepted for symmetry with other run_*_job helpers
from ..config import get_config
from .memories import get_memories
backend = await memory_engine._get_backend()
ops = backend.ops
store = get_memories()
config = get_config()
result = JobResult()
job_start = time.time()
semantic_link_min_similarity = get_config().semantic_link_min_similarity
deadline = time.monotonic() + _JOB_TIME_BUDGET_SECONDS
# --- Pass 1: relink ---
# Per-iteration loop: claim → top up → commit. We rely on submit-time
# dedup to keep at most one job per bank running, so no need for
# SKIP LOCKED.
iterations = 0
while True:
from .memory_engine import acquire_with_retry
# The store owns the whole drain loop: it is a claim → top-up → commit over
# its own link table, so how it batches and re-probes is its business — including
# the #3034 serialisation (the claim takes queue rows FOR UPDATE in (bank_id,
# unit_id) order against a concurrent re-enqueue), which lives in the store's
# claim (`ops.claim_graph_maintenance_batch`). A store with no links returns an
# empty dict and this is a no-op.
relink = await store.relink_pass(
backend=backend, fq_table=fq_table, bank_id=bank_id, config=config, deadline=deadline
)
result.relink_units_processed = relink.units_processed
result.relink_links_added = relink.links_added
async with acquire_with_retry(backend) as conn:
async with conn.transaction():
unit_ids = await ops.claim_graph_maintenance_batch(
conn,
fq_table("graph_maintenance_queue"),
bank_id,
_DRAIN_BATCH_SIZE,
)
if not unit_ids:
break
result.relink_links_added += await _relink_batch(
conn,
bank_id,
unit_ids,
ops,
backend,
semantic_link_min_similarity,
)
result.relink_units_processed += len(unit_ids)
iterations += 1
if iterations > 10000:
# Defensive guard against runaway loops — at 50 units/iter that's
# 500k targets, far beyond any realistic single-bank backlog.
logger.error(
f"[GRAPH_MAINT] bank={bank_id} hit iteration cap ({iterations}); aborting relink ({result.as_dict()})"
)
break
# --- Pass 2 & 3: entity / cooccurrence sweeps ---
# Bank-wide single-statement deletes. Cheap when there's nothing to do.
#
# Unlike Pass 1's queue claim, these DELETEs aren't protected by any
# consistent lock-ordering guarantee: prune_stale_cooccurrences scans
# entity_cooccurrences via a join/NOT EXISTS plan, while retain's
# concurrent cooccurrence upserts (entity_resolver._flush_pending) lock
# the same rows in sorted (entity_id_1, entity_id_2) order. When a sweep
# and a concurrent upsert touch overlapping rows in opposite orders,
# Postgres detects a genuine circular wait and aborts one side with
# DeadlockDetectedError. Both prunes are idempotent bank-wide sweeps —
# rerunning only deletes what's still stale — so retrying the whole
# transaction on deadlock is safe.
from .db_utils import retry_with_backoff
from .memory_engine import acquire_with_retry
async def _run_sweep() -> _SweepCounts:
async with acquire_with_retry(backend) as conn:
async with conn.transaction():
orphan_pruned = await ops.prune_orphan_entities(
conn,
fq_table("entities"),
fq_table("unit_entities"),
bank_id,
)
# The orphan prune above cascades cooccurrences via FK. The
# explicit cooccurrence pass below catches the *stale-count*
# case: both entities still exist but no current unit
# witnesses them together.
stale_pruned = await ops.prune_stale_cooccurrences(
conn,
fq_table("entity_cooccurrences"),
fq_table("unit_entities"),
fq_table("entities"),
bank_id,
)
return _SweepCounts(orphan_entities_pruned=orphan_pruned, stale_cooccurrences_pruned=stale_pruned)
# A larger retry budget than the default (3): this is idempotent background
# maintenance with no client waiting on it, so a longer retry tail costs
# nothing, whereas a dropped sweep silently leaks orphan entities / stale
# cooccurrences until the next run. With jittered backoff a single sweep
# contending against continuous retain upserts effectively never exhausts
# this budget (each retry independently clears with high probability).
sweep = await retry_with_backoff(_run_sweep, max_retries=_SWEEP_MAX_RETRIES)
result.orphan_entities_pruned = sweep.orphan_entities_pruned
result.stale_cooccurrences_pruned = sweep.stale_cooccurrences_pruned
# --- Pass 2: entity prune ---
# Same shape as Pass 1 and owned by the store for the same reason: a
# claim → prune → commit loop over `entities` / `unit_entities` /
# `entity_cooccurrences`, including the ordered locking that keeps its
# deletes from cycling against retain's concurrent entity and cooccurrence
# upserts. A store that never wrote `unit_entities` returns an empty dict
# and this is a no-op. Runs after the relink pass so the remaining budget
# is whatever Pass 1 left.
prune = await store.entity_prune_pass(backend=backend, fq_table=fq_table, bank_id=bank_id, deadline=deadline)
result.entities_examined = prune.entities_examined
result.orphan_entities_pruned = prune.orphan_entities_pruned
result.stale_cooccurrences_pruned = prune.stale_cooccurrences_pruned
result.queues_drained = relink.queue_exhausted and prune.queue_exhausted
elapsed = time.time() - job_start
# --- Hand-off: schedule a successor for any work this run leaves behind ---
#
# Submit-time dedup now treats a *running* graph-maintenance job as covering
# the bank (see _submit_async_operation's dedupe_by_bank_includes_processing).
# That is what stops one job being queued per triggering operation, but it
# means a submit made while this job runs is suppressed. So this job has to
# hand off to a successor for any work it leaves behind, or that work strands
# until some unrelated future trigger. Both hand-offs below pass
# dedupe_excludes_operation_id: the worker only marks the operation completed
# after this body returns, so the row is still 'processing' now and the
# widened predicate would otherwise dedup the hand-off against its own row and
# silently do nothing.
from .memory_engine import acquire_with_retry
from .task_backend import SyncTaskBackend
if not result.queues_drained:
# Backlog case: the time budget stopped a drain with work still queued, so
# more is provably left. Chain a follow-up so the backlog converges
# without waiting for the next delete to trigger a run — on a bank that
# has gone quiet that may be never. WARNING because a bank that keeps
# landing here is producing maintenance faster than one run absorbs it.
logger.warning(
f"[GRAPH_MAINT] bank={bank_id} hit the {_JOB_TIME_BUDGET_SECONDS:.0f}s budget with work still "
f"queued; committed {result.as_dict()} in {elapsed:.2f}s"
)
# A synchronous task backend (tests, embedded) runs the successor inline,
# which would recurse one job per budget window instead of scheduling.
# There the caller is already the drain loop and gets the remaining rows
# on its next call, so skip the hand-off.
if not isinstance(memory_engine._task_backend, SyncTaskBackend):
try:
await memory_engine.submit_async_graph_maintenance(
bank_id=bank_id,
request_context=request_context,
dedupe_excludes_operation_id=operation_id,
)
except Exception:
# Never fail a completed maintenance run over the hand-off. The
# work is still queued and the next trigger picks it up; log
# loudly so a persistent failure here is visible, not silent.
logger.exception(f"[GRAPH_MAINT] bank={bank_id} follow-up submit failed")
else:
# Gap case: both queues drained within budget, but new rows can have
# landed in the gap between a pass's final claim and this job being marked
# completed. Their submits were deduped against this still-'processing'
# job, so nothing is scheduled to pick them up. Re-check both queues —
# reusing the portable existence check submit uses for its empty-queue
# short-circuit (no Postgres-only LIMIT, and covers the relink and
# entity-prune queues) — and hand off anything that landed.
#
# Gated on this run having made progress. A run that consumed nothing and
# still sees queued work would hand off to a successor that repeats the
# exact outcome — an endless per-bank chain. Requiring progress means the
# chain only continues while it is actually draining, so it terminates.
# (The backlog branch above is not gated this way: its contract is to
# always continue a budgeted backlog so a quiet bank is never stranded.)
#
# Not guarded against SyncTaskBackend, unlike the backlog branch: this
# branch cannot fire on one. A synchronous backend is single-threaded, so
# nothing enqueues concurrently and the queues are empty once the passes
# (which never enqueue for themselves) return — leaving no gap to close.
made_progress = result.relink_units_processed > 0 or result.entities_examined > 0
try:
backend_check = await memory_engine._get_backend()
async with acquire_with_retry(backend_check) as conn:
work_remains = bool(
await conn.fetchval(
f"""
SELECT 1 WHERE
EXISTS (SELECT 1 FROM {fq_table("graph_maintenance_queue")} WHERE bank_id = $1)
OR EXISTS (SELECT 1 FROM {fq_table("entity_maintenance_queue")} WHERE bank_id = $1)
""",
bank_id,
)
)
if work_remains and not made_progress:
logger.warning(
f"[GRAPH_MAINT] bank={bank_id} queue still non-empty after a run that drained "
f"nothing; not chaining a successor (it would repeat this outcome)"
)
elif work_remains:
logger.info(f"[GRAPH_MAINT] bank={bank_id} work arrived during the run; submitting a follow-up job")
await memory_engine.submit_async_graph_maintenance(
bank_id=bank_id,
request_context=request_context,
dedupe_excludes_operation_id=operation_id,
)
except Exception:
# As above: the queued work survives, so log rather than fail the run.
logger.exception(f"[GRAPH_MAINT] bank={bank_id} follow-up submit failed")
logger.info(
f"[GRAPH_MAINT] bank={bank_id} done: {result.as_dict()}, elapsed={elapsed:.2f}s, operation_id={operation_id}"
)
return result.as_dict()
async def _relink_batch(
conn: DatabaseConnection,
bank_id: str,
victim_ids: list[str],
ops: Any,
backend: Any,
semantic_link_min_similarity: float,
) -> int:
"""Top up temporal/semantic links for a batch of victim units. Returns rows inserted."""
# Load each victim's metadata. Victims whose units were deleted between
# enqueue and now silently drop out — exactly the no-op behaviour we want
# for stale queue rows.
victim_uuids = [uuid_module.UUID(vid) for vid in victim_ids]
victim_rows = await conn.fetch(
f"""
SELECT id::text AS id, event_date, fact_type, embedding::text AS embedding
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
AND bank_id = $2
AND fact_type IN ('experience', 'world')
""",
victim_uuids,
bank_id,
)
if not victim_rows:
return 0
alive_uuids = [uuid_module.UUID(row["id"]) for row in victim_rows]
# Count current outgoing temporal/semantic links per victim so we only
# probe for the ones genuinely below cap. Saves the bulk of the work when
# most victims still have plenty of links.
count_rows = await conn.fetch(
f"""
SELECT from_unit_id, link_type, COUNT(*) AS cnt
FROM {fq_table("memory_links")}
WHERE from_unit_id = ANY($1::uuid[])
AND bank_id = $2
AND link_type IN ('temporal', 'semantic')
GROUP BY from_unit_id, link_type
""",
alive_uuids,
bank_id,
)
counts: dict[tuple[str, str], int] = {}
for row in count_rows:
counts[(str(row["from_unit_id"]), row["link_type"])] = int(row["cnt"])
# --- Temporal top-up ---
temporal_needs = [r for r in victim_rows if counts.get((r["id"], "temporal"), 0) < MAX_TEMPORAL_LINKS_PER_UNIT]
new_links: list[tuple] = []
if temporal_needs:
lateral_unit_ids = [uuid_module.UUID(r["id"]) for r in temporal_needs if r["event_date"] is not None]
lateral_event_dates = [
_normalize_datetime(r["event_date"]) for r in temporal_needs if r["event_date"] is not None
]
lateral_fact_types = [r["fact_type"] for r in temporal_needs if r["event_date"] is not None]
if lateral_unit_ids:
rows = await ops.fetch_temporal_neighbors(
conn,
fq_table("memory_units"),
bank_id,
lateral_unit_ids,
lateral_event_dates,
lateral_fact_types,
MAX_TEMPORAL_LINKS_PER_UNIT,
)
for row in rows:
time_diff_h = float(row["time_diff_hours"])
# Mirror the 24h window enforced at retain time. The bidirectional
# index scan returns the K closest neighbours regardless of
# window, so we filter here.
if time_diff_h > 24:
continue
weight = max(0.3, 1.0 - (time_diff_h / 24))
new_links.append((row["from_id"], str(row["id"]), "temporal", weight, None))
# --- Semantic top-up ---
# ANN must run on its own connection: it opens a nested transaction with
# SET LOCAL hnsw.ef_search + CREATE TEMP TABLE ON COMMIT DROP, and nesting
# that inside our current write transaction would commit our writes early.
semantic_needs = [
r
for r in victim_rows
if counts.get((r["id"], "semantic"), 0) < MAX_SEMANTIC_LINKS_PER_UNIT and r["embedding"] is not None
]
if semantic_needs:
from .memory_engine import acquire_with_retry
seed_ids = [r["id"] for r in semantic_needs]
seed_embs = [r["embedding"] for r in semantic_needs]
seed_ftypes = [r["fact_type"] for r in semantic_needs]
async with acquire_with_retry(backend) as ann_conn:
try:
ann_links = await compute_semantic_links_ann(
ann_conn,
bank_id,
seed_ids,
seed_embs,
fact_types=seed_ftypes,
threshold=semantic_link_min_similarity,
)
# Strip self-links (rare but possible because the ANN probe
# has no exclude list — see the comment in compute_semantic_links_ann).
ann_links = [lnk for lnk in ann_links if lnk[0] != lnk[1]]
new_links.extend(ann_links)
except Exception as e:
# ANN uses PG-specific HNSW syntax; on dialects/configs where
# it isn't available we still want the temporal top-up to land.
logger.warning(f"[GRAPH_MAINT] Semantic top-up failed for bank={bank_id}: {type(e).__name__}: {e}")
if not new_links:
return 0
await _bulk_insert_links(
conn,
new_links,
bank_id=bank_id,
skip_exists_check=False,
ops=ops,
)
return len(new_links)
@@ -531,11 +531,15 @@ class MemoryEngineInterface(ABC):
Get consolidation freshness for a bank.
Cheap alternative to get_bank_stats when callers only need
last_consolidated_at / pending_consolidation / failed_consolidation.
last_consolidated_at / last_memory_write_at / pending_consolidation /
failed_consolidation.
Returns:
Dict with last_consolidated_at (ISO-8601 string or None),
pending_consolidation (int), and failed_consolidation (int).
Dict with last_consolidated_at and last_memory_write_at (ISO-8601
strings or None), pending_consolidation (int), and
failed_consolidation (int). last_memory_write_at is the newest write
across the bank's memories — a mental model refreshed at or after it
cannot be stale, whatever its scope.
"""
...
@@ -5,14 +5,18 @@ This module defines the interface that all LLM providers must implement,
enabling support for multiple LLM backends (OpenAI, Anthropic, Gemini, Codex, etc.)
"""
import logging
from abc import ABC, abstractmethod
from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from typing import Any, Self
from typing import Any, Callable, Self
from .response_models import LLMToolCallResult
logger = logging.getLogger(__name__)
class LLMToolChoiceMode(StrEnum):
"""Canonical tool-selection modes shared by every LLM provider."""
@@ -67,7 +71,7 @@ class LLMInterface(ABC):
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
reasoning_effort: str | None = None,
**kwargs: Any,
):
"""
@@ -78,14 +82,37 @@ class LLMInterface(ABC):
api_key: API key or authentication token.
base_url: Base URL for the API.
model: Model name.
reasoning_effort: Reasoning effort level for supported providers.
reasoning_effort: Reasoning effort level, or None when the operator
configured none in which case no provider sends the parameter and
every model runs at its own default effort.
**kwargs: Additional provider-specific parameters.
"""
self.provider = provider.lower()
self.api_key = api_key
self.base_url = base_url
self.model = model
self.reasoning_effort = reasoning_effort
# None means "the operator said nothing", and nothing is what gets sent: no
# provider may invent a level. Hindsight used to resolve unset to "low" here and
# ship it to whichever lanes their capability check happened to accept, which
# made the setting both invisible (a configured value could be silently dropped —
# issue #3449) and presumptuous (an unconfigured one was still transmitted).
# An empty string is an unset environment variable, not a level.
self.reasoning_effort: str | None = reasoning_effort or None
def _warn_reasoning_effort_unsupported(self) -> None:
"""Report, once at startup, that this provider cannot honour a configured effort.
Providers with no reasoning knob to turn call this from ``__init__``. Silence is
what made issue #3449 expensive: the variable is set, documented and visible in
the environment, so every signal the operator has says it is in force. A setting
this provider cannot act on has to say so out loud.
"""
if self.reasoning_effort is None:
return
logger.warning(
f"reasoning_effort={self.reasoning_effort!r} is ignored: the {self.provider} provider "
f"has no reasoning-effort control. Remove the setting or switch provider to apply it."
)
@abstractmethod
async def verify_connection(self) -> None:
@@ -112,6 +139,7 @@ class LLMInterface(ABC):
strict_schema: bool = False,
return_usage: bool = False,
cached_prefix: str | None = None,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> Any:
"""
Make an LLM API call with retry logic.
@@ -133,6 +161,11 @@ class LLMInterface(ABC):
cached_prefix: Opaque handle from ``get_or_create_cached_prefix`` for the
cacheable system prefix, or None. Providers without explicit prompt
caching ignore it (and the wrapper only forwards it when set).
attempt_context: Factory for an async context manager holding the shared
concurrency permits. Passed only when the provider declares
``supports_attempt_scoped_concurrency()``; the provider must enter it
around each individual upstream request so retry backoff never
occupies a permit.
Returns:
If return_usage=False: Parsed response if response_format is provided, otherwise text content.
@@ -158,6 +191,7 @@ class LLMInterface(ABC):
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
cached_prefix: str | None = None,
cached_prefix_message_count: int = 0,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support.
@@ -172,6 +206,8 @@ class LLMInterface(ABC):
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: Canonical tool-selection policy.
attempt_context: Factory for an async context manager holding the shared
concurrency permits see ``call``.
Returns:
LLMToolCallResult with content and/or tool_calls.
@@ -187,6 +223,10 @@ class LLMInterface(ABC):
"""
return False
def supports_attempt_scoped_concurrency(self) -> bool:
"""Whether retries can acquire concurrency permits per upstream attempt."""
return False
# ── Prompt prefix caching (optional, per-provider) ─────────────────────────
def supports_prompt_caching(self) -> bool:
@@ -9,7 +9,7 @@ import os
import re
import time
import uuid
from contextlib import AsyncExitStack
from contextlib import AsyncExitStack, asynccontextmanager
from typing import TYPE_CHECKING, Any
from json_repair import repair_json
@@ -29,7 +29,15 @@ from ..config import (
ENV_REFLECT_LLM_MAX_CONCURRENT,
ENV_RETAIN_LLM_MAX_CONCURRENT,
)
from .llm_interface import LLM_TOOL_CHOICE_AUTO, LLMToolChoice, LLMToolChoiceMode
from .cache_affinity import parse_cache_affinity
from .llm_interface import (
LLM_TOOL_CHOICE_AUTO,
LLMToolChoice,
LLMToolChoiceMode,
)
from .llm_interface import (
OutputTooLongError as OutputTooLongError,
)
if TYPE_CHECKING:
from .response_models import LLMToolCallResult
@@ -107,6 +115,27 @@ def _semaphores_for_scope(scope: str) -> list[asyncio.Semaphore]:
return [per_op, _global_llm_semaphore]
@asynccontextmanager
async def _attempt_permits(scope: str):
"""Hold configured LLM concurrency permits for one upstream attempt."""
from ..worker.stage import get_stage, set_stage
async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
try:
yield
except BaseException:
# A failed attempt exits here with its permits released while the
# provider classifies the error and sleeps out its backoff. Suffix
# the stage so `attempt=N` always means "permits held, request in
# flight" (#3002); the next attempt re-stamps after re-acquiring.
stage = get_stage()
if stage is not None and not stage.endswith(".backoff"):
set_stage(f"{stage}.backoff")
raise
def _request_params(
*,
max_completion_tokens: int | None = None,
@@ -164,16 +193,11 @@ def sanitize_text(text: str | None) -> str | None:
sanitize_llm_output = sanitize_text
class OutputTooLongError(Exception):
"""
Bridge exception raised when LLM output exceeds token limits.
This wraps provider-specific errors (e.g., OpenAI's LengthFinishReasonError)
to allow callers to handle output length issues without depending on
provider-specific implementations.
"""
pass
# ``OutputTooLongError`` is re-exported from ``llm_interface`` (the canonical
# definition the providers raise) so that ``fact_extraction`` and ``multi_llm``,
# which import it from here, catch/inspect the very same class. Do NOT redefine
# it locally: a shadow class silently breaks ``except OutputTooLongError`` on the
# real provider path (see issue #3172).
def parse_llm_json(raw: str) -> Any:
@@ -247,6 +271,7 @@ _PROVIDERS_WITHOUT_API_KEY = frozenset(
"litellmrouter",
"bedrock",
"nous",
"xai-oauth",
}
)
@@ -272,7 +297,7 @@ def create_llm_provider(
api_key: str,
base_url: str,
model: str,
reasoning_effort: str,
reasoning_effort: str | None,
groq_service_tier: str | None = None,
openai_service_tier: str | None = None,
bedrock_service_tier: str | None = None,
@@ -287,6 +312,8 @@ def create_llm_provider(
gemini_service_tier: str | None = None,
timeout: float | None = None,
ollama_num_ctx: int | None = None,
cache_affinity: str | None = None,
structured_output_forced_tool: bool = False,
) -> Any: # Returns LLMInterface
"""
Factory function to create the appropriate LLM provider implementation.
@@ -296,7 +323,9 @@ def create_llm_provider(
api_key: API key (may be None for local providers or OAuth providers).
base_url: Base URL for the API.
model: Model name.
reasoning_effort: Reasoning effort level for supported providers.
reasoning_effort: Reasoning effort level for supported providers, or None when
the operator configured none (providers then fall back to the default level
and may skip the parameter entirely).
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".
@@ -310,9 +339,20 @@ def create_llm_provider(
for OpenAI/Anthropic vs ``max_output_tokens`` for Gemini).
default_headers: Custom headers passed to provider SDK clients (used by operators
routing through proxies / request-tracing middleware). Wired into the Anthropic
provider (SDK ``default_headers``) and the LiteLLM-backed providers ``litellm``,
``litellmrouter`` and ``bedrock`` as the LiteLLM ``extra_headers`` completion
kwarg; other providers may opt in as needed.
provider, the ``OpenAICompatibleLLM`` branch, ``fireworks``, ``nous`` and the
Responses API (SDK ``default_headers``), and into the LiteLLM-backed providers
``litellm``, ``litellmrouter`` and ``bedrock`` as the LiteLLM ``extra_headers``
completion kwarg; other providers may opt in as needed.
cache_affinity: Backend prompt-cache pinning mode, forwarded to the
``OpenAICompatibleLLM`` branch, ``fireworks`` and ``nous`` (all three share the
OpenAI-compatible wire format): "none" (default), "xai_conv_id",
"openai_prompt_cache_key", or "auto". Providers on other branches do their own
cache work or none at all. See ``engine/cache_affinity.py``.
structured_output_forced_tool: Ask the LiteLLM-backed providers (``litellm``,
``litellmrouter``, ``bedrock``) for structured output via a forced tool call
instead of ``response_format``. For backends that reject the response_format
route see ``HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL``. Other
providers ignore it.
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).
@@ -340,6 +380,7 @@ def create_llm_provider(
MockLLM,
NoneLLM,
OpenAICompatibleLLM,
OpenAIResponsesLLM,
)
provider_lower = provider.lower()
@@ -357,6 +398,7 @@ def create_llm_provider(
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
)
elif provider_lower == "claude-code":
@@ -423,6 +465,7 @@ def create_llm_provider(
extra_body=extra_body,
default_headers=default_headers,
timeout=timeout,
structured_output_forced_tool=structured_output_forced_tool,
)
elif provider_lower == "litellmrouter":
@@ -443,6 +486,7 @@ def create_llm_provider(
extra_body=extra_body,
default_headers=default_headers,
timeout=timeout,
structured_output_forced_tool=structured_output_forced_tool,
)
elif provider_lower == "bedrock":
@@ -458,6 +502,7 @@ def create_llm_provider(
default_headers=default_headers,
bedrock_service_tier=bedrock_service_tier,
timeout=timeout,
structured_output_forced_tool=structured_output_forced_tool,
)
elif provider_lower == "llamacpp":
@@ -470,6 +515,7 @@ def create_llm_provider(
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
model_path=config.llamacpp_model_path,
gpu_layers=config.llamacpp_gpu_layers,
context_size=config.llamacpp_context_size,
@@ -489,12 +535,16 @@ def create_llm_provider(
model=model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
default_headers=default_headers,
cache_affinity=cache_affinity,
)
elif provider_lower == "nous":
# Nous Portal is OpenAI-compatible on the wire; NousLLM adds rotating
# inference:invoke JWT auth read natively from ~/.hermes/auth.json
# (no static api_key, no hermes_cli dependency — same shape as Codex).
# default_headers/cache_affinity ride NousLLM's **kwargs passthrough to
# OpenAICompatibleLLM.__init__ unchanged (see NousLLM.__init__).
from hindsight_api.engine.providers.nous_llm import NousLLM
return NousLLM(
@@ -504,6 +554,41 @@ def create_llm_provider(
model=model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
default_headers=default_headers,
cache_affinity=cache_affinity,
timeout=timeout,
)
elif provider_lower == "xai-oauth":
# SuperGrok subscription lane: api.x.ai spoken plainly, but the
# credential is a device-code OAuth grant with proactive/reactive
# refresh over a shared on-disk store, and xAI's 403 shapes need their
# own classification — neither fits the OpenAI SDK client, hence its
# own provider.
from hindsight_api.engine.providers.xai_oauth_llm import XaiOAuthLLM
return XaiOAuthLLM(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
timeout=timeout,
)
elif provider_lower == "openai-responses":
# OpenAI Responses API (/v1/responses). Unlike chat/completions, it
# supports reasoning + function tools together, so reflect's tool loop
# can run with a real reasoning_effort. See OpenAIResponsesLLM.
return OpenAIResponsesLLM(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
openai_service_tier=openai_service_tier,
extra_body=extra_body,
default_headers=default_headers,
timeout=timeout,
)
@@ -531,6 +616,8 @@ def create_llm_provider(
groq_service_tier=groq_service_tier,
openai_service_tier=openai_service_tier,
extra_body=extra_body,
default_headers=default_headers,
cache_affinity=cache_affinity,
ollama_num_ctx=ollama_num_ctx,
timeout=timeout,
)
@@ -552,7 +639,7 @@ class LLMProvider:
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
reasoning_effort: str | None = None,
groq_service_tier: str | None = None,
openai_service_tier: str | None = None,
bedrock_service_tier: str | None = None,
@@ -570,6 +657,8 @@ class LLMProvider:
initial_backoff: float | None = None,
max_backoff: float | None = None,
ollama_num_ctx: int | None = None,
cache_affinity: str | None = None,
structured_output_forced_tool: bool = False,
):
"""
Initialize LLM provider.
@@ -579,7 +668,8 @@ class LLMProvider:
api_key: API key.
base_url: Base URL for the API.
model: Model name.
reasoning_effort: Reasoning effort level for supported providers.
reasoning_effort: Reasoning effort level for supported providers, or None
when the operator configured none.
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.
@@ -591,6 +681,11 @@ class LLMProvider:
(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.
cache_affinity: Backend prompt-cache pinning mode for the OpenAI-compatible and
Fireworks providers ("none", "xai_conv_id", "openai_prompt_cache_key",
"auto"). Validated here for every provider so a typo never fails silently;
providers on other factory branches ignore it. Used verbatim callers
resolve the per-operation/global fallback.
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"``.
@@ -611,6 +706,9 @@ class LLMProvider:
``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.
structured_output_forced_tool: Structured output via a forced tool call
instead of ``response_format``, for the LiteLLM-backed providers - from
config (``HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL``).
This constructor uses every argument as passed and does not read global
``HindsightConfig``: resolving the server-level default for a ``None`` argument is the
@@ -639,6 +737,9 @@ class LLMProvider:
self.openai_service_tier = openai_service_tier
self.bedrock_service_tier = bedrock_service_tier
self.gemini_service_tier = gemini_service_tier
# Structured-output transport for the LiteLLM-backed providers. Used verbatim —
# the caller resolves the server-level default, like the fields above.
self.structured_output_forced_tool = structured_output_forced_tool
self.ollama_num_ctx = _validate_ollama_num_ctx(ollama_num_ctx)
# Gemini safety settings (instance default; can be overridden per-request via context var)
self.gemini_safety_settings = gemini_safety_settings
@@ -653,10 +754,16 @@ class LLMProvider:
# 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
# Backend prompt-cache pinning mode. Validated here rather than only at the
# provider so a typo fails for every provider, not just the ones that act on
# it — the setting has no visible effect in the response, so a silent
# fallback to "none" would be indistinguishable from it working.
self.cache_affinity = parse_cache_affinity(cache_affinity).value
# Validate provider
valid_providers = [
"openai",
"openai-responses",
"groq",
"ollama",
"ollama-cloud",
@@ -682,6 +789,7 @@ class LLMProvider:
"atlas",
"fireworks",
"nous",
"xai-oauth",
]
if self.provider not in valid_providers:
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
@@ -786,6 +894,8 @@ class LLMProvider:
litellmrouter_config=router_config,
ollama_num_ctx=self.ollama_num_ctx,
timeout=self.timeout,
cache_affinity=self.cache_affinity,
structured_output_forced_tool=self.structured_output_forced_tool,
)
# Backward compatibility: Keep mock provider properties
@@ -951,10 +1061,18 @@ class LLMProvider:
# hand so the error path below can attach it if parsing/validation fails.
usage_token = set_response_usage(None)
try:
# Providers that own retry loops acquire the shared permits for each
# upstream attempt so backoff never occupies request capacity.
attempt_gated = self._provider_impl.supports_attempt_scoped_concurrency()
async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
set_stage(base_stage)
if not attempt_gated:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
# Permits in hand — only now leave `.queued`. Attempt-gated
# providers acquire permits per attempt instead, so they keep
# `.queued` until their first `attempt=N` stamp lands after
# the permit acquire inside attempt_context (#3002).
set_stage(base_stage)
# cached_prefix is only set for providers that returned a handle
# from get_or_create_cached_prefix() (e.g. Gemini); it's None for
@@ -963,6 +1081,7 @@ class LLMProvider:
cache_kwarg = {"cached_prefix": cached_prefix} if cached_prefix is not None else {}
try:
# Delegate to provider implementation
attempt_kwarg = {"attempt_context": lambda: _attempt_permits(scope)} if attempt_gated else {}
result = await self._provider_impl.call(
messages=messages,
response_format=response_format,
@@ -976,6 +1095,7 @@ class LLMProvider:
strict_schema=strict_schema,
return_usage=return_usage,
**cache_kwarg,
**attempt_kwarg,
)
except Exception as e:
# The provider call may have succeeded (and incurred token
@@ -1087,10 +1207,15 @@ class LLMProvider:
# hand so the error path below can attach it if parsing/validation fails.
usage_token = set_response_usage(None)
try:
attempt_gated = self._provider_impl.supports_attempt_scoped_concurrency()
async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
set_stage(base_stage)
if not attempt_gated:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
# Permits in hand — only now leave `.queued`; attempt-gated
# providers stay `.queued` until their first post-acquire
# `attempt=N` stamp (see call() above, #3002).
set_stage(base_stage)
# cached_prefix is only set for providers that returned a handle
# from get_or_create_cached_prefix() / create_incremental_cache();
@@ -1103,6 +1228,7 @@ class LLMProvider:
)
try:
# Delegate to provider implementation
attempt_kwarg = {"attempt_context": lambda: _attempt_permits(scope)} if attempt_gated else {}
result = await self._provider_impl.call_with_tools(
messages=messages,
tools=tools,
@@ -1114,6 +1240,7 @@ class LLMProvider:
max_backoff=max_backoff,
tool_choice=tool_choice,
**cache_kwarg,
**attempt_kwarg,
)
except Exception as e:
# The provider call may have succeeded (and incurred token
@@ -1308,15 +1435,17 @@ class LLMProvider:
# 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_CACHE_AFFINITY,
DEFAULT_LLM_GROQ_SERVICE_TIER,
DEFAULT_LLM_OPENAI_SERVICE_TIER,
DEFAULT_LLM_PROMPT_CACHE_ENABLED,
DEFAULT_LLM_PROVIDER,
DEFAULT_LLM_REASONING_EFFORT,
DEFAULT_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
DEFAULT_LLM_TIMEOUT,
ENV_LLM_API_KEY,
ENV_LLM_BASE_URL,
ENV_LLM_BEDROCK_SERVICE_TIER,
ENV_LLM_CACHE_AFFINITY,
ENV_LLM_DEFAULT_HEADERS,
ENV_LLM_EXTRA_BODY,
ENV_LLM_GEMINI_SAFETY_SETTINGS,
@@ -1329,11 +1458,13 @@ class LLMProvider:
ENV_LLM_PROMPT_CACHE_ENABLED,
ENV_LLM_PROVIDER,
ENV_LLM_REASONING_EFFORT,
ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
ENV_LLM_TIMEOUT,
ENV_LLM_VERTEXAI_PROJECT_ID,
ENV_LLM_VERTEXAI_REGION,
ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
_get_default_model_for_provider,
_parse_boolean_env,
_parse_llm_router_config,
_parse_optional_positive_int,
parse_gemini_service_tier,
@@ -1353,6 +1484,9 @@ 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"))
# Same default as HindsightConfig.from_env: this entry point must not
# resolve to a different mode than the engine's own config path.
cache_affinity = os.getenv(ENV_LLM_CACHE_AFFINITY, DEFAULT_LLM_CACHE_AFFINITY) or None
prompt_cache_enabled = os.getenv(
ENV_LLM_PROMPT_CACHE_ENABLED, str(DEFAULT_LLM_PROMPT_CACHE_ENABLED)
).lower() in (
@@ -1367,9 +1501,10 @@ class LLMProvider:
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort=os.getenv(ENV_LLM_REASONING_EFFORT, DEFAULT_LLM_REASONING_EFFORT),
reasoning_effort=os.getenv(ENV_LLM_REASONING_EFFORT) or None,
extra_body=extra_body,
default_headers=default_headers,
cache_affinity=cache_affinity,
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,
@@ -1386,6 +1521,10 @@ class LLMProvider:
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))),
structured_output_forced_tool=_parse_boolean_env(
ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
DEFAULT_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
),
)
@@ -3,15 +3,16 @@
A single periodic loop that drives all of Hindsight's recurring housekeeping
from one place, so we don't spawn a separate ``asyncio`` task per concern:
- **Retention sweeps** (hourly): delete ``audit_log`` and ``llm_requests`` rows
older than their configured retention, across *all* tenant schemas.
- **Retention sweeps** (configurable, default hourly): delete ``audit_log`` and
``llm_requests`` rows older than their configured retention, across *all*
tenant schemas.
- **Consolidation reconcile** (configurable, default 5 min): re-schedule
consolidation for banks that have eligible-but-unscheduled facts and no
in-flight consolidation. This recovers facts that were stranded when a
consolidation operation failed terminally and left them with
``consolidated_at IS NULL AND consolidation_failed_at IS NULL`` and nothing to
re-trigger them.
- **Scheduled mental model refresh** (configurable check cadence, default 60s):
- **Scheduled mental model refresh** (configurable check cadence, default 5 min):
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
@@ -25,12 +26,37 @@ server-side PL/pgSQL routines (``schemas_with_expired_rows`` and
``banks_needing_consolidation``, in the configured schema see ``fq_routine``)
one round-trip each instead of a per-schema query storm, which matters at
thousands of tenants.
The loop runs in *every* API/worker process with no leader election, so a job that
enqueues work must make that enqueue idempotent or the fleet queues one wave per
process. Operation cleanup deletes a bounded batch per schema; the consolidation
reconcile and the scheduled mental model refresh both dedupe against in-flight
operations inside the inserting transaction (see ``_submit_async_operation``);
retention deletes in bounded chunks claimed with SKIP LOCKED, so concurrent
sweepers split the work instead of colliding (see ``_purge_table_in_batches``).
The *work* is therefore safe to run everywhere. The *discovery* in front of it is
not free: one round-trip on the wire is still one query per tenant schema inside
the routine, every process pays it, and its cost scales with tenant count while
the work it finds does not. Two things keep that proportionate, and both are
load-bearing rather than incidental:
- **Cadence is config, not a constant.** Every job's interval is a server-level
setting. The jobs that delete rows whose retention is measured in *days*
(retention, operation cleanup) have no reason to probe every schema every
minute.
- **The first tick is jittered** (``maintenance_start_jitter_seconds``). Every job
is due on the first tick, so a fleet started together a deploy, a rolling
restart would fire every cross-tenant probe in every process at the same
instant. SKIP LOCKED keeps that correct but not cheap: the probes are reads, and
N of them land at once. Steady state self-staggers; startup does not.
"""
from __future__ import annotations
import asyncio
import logging
import random
import time
from collections.abc import Coroutine
from datetime import datetime, timedelta, timezone
@@ -48,12 +74,40 @@ logger = logging.getLogger(__name__)
# Short tick so jobs with different cadences share one loop without per-job tasks.
_TICK_SECONDS = 60
# Retention sweeps are not time-sensitive; hourly matches the previous per-sweep cadence.
_RETENTION_INTERVAL_SECONDS = 3600
# Operation cleanup deletes one bounded batch per schema per run, so its cadence
# sets the drain rate for a backlog. Kept at one-per-tick (the value it used while
# it rode the worker's poll loop) so throughput is unchanged by the move.
_OPERATION_CLEANUP_INTERVAL_SECONDS = 60
# Cross-store txn recovery (only when the memories store keeps its rows outside SQL): a backstop
# for a writer that crashed between its external writes and the decide. The happy path decides
# inline after commit, so this rarely finds work; five minutes bounds how long a crashed txn stalls
# its namespace's fold.
_TXN_RECOVERY_INTERVAL_SECONDS = 300
# A pending txn is left alone for this long from first sighting before the sweep aborts an
# unwitnessed one — the writer may still be mid-flight (PendingTxn carries no timestamp).
_TXN_RECOVERY_GRACE_SECONDS = 300
# ── retention sweep pacing ────────────────────────────────────────────────────
# Retention used to issue one unbounded `DELETE FROM <table> WHERE started_at <
# cutoff` per schema. On a table with a real backlog that is a single statement
# holding row locks for minutes while it reads the whole expired range — and the
# maintenance loop runs in every API/worker process with no leader election, so
# every pod issued it at the same hourly boundary. Observed as two concurrent
# 330s+ deletes pinned on IO.DataFileRead, blocking each other on row locks,
# saturating RDS I/O and tripling recall latency.
#
# The fix is to design the collision out rather than elect one sweeper: each chunk
# claims its rows with FOR UPDATE SKIP LOCKED, so concurrent sweepers take
# *disjoint* chunks instead of waiting on each other, and the total work stays the
# number of expired rows however many pods join in. Chunks are short, index-driven
# transactions with a pause between them, so no statement holds locks for long and
# the deletes never monopolise disk I/O.
_RETENTION_BATCH_SIZE = 2000
# Ceiling on chunks per table per schema per run: a backstop against looping
# forever on a table that is filling faster than it drains. A full run therefore
# removes at most 2M rows per schema, and the next sweep (whose cadence is
# HINDSIGHT_API_RETENTION_SWEEP_INTERVAL_SECONDS, default hourly) continues.
_RETENTION_MAX_BATCHES = 1000
# Breather between chunks. Paces one process at ~8k rows/s worst case; several
# pods sweeping at once multiply that, which is still orders of magnitude gentler
# than the unbounded delete this replaces.
_RETENTION_BATCH_PAUSE_SECONDS = 0.25
class MaintenanceLoop:
@@ -65,6 +119,9 @@ class MaintenanceLoop:
self._stop = asyncio.Event()
# Monotonic timestamps of the last run per job, keyed by job name.
self._last_run: dict[str, float] = {}
# Cross-store txn recovery: first-sighting time per pending txn_id, so an unwitnessed
# txn gets a grace period before the sweep aborts it. Persists across ticks.
self._txn_first_seen: dict[str, float] = {}
# ── lifecycle ──────────────────────────────────────────────────────────
@@ -105,15 +162,41 @@ class MaintenanceLoop:
# Not gated on audit_log_enabled: that is per-bank overridable, so rows
# can exist even when the deployment default is off. Retention is driven
# purely by the (server-level) window.
audit_on = cfg.audit_log_retention_days > 0
llm_on = cfg.llm_trace_enabled and cfg.llm_trace_retention_days > 0
sweep_on = cfg.retention_sweep_interval_seconds > 0
audit_on = sweep_on and cfg.audit_log_retention_days > 0
llm_on = sweep_on and cfg.llm_trace_enabled and cfg.llm_trace_retention_days > 0
mm_refresh_on = cfg.mental_model_refresh_tick_seconds > 0
op_cleanup_on = cfg.operation_retention_days > 0
return reconcile_on or audit_on or llm_on or mm_refresh_on or op_cleanup_on
op_cleanup_on = cfg.operation_cleanup_interval_seconds > 0 and cfg.operation_retention_days > 0
return (
reconcile_on
or audit_on
or llm_on
or mm_refresh_on
or op_cleanup_on
or MaintenanceLoop._cross_store_recovery_enabled()
)
@staticmethod
def _cross_store_recovery_enabled() -> bool:
"""True when the memories store keeps memories outside SQL and therefore has
cross-store write-group txns a crashed writer could leave undecided.
Deliberately reads the PROCESS-LEVEL class attribute, not the per-bank
``writes_memory_rows_in_sql_for(bank_id)`` this only decides whether the recovery LOOP
needs to run at all. A store that routes some banks outside SQL keeps the class attribute
False so the loop runs, then ``recover_pending_txns`` is bank-scoped inside it."""
try:
from .memories import get_memories
return not get_memories().writes_memory_rows_in_sql
except Exception:
return False
# ── loop ───────────────────────────────────────────────────────────────
async def _run(self) -> None:
if not await self._wait_start_jitter():
return
while not self._stop.is_set():
try:
await self._tick()
@@ -124,6 +207,26 @@ class MaintenanceLoop:
except asyncio.TimeoutError:
pass
async def _wait_start_jitter(self) -> bool:
"""Delay the first tick by a random offset. Returns False if stopped while waiting.
Every job is due the first time ``_is_due`` sees it, so N processes started
together would run all of them at the same instant the one moment where
redundant cross-tenant discovery and overlapping DELETEs actually collide.
Spreading the *first* tick is enough: from then on each process keeps its
own phase.
"""
jitter = get_config().maintenance_start_jitter_seconds
if jitter <= 0:
return True
delay = random.uniform(0, jitter)
logger.debug(f"Maintenance loop: delaying first tick by {delay:.1f}s")
try:
await asyncio.wait_for(self._stop.wait(), timeout=delay)
except asyncio.TimeoutError:
return True
return False
def _is_due(self, job: str, interval_seconds: int) -> bool:
"""True if ``job`` has never run or its interval has elapsed; marks it run now."""
now = time.monotonic()
@@ -135,7 +238,8 @@ class MaintenanceLoop:
async def _tick(self) -> None:
cfg = get_config()
if self._is_due("retention", _RETENTION_INTERVAL_SECONDS):
retention_interval = cfg.retention_sweep_interval_seconds
if retention_interval > 0 and self._is_due("retention", retention_interval):
await self._run_timed("retention", self._run_retention(cfg))
interval = cfg.consolidation_reconcile_interval_seconds
if interval > 0 and self._is_due("reconcile", interval):
@@ -143,8 +247,15 @@ class MaintenanceLoop:
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())
if cfg.operation_retention_days > 0 and self._is_due("operation_cleanup", _OPERATION_CLEANUP_INTERVAL_SECONDS):
cleanup_interval = cfg.operation_cleanup_interval_seconds
if (
cleanup_interval > 0
and cfg.operation_retention_days > 0
and self._is_due("operation_cleanup", cleanup_interval)
):
await self._run_timed("operation cleanup", self._run_operation_cleanup(cfg))
if self._cross_store_recovery_enabled() and self._is_due("txn_recovery", _TXN_RECOVERY_INTERVAL_SECONDS):
await self._run_timed("cross-store txn recovery", self._run_txn_recovery())
async def _run_timed(self, name: str, coro: Coroutine[Any, Any, None]) -> None:
"""Run a maintenance job and emit one timing line for it.
@@ -171,26 +282,86 @@ class MaintenanceLoop:
if cfg.llm_trace_enabled and cfg.llm_trace_retention_days > 0:
await self._purge_expired("llm_requests", "started_at", cfg.llm_trace_retention_days)
async def _purge_expired(self, table: str, ts_col: str, days: int) -> None:
"""Delete rows older than ``days`` from ``table`` across every tenant schema."""
async def _purge_expired(self, table: str, ts_col: str, days: int) -> int:
"""Delete rows older than ``days`` from ``table`` across every tenant schema.
Only for the retention tables (``audit_log``, ``llm_requests``): chunking
deletes by primary key assumes the ``id`` column both of them carry.
Returns the number of rows deleted by *this* process.
"""
backend = self._engine._backend
try:
async with acquire_with_retry(backend, max_retries=1) as conn:
rows = await conn.fetch(
f"SELECT * FROM {fq_routine('schemas_with_expired_rows')}($1, $2, $3)", table, ts_col, days
)
for row in rows:
schema = row[0]
# schema names come from pg_class; quote defensively all the same.
qschema = '"' + schema.replace('"', '""') + '"'
result = await conn.execute(
f"DELETE FROM {qschema}.{table} WHERE {ts_col} < NOW() - make_interval(days => $1)",
days,
)
if result and result != "DELETE 0":
logger.info(f"Retention sweep {schema}.{table}: {result}")
except Exception as e:
logger.warning(f"Retention sweep failed for {table}: {e}")
logger.warning(f"Retention sweep discovery failed for {table}: {e}")
return 0
# One cutoff for the whole sweep: a per-chunk NOW() would let the window
# creep forward mid-run, which makes "deleted < batch means done" wrong.
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
total = 0
for row in rows:
schema = row[0]
# schema names come from pg_class; quote defensively all the same.
qschema = '"' + schema.replace('"', '""') + '"'
try:
deleted = await self._purge_table_in_batches(f"{qschema}.{table}", ts_col, cutoff)
except Exception as e:
logger.warning(f"Retention sweep failed for {schema}.{table}: {e}")
continue
if deleted:
total += deleted
logger.info(f"Retention sweep {schema}.{table}: DELETE {deleted}")
return total
async def _purge_table_in_batches(self, table: str, ts_col: str, cutoff: datetime) -> int:
"""Delete expired rows from one qualified table in bounded chunks.
Rows are claimed oldest-first off the ``(started_at)`` index with FOR UPDATE
SKIP LOCKED, which is what makes a leaderless fleet safe: a chunk never
waits on another sweeper (or on a writer still finishing its own row), and
two processes sweeping the same table take disjoint chunks rather than
redoing each other's work. Each chunk commits on its own, so no transaction
holds locks longer than one batch.
"""
backend = self._engine._backend
deleted = 0
for batch in range(_RETENTION_MAX_BATCHES):
if self._stop.is_set():
break
if batch:
await asyncio.sleep(_RETENTION_BATCH_PAUSE_SECONDS)
async with acquire_with_retry(backend, max_retries=1) as conn, conn.transaction():
removed = await conn.fetchval(
f"""
WITH expired AS (
SELECT id FROM {table}
WHERE {ts_col} < $1
ORDER BY {ts_col}
LIMIT $2
FOR UPDATE SKIP LOCKED
), removed AS (
DELETE FROM {table} t USING expired e WHERE t.id = e.id RETURNING 1
)
SELECT count(*) FROM removed
""",
cutoff,
_RETENTION_BATCH_SIZE,
)
deleted += removed
# A short chunk means the expired range is drained — or that another
# sweeper holds the rest, which is equally a reason to stop.
if removed < _RETENTION_BATCH_SIZE:
break
else:
logger.warning(
f"Retention sweep hit its per-run batch ceiling on {table} after {deleted} row(s); "
"the remainder is left for the next run"
)
return deleted
# ── terminal operation cleanup ─────────────────────────────────────────
@@ -245,6 +416,14 @@ class MaintenanceLoop:
try:
table = fq_table_explicit("async_operations", schema)
async with acquire_with_retry(backend, max_retries=1) as conn:
# Delete export archives owned by rows about to be pruned first,
# so the file-storage blobs don't outlive their operation row.
# Same batch bound as the prune below: the two walk the same
# ordered window so a backlog doesn't re-purge already-deleted
# archives on every cycle.
await engine.purge_expired_export_archives(
conn, table, cutoff, batch_size=cfg.operation_cleanup_batch_size
)
async with conn.transaction():
deleted = await backend.ops.prune_terminal_operations(
conn, table, cutoff, batch_size=cfg.operation_cleanup_batch_size
@@ -259,6 +438,42 @@ class MaintenanceLoop:
if pruned:
logger.info(f"Operation cleanup: pruned {pruned} operation(s) total")
# ── cross-store txn recovery ─────────────────────────────────────────────
async def _run_txn_recovery(self) -> None:
"""Resolve write-group txns a crashed writer left undecided, for a store that keeps its
rows outside SQL.
For each bank, the store lists its namespace's pending txns and decides each against the
Postgres witness table (present commit, absent past the grace abort never on
assumption), then reaps expired witness rows. A no-op for the SQL stores. Best-effort: a
failure here only delays a stalled fold until the next tick.
"""
from .memories import get_memories
store = get_memories()
if store.writes_memory_rows_in_sql:
return
backend = self._engine._backend
try:
async with acquire_with_retry(backend, max_retries=1) as conn:
bank_ids = [r[0] for r in await conn.fetch(f"SELECT bank_id FROM {fq_table('banks')}")]
if not bank_ids:
return
decided = await store.recover_pending_txns(
conn=conn,
fq_table=fq_table,
bank_ids=bank_ids,
first_seen=self._txn_first_seen,
now=time.monotonic(),
grace_seconds=_TXN_RECOVERY_GRACE_SECONDS,
)
except Exception as e:
logger.warning(f"Cross-store txn recovery failed: {e}")
return
if decided:
logger.info(f"Cross-store txn recovery: decided {decided} undecided txn(s)")
# ── consolidation reconcile ──────────────────────────────────────────────
async def _run_reconcile(self) -> None:
@@ -383,6 +598,7 @@ class MaintenanceLoop:
submitted = 0
skipped_unknown = 0
skipped_fresh = 0
skipped_in_flight = 0
for row in due:
schema = row["schema_name"]
bank_id = row["bank_id"]
@@ -402,7 +618,8 @@ class MaintenanceLoop:
# 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')} "
f"SELECT id, tags, trigger, last_refreshed_at, last_memory_seen_at "
f"FROM {fq_table('mental_models')} "
"WHERE bank_id = $1 AND id = $2",
bank_id,
mm_id,
@@ -413,18 +630,28 @@ class MaintenanceLoop:
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
# skip_if_in_flight makes the enqueue itself idempotent. The discovery
# routine already excludes models with a pending/processing refresh,
# but that exclusion is a *read*: this loop runs in every process, so
# every process saw the same "nothing in flight" snapshot and inserted
# its own operation — one queued wave per process (#3210). The insert
# now carries the check, so a second one is never created.
result = await engine.submit_async_refresh_mental_model(
bank_id=bank_id, mental_model_id=mm_id, request_context=context, skip_if_in_flight=True
)
submitted += 1
if result.get("deduplicated"):
skipped_in_flight += 1
else:
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:
if submitted or skipped_unknown or skipped_fresh or skipped_in_flight:
logger.info(
f"Scheduled mental model refresh: scheduled {submitted} model(s)"
+ (f", {skipped_fresh} up-to-date" if skipped_fresh else "")
+ (f", {skipped_in_flight} already in flight" if skipped_in_flight else "")
+ (f", skipped {skipped_unknown} in unrecognized schema(s)" if skipped_unknown else "")
)
@@ -0,0 +1,88 @@
"""The memories store: which one is installed, and how the engine reaches it.
Resolved through the ordinary extension loader ``HINDSIGHT_API_MEMORIES_EXTENSION``
names a ``module:Class``, and ``HINDSIGHT_API_MEMORIES_*`` becomes its config so
this behaves like every other extension point. Unset (the normal case) means
:class:`~hindsight_api.engine.memories.postgres.PostgresMemories`: rows in
`memory_units`, links in `memory_links` / `unit_entities`, retrieval as SQL.
"""
from __future__ import annotations
import logging
from .base import (
META_CHUNK_ID,
CausalEdgeRecord,
DeletePredicate,
FactRecord,
MemoriesExtension,
MemoryPatch,
RecallArms,
ScanPage,
StoredMemory,
build_fact_records,
build_text_signals,
source_key,
)
logger = logging.getLogger(__name__)
_memories: MemoriesExtension | None = None
def create_memories(context=None) -> MemoriesExtension:
"""Build the configured memories store, or the Postgres default."""
from ...extensions.loader import load_extension
loaded = load_extension("MEMORIES", MemoriesExtension, context=context)
if loaded is not None:
logger.info("[memories] store=%s (memory rows do not go to postgres)", loaded.name)
return loaded
from .postgres import PostgresMemories
return PostgresMemories({})
def get_memories() -> MemoriesExtension:
"""The process-wide memories store, built on first use.
Retrieval and the retain pipeline reach it through call chains that do not
carry the engine, so it is resolved here rather than threaded through every
signature.
"""
global _memories
if _memories is None:
_memories = create_memories()
return _memories
def set_memories(memories: MemoriesExtension | None) -> None:
"""Override the store (tests, and engine startup after initialize())."""
global _memories
_memories = memories
# The graph arm's retriever is chosen from the store and then cached, so it
# has to be re-resolved whenever the store changes.
from ..search.retrieval import set_default_graph_retriever
set_default_graph_retriever(None)
__all__ = [
"META_CHUNK_ID",
"CausalEdgeRecord",
"DeletePredicate",
"FactRecord",
"MemoriesExtension",
"MemoryPatch",
"RecallArms",
"ScanPage",
"StoredMemory",
"build_fact_records",
"build_text_signals",
"create_memories",
"get_memories",
"set_memories",
"source_key",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
"""The Postgres memories implementation, split by what calls it.
:class:`~hindsight_api.engine.memories.postgres.PostgresMemories` is a thin class
over these modules; the queries live here, grouped by concern rather than piled
behind one object:
* :mod:`counts` the stats/admin aggregates (freshness, per-doc, timeseries, scopes)
* :mod:`curation` the memory/entity list and detail views
* :mod:`graph` the graph view, entity postings, and the maintenance passes
* :mod:`reads` addressed reads: get, scan, count, tags, consolidation state
* :mod:`writes` inserts, deletes, and observation invalidation
Every function here takes the live connection and Hindsight's ``fq_table``
resolver rather than reaching for globals, so each is callable from a
transaction the caller already owns.
"""
from __future__ import annotations
__all__ = ["counts", "curation", "graph", "reads", "writes"]
@@ -0,0 +1,168 @@
"""The count/aggregate surfaces: consolidation freshness, per-document counts,
ingestion over time, observation scopes.
Each is one ``GROUP BY`` (or filtered ``COUNT``) over `memory_units`. They back
the stats and admin views, not retrieval, so they are grouped here away from the
addressed reads. The SQL is lifted verbatim from the engine methods that used to
carry it; only the connection and ``fq_table`` resolver are now parameters.
"""
from __future__ import annotations
from collections.abc import Callable
from datetime import datetime
from typing import Any
async def consolidation_freshness(*, conn, fq_table: Callable[[str], str], bank_id: str) -> dict[str, Any]:
"""Last consolidation time, the pending / failed fact counts, and the write watermark, in one scan.
``pending`` and ``failed`` are disjoint: pending carries the consolidator's
own candidate predicate (``consolidated_at IS NULL AND consolidation_failed_at
IS NULL``, see ``reads.find_unconsolidated``), so it reads as "work the
consolidator will still do" and drains to zero. A fact the LLM could not
handle is counted once, under ``failed``, and only leaves that bucket via the
consolidation-recovery endpoint.
All four come from a single pass so keeping ``failed`` part of the
published contract costs nothing over reflect()'s ``pending`` read, and
``last_memory_write_at`` (the newest ``updated_at`` anywhere in the bank)
rides along for free. That watermark is what lets a caller decide a mental
model is up to date without running its own scoped scan: nothing in the bank
changed since the refresh, so nothing in the model's scope did either.
"""
row = await conn.fetchrow(
f"""
SELECT
MAX(consolidated_at) AS last_consolidated_at,
MAX(updated_at) AS last_memory_write_at,
COUNT(*) FILTER (
WHERE consolidated_at IS NULL
AND consolidation_failed_at IS NULL
AND fact_type IN ('experience', 'world')
) AS pending,
COUNT(*) FILTER (WHERE consolidation_failed_at IS NOT NULL AND fact_type IN ('experience', 'world')) AS failed
FROM {fq_table("memory_units")}
WHERE bank_id = $1
""",
bank_id,
)
if row is None:
return {"last_consolidated_at": None, "last_memory_write_at": None, "pending": 0, "failed": 0}
return {
"last_consolidated_at": row["last_consolidated_at"],
"last_memory_write_at": row["last_memory_write_at"],
"pending": row["pending"] or 0,
"failed": row["failed"] or 0,
}
async def link_counts(*, conn, fq_table: Callable[[str], str], bank_id: str) -> dict[str, int]:
"""``{link_type: count}`` of live links in a bank.
Non-entity links (temporal / semantic / caused_by) are a single ``GROUP BY`` over
``memory_links``. Entity links are no longer stored there they are derived on demand
from ``unit_entities``, replicating the historical writer cap of ``MAX_LINKS_PER_ENTITY``
bidirectional edges per shared entity so they are aggregated to one ``entity`` scalar.
"""
max_links_per_entity = 10
non_entity_link_rows = await conn.fetch(
f"""
SELECT link_type, COUNT(*) as count
FROM {fq_table("memory_links")}
WHERE bank_id = $1
GROUP BY link_type
""",
bank_id,
)
entity_total_row = await conn.fetchrow(
f"""
WITH per_entity AS (
SELECT ue.entity_id, COUNT(*) AS n
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("memory_units")} mu ON mu.id = ue.unit_id
WHERE mu.bank_id = $1
GROUP BY ue.entity_id
)
SELECT COALESCE(SUM(LEAST(n - 1, $2)), 0)::bigint AS count
FROM per_entity
""",
bank_id,
max_links_per_entity,
)
entity_link_total = int(entity_total_row["count"] or 0) if entity_total_row else 0
counts: dict[str, int] = {row["link_type"]: row["count"] for row in non_entity_link_rows}
if entity_link_total > 0:
counts["entity"] = entity_link_total
return counts
async def document_memory_counts(
*, conn, fq_table: Callable[[str], str], bank_id: str, document_ids: list[str]
) -> dict[str, int]:
"""Live memory count per document id, for the ids given."""
if not document_ids:
return {}
rows = await conn.fetch(
f"""
SELECT document_id, COUNT(*) AS unit_count
FROM {fq_table("memory_units")}
WHERE bank_id = $1 AND document_id = ANY($2::text[])
GROUP BY document_id
""",
bank_id,
list(document_ids),
)
return {row["document_id"]: row["unit_count"] for row in rows}
async def memories_timeseries(
*, conn, fq_table: Callable[[str], str], bank_id: str, time_field: str, trunc: str, since: datetime
) -> list[dict[str, Any]]:
"""Memories bucketed by ``time_field`` (truncated to ``trunc``) and fact_type.
``time_field`` is whitelisted by the caller before it reaches here it is
interpolated into SQL. Event-time fields fall back to ``created_at`` per row so
rows without an event timestamp still appear.
"""
bucket_expr = time_field if time_field == "created_at" else f"COALESCE({time_field}, created_at)"
rows = await conn.fetch(
f"""
SELECT date_trunc('{trunc}', {bucket_expr} AT TIME ZONE 'UTC') AS bucket,
fact_type, COUNT(*) AS count
FROM {fq_table("memory_units")}
WHERE bank_id = $1 AND {bucket_expr} >= $2
GROUP BY bucket, fact_type
ORDER BY bucket
""",
bank_id,
since,
)
return [{"bucket": r["bucket"], "fact_type": r["fact_type"], "count": r["count"]} for r in rows]
async def observation_scope_counts(*, conn, fq_table: Callable[[str], str], bank_id: str) -> list[dict[str, Any]]:
"""Observations grouped by scope (their sorted tag set), most-populous first."""
rows = await conn.fetch(
f"""
SELECT scope, COUNT(*) AS count
FROM (
SELECT COALESCE(ARRAY(SELECT unnest(tags) ORDER BY 1), '{{}}'::text[]) AS scope
FROM {fq_table("memory_units")}
WHERE bank_id = $1 AND fact_type = 'observation'
) s
GROUP BY scope
ORDER BY count DESC, scope
""",
bank_id,
)
return [{"tags": list(r["scope"]), "count": r["count"]} for r in rows]
__all__ = [
"consolidation_freshness",
"document_memory_counts",
"link_counts",
"memories_timeseries",
"observation_scope_counts",
]
@@ -0,0 +1,507 @@
"""Curation reads: the memory list, the memory detail view, and the entity list.
These back the curation UI the table of memories a bank holds, the detail panel
for one of them, and the entity roster beside it. They are paged and filtered
rather than ranked: nothing here scores anything, and nothing walks the corpus.
Two things separate them from the addressed reads in :mod:`reads`. They render
*view* dicts (ISO strings, joined entity names, a ``state`` discriminator) rather
than :class:`~hindsight_api.engine.memories.base.StoredMemory`, because the HTTP
layer serialises what comes back verbatim. And they read the archive as well as
the live table: curation moves an invalidated fact to `invalidated_memory_units`,
so "show me the invalidated ones" is a different table, not a different predicate.
Authentication, operation validation and audit stay with the engine methods that
call these only the queries and their row rendering live here.
"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
from ...search.tags import build_tags_where_clause
def _entity_rows_for_units_sql(*, ops, fq_table, unit_ids_placeholder: int) -> str:
"""SQL SELECT producing ``(unit_id, entity_id, canonical_name)`` rows for
the given unit IDs.
Direct rows come from ``unit_entities``. Observations rarely carry
direct rows there; their entity association lives transitively through
their source memories (``source_memory_ids`` on PG, the
``observation_sources`` junction on Oracle). When an observation has
no direct entity rows the SELECT inherits its source memories'
entities, so the result is the same set callers would get from
``get_memory_unit``.
``unit_ids_placeholder`` is the 1-based parameter index that holds the
``uuid[]`` of unit IDs. The placeholder is referenced twice both
sides of the UNION need it so callers should not reuse the slot.
"""
ue = fq_table("unit_entities")
ents = fq_table("entities")
mu = fq_table("memory_units")
p = unit_ids_placeholder
direct = (
f"SELECT ue.unit_id, e.id AS entity_id, e.canonical_name "
f"FROM {ue} ue "
f"JOIN {ents} e ON e.id = ue.entity_id "
f"WHERE ue.unit_id = ANY(${p}::uuid[])"
)
if ops.uses_observation_sources_table:
os_t = fq_table("observation_sources")
inherited = (
f"SELECT os.observation_id AS unit_id, e.id AS entity_id, e.canonical_name "
f"FROM {os_t} os "
f"JOIN {ue} src_ue ON src_ue.unit_id = os.source_id "
f"JOIN {ents} e ON e.id = src_ue.entity_id "
f"WHERE os.observation_id = ANY(${p}::uuid[]) "
f"AND NOT EXISTS (SELECT 1 FROM {ue} d WHERE d.unit_id = os.observation_id)"
)
else:
inherited = (
f"SELECT obs.id AS unit_id, e.id AS entity_id, e.canonical_name "
f"FROM {mu} obs "
f"CROSS JOIN LATERAL unnest(obs.source_memory_ids) AS src_id "
f"JOIN {ue} src_ue ON src_ue.unit_id = src_id "
f"JOIN {ents} e ON e.id = src_ue.entity_id "
f"WHERE obs.id = ANY(${p}::uuid[]) "
f"AND obs.fact_type = 'observation' "
f"AND obs.source_memory_ids IS NOT NULL "
f"AND NOT EXISTS (SELECT 1 FROM {ue} d WHERE d.unit_id = obs.id)"
)
return f"({direct}) UNION ({inherited})"
async def list_memory_units(
*,
conn,
ops,
fq_table,
bank_id: str,
fact_type: str | None = None,
search_query: str | None = None,
consolidation_state: str | None = None,
state: str | None = None,
document_id: str | None = None,
entity_id: str | None = None,
tags: list[str] | None = None,
tags_match: str = "any",
created_before: datetime | None = None,
limit: int = 100,
offset: int = 0,
) -> dict[str, Any]:
"""
List memory units for table view with optional full-text search.
Args:
conn: Open database connection (the caller owns the transaction).
ops: Dialect ops. Unused by this query; part of the interface signature.
fq_table: Table-name resolver.
bank_id: Filter by bank ID
fact_type: Filter by fact type (world, experience)
search_query: Full-text search query (searches text and context fields)
document_id: Optional filter to a single source document.
tags: Optional list of tag names to filter by. When omitted, no tag
filtering is applied (except tags_match='exact', which then selects
the untagged/global scope).
tags_match: How to combine tags (same modes as recall): 'any' (OR,
default) or 'all' (AND) both also include untagged units;
'any_strict'/'all_strict' exclude untagged units; 'exact' matches
units whose tag set equals the given tags exactly.
state: Optional curation-state filter ('valid' or 'invalidated').
Invalidated facts live in a separate archive table; 'invalidated'
reads that archive. Omitted/('valid') lists live facts.
consolidation_state: Optional filter on consolidation state. One of
'failed' (consolidation permanently failed and awaiting recovery),
'pending' (not yet consolidated, no failure), or
'done' (successfully consolidated). Only applies to source memory
types (world/experience).
limit: Maximum number of results to return
offset: Offset for pagination
Returns:
Dict with items (list of memory units) and total count
"""
if state is not None and state not in ("valid", "invalidated"):
raise ValueError(f"Invalid state '{state}': expected 'valid' or 'invalidated'.")
if entity_id is not None:
import uuid as _uuid
try:
_uuid.UUID(entity_id)
except ValueError:
raise ValueError(f"Invalid entity_id: '{entity_id}' is not a valid UUID") from None
# Invalidated facts live in a separate archive table; pick the source
# accordingly. Default (state is None) lists live facts.
is_archived = state == "invalidated"
source_table = fq_table("invalidated_memory_units") if is_archived else fq_table("memory_units")
# Build query conditions
query_conditions = []
query_params = []
param_count = 0
if bank_id:
param_count += 1
query_conditions.append(f"bank_id = ${param_count}")
query_params.append(bank_id)
if fact_type:
param_count += 1
query_conditions.append(f"fact_type = ${param_count}")
query_params.append(fact_type)
if document_id:
param_count += 1
query_conditions.append(f"document_id = ${param_count}")
query_params.append(document_id)
if entity_id:
# Reverse lookup via the stored entity links. Entity links reference live memory units, so
# this yields nothing against the invalidated archive (documented on the method).
param_count += 1
query_conditions.append(
f"id IN (SELECT unit_id FROM {fq_table('unit_entities')} WHERE entity_id = ${param_count}::uuid)"
)
query_params.append(entity_id)
if search_query:
# Full-text search on text and context fields using ILIKE
param_count += 1
query_conditions.append(f"(text ILIKE ${param_count} OR context ILIKE ${param_count})")
query_params.append(f"%{search_query}%")
if consolidation_state:
# Named apart from `state`, which the engine method used to shadow here;
# `is_archived` was already resolved above, so behaviour is unchanged.
wanted = consolidation_state.lower()
if wanted == "failed":
query_conditions.append("consolidation_failed_at IS NOT NULL AND fact_type IN ('experience', 'world')")
elif wanted == "pending":
query_conditions.append(
"consolidated_at IS NULL AND consolidation_failed_at IS NULL AND fact_type IN ('experience', 'world')"
)
elif wanted == "done":
query_conditions.append("consolidated_at IS NOT NULL AND fact_type IN ('experience', 'world')")
else:
raise ValueError(
f"Invalid consolidation_state '{consolidation_state}': expected 'failed', 'pending', or 'done'."
)
if tags:
tags_clause, tags_params, next_param = build_tags_where_clause(tags, param_count + 1, "", tags_match)
if tags_clause:
query_conditions.append(tags_clause.removeprefix("AND "))
query_params.extend(tags_params)
param_count = next_param - 1
elif tags_match == "exact":
# Exact match with no tags is the "global" scope: rows that carry no
# tags at all. (Other match modes treat empty tags as "no filter".)
query_conditions.append("(tags IS NULL OR tags = '{}')")
if created_before is not None:
param_count += 1
query_conditions.append(f"created_at < ${param_count}")
query_params.append(created_before)
where_clause = "WHERE " + " AND ".join(query_conditions) if query_conditions else ""
# Get total count
count_query = f"""
SELECT COUNT(*) as total
FROM {source_table}
{where_clause}
"""
count_result = await conn.fetchrow(count_query, *query_params)
total = count_result["total"]
# Get units with limit and offset
param_count += 1
limit_param = f"${param_count}"
query_params.append(limit)
param_count += 1
offset_param = f"${param_count}"
query_params.append(offset)
# The archive carries invalidation bookkeeping; the live table doesn't.
curation_cols = (
"invalidation_reason, invalidated_at"
if is_archived
else "NULL::text AS invalidation_reason, NULL::timestamptz AS invalidated_at"
)
units = await conn.fetch(
f"""
SELECT id, text, event_date, context, fact_type, document_id,
mentioned_at, occurred_start, occurred_end, chunk_id, proof_count,
tags, metadata, consolidated_at, consolidation_failed_at, edited_at, {curation_cols}
FROM {source_table}
{where_clause}
ORDER BY mentioned_at DESC NULLS LAST, created_at DESC
LIMIT {limit_param} OFFSET {offset_param}
""",
*query_params,
)
# Get entity information for these units
if units:
unit_ids = [row["id"] for row in units]
unit_entities = await conn.fetch(
f"""
SELECT ue.unit_id, e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON ue.entity_id = e.id
WHERE ue.unit_id = ANY($1::uuid[])
ORDER BY ue.unit_id
""",
unit_ids,
)
else:
unit_entities = []
# Build entity mapping
entity_map: dict[Any, list[str]] = {}
for row in unit_entities:
unit_id = row["unit_id"]
entity_name = row["canonical_name"]
if unit_id not in entity_map:
entity_map[unit_id] = []
entity_map[unit_id].append(entity_name)
# Build result items
items = []
for row in units:
unit_id = row["id"]
entities = entity_map.get(unit_id, [])
items.append(
{
"id": str(unit_id),
"text": row["text"],
"context": row["context"] if row["context"] else "",
"date": row["event_date"].isoformat() if row["event_date"] else "",
"fact_type": row["fact_type"],
"document_id": row["document_id"],
"mentioned_at": row["mentioned_at"].isoformat() if row["mentioned_at"] else None,
"occurred_start": row["occurred_start"].isoformat() if row["occurred_start"] else None,
"occurred_end": row["occurred_end"].isoformat() if row["occurred_end"] else None,
"entities": ", ".join(entities) if entities else "",
"chunk_id": row["chunk_id"] if row["chunk_id"] else None,
"proof_count": row["proof_count"] if row["proof_count"] is not None else 1,
"tags": list(row["tags"]) if row["tags"] else [],
"metadata": conn.parse_json(row["metadata"]) if row["metadata"] is not None else {},
"consolidated_at": row["consolidated_at"].isoformat() if row["consolidated_at"] else None,
"consolidation_failed_at": (
row["consolidation_failed_at"].isoformat() if row["consolidation_failed_at"] else None
),
"state": "invalidated" if is_archived else "valid",
"invalidation_reason": row["invalidation_reason"],
"invalidated_at": row["invalidated_at"].isoformat() if row["invalidated_at"] else None,
"edited_at": row["edited_at"].isoformat() if row["edited_at"] else None,
}
)
return {"items": items, "total": total, "limit": limit, "offset": offset}
async def get_memory_unit(*, conn, ops, fq_table, bank_id: str, unit_id: str) -> dict[str, Any] | None:
"""
Get a single memory unit by ID.
Args:
conn: Open database connection (the caller owns the transaction).
ops: Dialect ops, for the observationsource entity inheritance shape.
fq_table: Table-name resolver.
bank_id: Bank ID
unit_id: Memory unit ID (the caller validates it is a UUID)
Returns:
Dict with memory unit data or None if not found
"""
# Get the memory unit (include source_memory_ids for mental models).
# Curation moves invalidated facts to invalidated_memory_units, so fall
# back to the archive (with its invalidation bookkeeping) on a miss.
select_cols = (
"id, text, context, event_date, occurred_start, occurred_end, "
"mentioned_at, fact_type, document_id, chunk_id, tags, metadata, source_memory_ids, "
"observation_scopes, edited_at"
)
row = await conn.fetchrow(
f"SELECT {select_cols}, NULL::text AS invalidation_reason, NULL::timestamptz AS invalidated_at "
f"FROM {fq_table('memory_units')} WHERE id = $1 AND bank_id = $2",
unit_id,
bank_id,
)
unit_state = "valid"
if not row:
row = await conn.fetchrow(
f"SELECT {select_cols}, invalidation_reason, invalidated_at "
f"FROM {fq_table('invalidated_memory_units')} WHERE id = $1 AND bank_id = $2",
unit_id,
bank_id,
)
unit_state = "invalidated"
if not row:
return None
# Get entity information. _entity_rows_for_units_sql handles the
# observation→source_memory_ids inheritance fallback in SQL, so a
# single query covers direct rows and inherited ones.
entities_rows = await conn.fetch(
_entity_rows_for_units_sql(ops=ops, fq_table=fq_table, unit_ids_placeholder=1),
[row["id"]],
)
entities = [r["canonical_name"] for r in entities_rows]
result: dict[str, Any] = {
"id": str(row["id"]),
"text": row["text"],
"context": row["context"] if row["context"] else "",
"date": row["event_date"].isoformat() if row["event_date"] else "",
"type": row["fact_type"],
"mentioned_at": row["mentioned_at"].isoformat() if row["mentioned_at"] else None,
"occurred_start": row["occurred_start"].isoformat() if row["occurred_start"] else None,
"occurred_end": row["occurred_end"].isoformat() if row["occurred_end"] else None,
"entities": entities,
"document_id": row["document_id"] if row["document_id"] else None,
"chunk_id": str(row["chunk_id"]) if row["chunk_id"] else None,
"tags": row["tags"] if row["tags"] else [],
"metadata": conn.parse_json(row["metadata"]) if row["metadata"] is not None else {},
"observation_scopes": (
conn.parse_json(row["observation_scopes"]) if row["observation_scopes"] is not None else None
),
"state": unit_state,
"invalidation_reason": row["invalidation_reason"],
"invalidated_at": row["invalidated_at"].isoformat() if row["invalidated_at"] else None,
"edited_at": row["edited_at"].isoformat() if row["edited_at"] else None,
}
# For observations, include source_memory_ids
# history is deprecated here - use GET /memories/{id}/history instead
if row["fact_type"] == "observation":
result["history"] = []
if row["fact_type"] == "observation" and row["source_memory_ids"]:
source_ids = row["source_memory_ids"]
result["source_memory_ids"] = [str(sid) for sid in source_ids]
# Fetch source memories
source_rows = await conn.fetch(
f"""
SELECT id, text, fact_type, context, occurred_start, mentioned_at
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
ORDER BY mentioned_at DESC NULLS LAST
""",
source_ids,
)
result["source_memories"] = [
{
"id": str(r["id"]),
"text": r["text"],
"type": r["fact_type"],
"context": r["context"],
"occurred_start": r["occurred_start"].isoformat() if r["occurred_start"] else None,
"mentioned_at": r["mentioned_at"].isoformat() if r["mentioned_at"] else None,
}
for r in source_rows
]
return result
async def list_entities(
*,
conn,
fq_table,
bank_id: str,
search: str | None = None,
limit: int = 100,
offset: int = 0,
) -> dict[str, Any]:
"""
List all entities for a bank with pagination.
Args:
conn: Open database connection (the caller owns the transaction).
fq_table: Table-name resolver.
bank_id: bank IDentifier
search: Optional case-insensitive substring match on canonical_name.
limit: Maximum number of entities to return
offset: Offset for pagination
Returns:
Dict with items, total, limit, offset
"""
conditions = ["bank_id = $1"]
params: list[Any] = [bank_id]
if search:
# Substring match, same ILIKE shape entity lookup uses elsewhere. Applied
# to the count too, so the UI pages over the filtered set.
params.append(f"%{search}%")
conditions.append(f"canonical_name ILIKE ${len(params)}")
where_clause = " AND ".join(conditions)
# Get total count
total_row = await conn.fetchrow(
f"""
SELECT COUNT(*) as total
FROM {fq_table("entities")}
WHERE {where_clause}
""",
*params,
)
total = total_row["total"] if total_row else 0
# Get paginated entities
rows = await conn.fetch(
f"""
SELECT id, canonical_name, mention_count, first_seen, last_seen, metadata
FROM {fq_table("entities")}
WHERE {where_clause}
ORDER BY mention_count DESC, last_seen DESC, id ASC
LIMIT ${len(params) + 1} OFFSET ${len(params) + 2}
""",
*params,
limit,
offset,
)
entities = []
for row in rows:
# Handle metadata - may be dict, JSON string, or None
metadata = row["metadata"]
if metadata is None:
metadata = {}
elif isinstance(metadata, str):
try:
metadata = json.loads(metadata)
except json.JSONDecodeError:
metadata = {}
entities.append(
{
"id": str(row["id"]),
"canonical_name": row["canonical_name"],
"mention_count": row["mention_count"],
"first_seen": row["first_seen"].isoformat() if row["first_seen"] else None,
"last_seen": row["last_seen"].isoformat() if row["last_seen"] else None,
"metadata": metadata,
}
)
return {
"items": entities,
"total": total,
"limit": limit,
"offset": offset,
}
__all__ = ["get_memory_unit", "list_entities", "list_memory_units"]
@@ -0,0 +1,997 @@
"""Graph-shaped reads and the link-maintenance passes, in SQL.
Everything here is a query over the *joins* around `memory_units` rather than
over the memories themselves: `unit_entities` (which entities a memory mentions)
and `memory_links` (memory-to-memory temporal/semantic/causal edges).
Two groups of callers:
* **The graph view.** :func:`graph_units`, :func:`graph_entity_rows` and
:func:`graph_direct_links` return raw rows; the engine still owns the
filtering, the observation inheritance, the derived entity edges, the
colouring and the response assembly. These functions answer only "which
memories", "which entity postings" and "which stored edges".
* **The graph-maintenance job.** :func:`enqueue_relink_victims` and
:func:`enqueue_entity_prune_candidates` run inside the delete transaction;
:func:`relink_pass` and :func:`entity_prune_pass` are the two drain loops the
job drives. The job keeps the orchestration (pass ordering, the time budget,
the timing log); each function here does the pass's work.
:func:`entity_memory_counts` and :func:`entities_for_units` are the two entity
postings reads that are not part of the graph view but read the same join table.
A store whose links travel inside the memory has nothing to relink and no join
table to sweep, which is why these are methods on the interface at all: it
answers them with zeroes rather than with SQL.
"""
from __future__ import annotations
import logging
import time
import uuid as uuid_module
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from ....config import get_config
from ...db.base import DatabaseConnection
from ...retain.link_utils import (
MAX_TEMPORAL_LINKS_PER_UNIT,
_bulk_insert_links,
_normalize_datetime,
compute_semantic_links_ann,
)
from ..base import EntityPrunePassResult, RelinkPassResult
logger = logging.getLogger(__name__)
# Mirrors the ``top_k`` default in ``compute_semantic_links_ann`` at retain
# time. If you change one, change the other — otherwise victims would either
# never reach the cap (probe returns less than the cap) or stay perpetually
# under it (cap is higher than retain creates).
MAX_SEMANTIC_LINKS_PER_UNIT = 50
# Worker fetches this many rows per relink-loop iteration. Bounds
# per-iteration probe/insert latency so a 10k-row backlog doesn't hold a
# worker slot for minutes. Chosen so the typical iteration runs in well
# under 1s.
_DRAIN_BATCH_SIZE = 50
# Defensive guard against runaway relink loops — at _DRAIN_BATCH_SIZE units per
# iteration that's 500k targets, far beyond any realistic single-bank backlog.
_RELINK_ITERATION_CAP = 10000
# Candidate entities claimed per entity-prune iteration.
#
# The binding cost is the cooccurrence prune: a candidate drags in every
# cooccurrence pair it appears in, and the statement builds a set of currently
# live pairs (#3367) seeded from those candidates' units to judge them against.
# Measured on a deliberately dense fixture (100k entities, 1.5M unit_entities,
# 2.86M cooccurrences, endpoints holding 150-400 postings each):
#
# batch 50 → 16-65ms <- here
# batch 500 → 265s <- the planner flips to a per-row plan and the
# statement blows the 60s command timeout
#
# So the batch size, not the bank size, is what has to stay bounded — and it has
# to stay small enough that the planner keeps choosing the hash anti-join.
# Raising it trades away three orders of magnitude of margin; don't, without
# re-measuring against a bank with hub entities.
#
# Also stays under Oracle's 1000-element IN-list limit, since ops_oracle expands
# ``= ANY(...)`` into an explicit list.
_ENTITY_PRUNE_BATCH_SIZE = 50
# Deadlock retries per entity-prune batch. The batch is idempotent, so a retry
# only re-deletes what is still dead; a handful of attempts clears the
# contention window a concurrent retain opens.
_PRUNE_BATCH_MAX_RETRIES = 3
# Unit ids per candidate-lookup round-trip when enqueueing. Bounded by Oracle's
# 1000-element IN-list limit (ops_oracle expands ``= ANY(...)`` into a literal
# list), which a bulk delete would otherwise blow straight through.
_ENQUEUE_LOOKUP_CHUNK = 500
# Cap at 10k edges — the UI can't usefully render more, and uncapped queries
# on highly-connected graphs (e.g. 1000 nodes with 500k+ edges) are too slow.
_GRAPH_MAX_EDGES = 10000
# Columns the graph view renders: nodes take id/text/date/context/entities,
# the table rows take the rest, and `source_memory_ids` is what lets the caller
# inherit an observation's links and entities from the facts behind it.
_GRAPH_UNIT_COLUMNS = (
"id, text, event_date, context, occurred_start, occurred_end, mentioned_at, "
"document_id, chunk_id, fact_type, tags, created_at, proof_count, source_memory_ids"
)
def _ops_for(conn: DatabaseConnection) -> Any:
"""The ``DataAccessOps`` matching the connection's SQL dialect.
This is the SQL memories store, and SQL means Postgres *or* Oracle the two
speak different dialects (Oracle inherits entity links through the
``observation_sources`` junction, Postgres through ``source_memory_ids``
arrays), so the ops must follow the connection rather than assume Postgres.
The ops go by ``conn.backend_type`` the connection objects carry the dialect
but not the backend's ``ops`` handle, so resolve through the per-dialect cache
of ``create_data_access_ops`` (a dict lookup after the first call, and the same
instance the backend holds). The default covers callers that hand in a bare
asyncpg connection with no dialect to report.
"""
from ...db import create_data_access_ops
return create_data_access_ops(getattr(conn, "backend_type", "postgresql"))
def _as_uuids(unit_ids: list) -> list:
"""Coerce a mixed list of uuid strings / UUIDs to UUIDs for a ``uuid[]`` bind."""
return [uuid_module.UUID(uid) if isinstance(uid, str) else uid for uid in unit_ids]
# ---------------------------------------------------------------- graph view
def _observations_via_source_match(
fq_table: Callable[[str], str],
ops: Any,
source_column: str,
source_placeholder: int,
bank_placeholder: int | None,
) -> str:
"""A predicate matching observations whose *sources* satisfy ``<col> = $n``.
Observations carry no `document_id` / `chunk_id` of their own; the link to a
source row lives in `source_memory_ids` (native array) or the
`observation_sources` junction, depending on the dialect.
"""
if ops.uses_observation_sources_table:
bank_clause = f" AND src.bank_id = ${bank_placeholder}" if bank_placeholder else ""
return (
f"id IN (SELECT os.observation_id "
f"FROM {fq_table('observation_sources')} os "
f"JOIN {fq_table('memory_units')} src ON src.id = os.source_id "
f"WHERE src.{source_column} = ${source_placeholder}{bank_clause})"
)
bank_clause = f" AND bank_id = ${bank_placeholder}" if bank_placeholder else ""
return (
f"source_memory_ids && (SELECT array_agg(id) "
f"FROM {fq_table('memory_units')} "
f"WHERE {source_column} = ${source_placeholder}{bank_clause})"
)
async def graph_units(
*,
conn: DatabaseConnection,
fq_table: Callable[[str], str],
bank_id: str | None = None,
fact_type: str | None = None,
search_query: str | None = None,
document_id: str | None = None,
chunk_id: str | None = None,
tags: list[str] | None = None,
tags_match: str = "all_strict",
limit: int = 1000,
) -> dict[str, Any]:
"""Memory nodes for the graph view, plus the total matching count.
Returns ``{"units": [...], "total": int}``: ``units`` is the page (newest
first, capped at ``limit``); ``total`` is how many match the filters, which
the UI shows alongside the page. ``document_id`` / ``chunk_id`` also match an
observation whose *sources* carry them, since observations have neither of
their own.
"""
from ...search.tags import build_tags_where_clause_simple
ops = _ops_for(conn)
conditions: list[str] = []
params: list[Any] = []
bank_placeholder: int | None = None
if bank_id:
params.append(bank_id)
bank_placeholder = len(params)
conditions.append(f"bank_id = ${bank_placeholder}")
if fact_type:
params.append(fact_type)
conditions.append(f"fact_type = ${len(params)}")
if document_id:
params.append(document_id)
obs = _observations_via_source_match(fq_table, ops, "document_id", len(params), bank_placeholder)
conditions.append(f"(document_id = ${len(params)} OR (fact_type = 'observation' AND {obs}))")
if chunk_id:
params.append(chunk_id)
obs = _observations_via_source_match(fq_table, ops, "chunk_id", len(params), bank_placeholder)
conditions.append(f"(chunk_id = ${len(params)} OR (fact_type = 'observation' AND {obs}))")
if search_query:
params.append(f"%{search_query}%")
conditions.append(f"(text ILIKE ${len(params)} OR context ILIKE ${len(params)})")
if tags:
tag_clause = build_tags_where_clause_simple(tags, len(params) + 1, match=tags_match)
if tag_clause:
conditions.append(tag_clause.removeprefix("AND "))
params.append(tags)
elif tags_match == "exact":
# Exact match with no tags is the "global" scope: rows carrying no tags at
# all. (Other modes treat empty tags as "no filter".)
conditions.append("(tags IS NULL OR tags = '{}')")
where_clause = "WHERE " + " AND ".join(conditions) if conditions else ""
total_row = await conn.fetchrow(
f"SELECT COUNT(*) AS total FROM {fq_table('memory_units')} {where_clause}",
*params,
)
total = total_row["total"] if total_row else 0
params.append(limit)
rows = await conn.fetch(
f"""
SELECT {_GRAPH_UNIT_COLUMNS}
FROM {fq_table("memory_units")}
{where_clause}
ORDER BY mentioned_at DESC NULLS LAST, event_date DESC
LIMIT ${len(params)}
""",
*params,
)
return {"units": [dict(row) for row in rows], "total": total}
async def graph_entity_rows(
*,
conn: DatabaseConnection,
fq_table: Callable[[str], str],
bank_id: str,
unit_ids: list[str],
) -> list[dict[str, Any]]:
"""``(unit_id, entity_id, canonical_name)`` rows for the graph view's entity edges.
Direct `unit_entities` postings only. An observation's entities are inherited
from its source memories by the caller, which is why the ids it passes here
are the visible units *plus* their source memories.
Scoped by unit id rather than by bank: the ids already came from a
bank-scoped :func:`graph_units`, and `unit_entities` carries no bank column.
"""
if not unit_ids:
return []
rows = await conn.fetch(
f"""
SELECT ue.unit_id, e.id AS entity_id, e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON ue.entity_id = e.id
WHERE ue.unit_id = ANY($1::uuid[])
ORDER BY ue.unit_id
""",
_as_uuids(unit_ids),
)
return [dict(row) for row in rows]
async def graph_direct_links(
*,
conn: DatabaseConnection,
fq_table: Callable[[str], str],
bank_id: str,
unit_ids: list[str],
) -> list[dict[str, Any]]:
"""Memory-to-memory edges with *both* endpoints in ``unit_ids``.
Entity edges are derived by the caller from `unit_entities` so we don't
materialize them in `memory_links` anymore (dropped in migration
e9b2c7d1f3a4) no link_type filter is needed. ``entity_name`` is selected as
NULL so the row shape matches the derived edges the caller mixes these with.
Pass the visible units *and* the source memories they inherit from: the
caller copies a source memory's links onto the observations built on it.
"""
if not unit_ids:
return []
rows = await conn.fetch(
f"""
SELECT ml.from_unit_id,
ml.to_unit_id,
ml.link_type,
ml.weight,
NULL::text AS entity_name
FROM {fq_table("memory_links")} ml
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.to_unit_id = ANY($1::uuid[])
ORDER BY ml.weight DESC NULLS LAST
LIMIT $2
""",
_as_uuids(unit_ids),
_GRAPH_MAX_EDGES,
)
return [dict(row) for row in rows]
# ------------------------------------------------------------ entity postings
async def entity_memory_counts(
*,
conn: DatabaseConnection,
fq_table: Callable[[str], str],
bank_id: str,
entity_ids: list[str] | None = None,
) -> dict[str, int]:
"""Live memory count per entity id, for the entities in ``bank_id``.
The GROUP BY is what makes this an orphan test: an entity with no surviving
`unit_entities` row produces no group, so it is simply absent from the
result rather than present with a zero.
Scoped through ``memory_units.bank_id`` `unit_entities` has no bank column,
and joining is what keeps the count to *live* memories (deleted units take
their postings with them via ON DELETE CASCADE).
"""
params: list[Any] = [bank_id]
entity_filter = ""
if entity_ids is not None:
if not entity_ids:
return {}
params.append(_as_uuids(entity_ids))
entity_filter = f"AND ue.entity_id = ANY(${len(params)}::uuid[])"
rows = await conn.fetch(
f"""
SELECT ue.entity_id, COUNT(*) AS n
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("memory_units")} mu ON mu.id = ue.unit_id
WHERE mu.bank_id = $1
{entity_filter}
GROUP BY ue.entity_id
""",
*params,
)
return {str(row["entity_id"]): int(row["n"]) for row in rows}
def _entity_rows_for_units_sql(
fq_table: Callable[[str], str],
ops: Any,
unit_ids_placeholder: int,
) -> str:
"""SQL SELECT producing ``(unit_id, entity_id, canonical_name)`` rows for
the given unit IDs.
Direct rows come from ``unit_entities``. Observations rarely carry
direct rows there; their entity association lives transitively through
their source memories (``source_memory_ids`` on PG, the
``observation_sources`` junction on Oracle). When an observation has
no direct entity rows the SELECT inherits its source memories'
entities, so the result is the same set callers would get from
``get_memory_unit``.
``unit_ids_placeholder`` is the 1-based parameter index that holds the
``uuid[]`` of unit IDs. The placeholder is referenced twice both
sides of the UNION need it so callers should not reuse the slot.
"""
ue = fq_table("unit_entities")
ents = fq_table("entities")
mu = fq_table("memory_units")
p = unit_ids_placeholder
direct = (
f"SELECT ue.unit_id, e.id AS entity_id, e.canonical_name "
f"FROM {ue} ue "
f"JOIN {ents} e ON e.id = ue.entity_id "
f"WHERE ue.unit_id = ANY(${p}::uuid[])"
)
if ops.uses_observation_sources_table:
os_t = fq_table("observation_sources")
inherited = (
f"SELECT os.observation_id AS unit_id, e.id AS entity_id, e.canonical_name "
f"FROM {os_t} os "
f"JOIN {ue} src_ue ON src_ue.unit_id = os.source_id "
f"JOIN {ents} e ON e.id = src_ue.entity_id "
f"WHERE os.observation_id = ANY(${p}::uuid[]) "
f"AND NOT EXISTS (SELECT 1 FROM {ue} d WHERE d.unit_id = os.observation_id)"
)
else:
inherited = (
f"SELECT obs.id AS unit_id, e.id AS entity_id, e.canonical_name "
f"FROM {mu} obs "
f"CROSS JOIN LATERAL unnest(obs.source_memory_ids) AS src_id "
f"JOIN {ue} src_ue ON src_ue.unit_id = src_id "
f"JOIN {ents} e ON e.id = src_ue.entity_id "
f"WHERE obs.id = ANY(${p}::uuid[]) "
f"AND obs.fact_type = 'observation' "
f"AND obs.source_memory_ids IS NOT NULL "
f"AND NOT EXISTS (SELECT 1 FROM {ue} d WHERE d.unit_id = obs.id)"
)
return f"({direct}) UNION ({inherited})"
async def entities_for_units(
*,
conn: DatabaseConnection,
fq_table: Callable[[str], str],
bank_id: str,
unit_ids: list[str],
) -> dict[str, list[str]]:
"""The entity ids each unit carries, keyed by unit id.
Observations inherit their source memories' entities when they carry no
direct postings of their own see :func:`_entity_rows_for_units_sql`. Units
with no entities are absent rather than mapped to an empty list.
"""
if not unit_ids:
return {}
rows = await conn.fetch(
_entity_rows_for_units_sql(fq_table, _ops_for(conn), unit_ids_placeholder=1),
_as_uuids(unit_ids),
)
# UNION already de-duplicates whole rows, but a unit can reach the same
# entity through more than one source memory, so dedupe per unit while
# preserving the order the rows arrived in.
by_unit: dict[str, list[str]] = {}
for row in rows:
unit_key = str(row["unit_id"])
entity_id = str(row["entity_id"])
bucket = by_unit.setdefault(unit_key, [])
if entity_id not in bucket:
bucket.append(entity_id)
return by_unit
async def entity_map_for_units(
*,
conn: DatabaseConnection,
fq_table: Callable[[str], str],
bank_id: str,
unit_ids: list[str],
) -> dict[str, list[dict[str, str]]]:
"""``{unit_id: [{entity_id, canonical_name}]}`` — the recall/curation shape.
The named twin of :func:`entities_for_units`: recall renders the entity name
on each fact, so it needs the label, not just the id. Observation-via-source
inheritance and the per-unit dedupe are identical.
"""
if not unit_ids:
return {}
rows = await conn.fetch(
_entity_rows_for_units_sql(fq_table, _ops_for(conn), unit_ids_placeholder=1),
_as_uuids(unit_ids),
)
by_unit: dict[str, list[dict[str, str]]] = {}
for row in rows:
unit_key = str(row["unit_id"])
entity_id = str(row["entity_id"])
bucket = by_unit.setdefault(unit_key, [])
if not any(existing["entity_id"] == entity_id for existing in bucket):
bucket.append({"entity_id": entity_id, "canonical_name": row["canonical_name"]})
return by_unit
async def resolve_entity_names(
*,
conn: DatabaseConnection,
fq_table: Callable[[str], str],
bank_id: str,
entity_ids: list[str],
) -> dict[str, str]:
"""``{entity_id: canonical_name}`` for the given ids, scoped to ``bank_id``.
The label half of :func:`entity_map_for_units`, for a backend that already
carries a unit's entity ids on the recalled result: recall builds the
unit->entity map from those ids and needs only the names, so this resolves the
``entities`` registry once without re-fetching any memory. Bank-scoped like the
sibling registry reads (the ``entities`` table has a ``bank_id`` column).
Ids that don't parse as UUIDs are dropped rather than raised — a malformed id
on a store's result payload must not turn into a DB error mid-recall — and ids
with no registry row (or in another bank) are simply absent from the result.
"""
if not entity_ids:
return {}
# Bind ``uuid.UUID`` objects for the ``uuid[]`` param (repo convention, see
# ``_as_uuids``), but coerce defensively: skip anything unparseable instead of
# letting the whole resolve raise.
uuids: list = []
for raw in {str(e) for e in entity_ids}:
try:
uuids.append(uuid_module.UUID(raw))
except (ValueError, AttributeError, TypeError):
continue
if not uuids:
return {}
rows = await conn.fetch(
f"SELECT id, canonical_name FROM {fq_table('entities')} WHERE id = ANY($1::uuid[]) AND bank_id = $2",
uuids,
bank_id,
)
return {str(row["id"]): row["canonical_name"] for row in rows}
# --------------------------------------------------------------- maintenance
async def enqueue_relink_victims(
*,
conn: DatabaseConnection,
fq_table: Callable[[str], str],
bank_id: str,
affected_unit_ids: list,
include_affected_units: bool = False,
) -> int:
"""Enqueue surviving units whose outgoing temporal/semantic links pointed at
``affected_unit_ids`` for later link top-up.
Must run inside the same transaction that drops those links, *before* the
delete (or cascade) fires once the rows are gone, the join that finds the
victims returns nothing.
Args:
conn: Database connection inside the active transaction.
fq_table: Schema-qualifying table-name resolver.
bank_id: Bank owning the affected units.
affected_unit_ids: Memory_unit IDs whose incident temporal/semantic links
are about to be (or are being) removed.
include_affected_units: Also enqueue ``affected_unit_ids`` themselves for
an edit that deletes a unit's links but leaves the unit live, so its own
outgoing adjacency is rebuilt too. One combined insert keeps the queue's
sorted lock ordering intact.
Returns:
Number of distinct victim units enqueued (after dedup against rows
already in the queue).
"""
if not affected_unit_ids:
return 0
ops = _ops_for(conn)
affected_uuids = _as_uuids(affected_unit_ids)
affected_str_set = {str(uid) for uid in affected_uuids}
# Find units (other than the affected ones) that have an outgoing
# temporal/semantic link pointing at an affected unit. Entity links are
# intentionally excluded — they're scheduled for removal and would only
# add noise to the recompute job.
victim_rows = await conn.fetch(
f"""
SELECT DISTINCT from_unit_id
FROM {fq_table("memory_links")}
WHERE to_unit_id = ANY($1::uuid[])
AND bank_id = $2
AND link_type IN ('temporal', 'semantic')
""",
affected_uuids,
bank_id,
)
victim_ids = {row["from_unit_id"] for row in victim_rows if str(row["from_unit_id"]) not in affected_str_set}
if include_affected_units:
victim_ids.update(affected_uuids)
if not victim_ids:
return 0
await ops.enqueue_graph_maintenance(
conn,
fq_table("graph_maintenance_queue"),
bank_id,
list(victim_ids),
)
logger.debug(
f"[GRAPH_MAINT] Enqueued {len(victim_ids)} relink victims in "
f"bank={bank_id} ({len(affected_unit_ids)} units affected)"
)
return len(victim_ids)
async def relink_pass(
*,
backend: Any,
fq_table: Callable[[str], str],
bank_id: str,
config: Any,
deadline: float | None = None,
) -> RelinkPassResult:
"""Drain ``graph_maintenance_queue`` for ``bank_id``, topping up lost links.
Per-iteration loop: claim top up commit. We rely on at most one job per
bank running, so no need for SKIP LOCKED. Submit-time dedup alone does NOT
give that it only inspects 'pending' rows so the guarantee comes from
``claim_tasks``, which refuses to claim a graph_maintenance row for a bank
that already has one in flight (``graph_maintenance_bank_serialization_sql``,
#3230). Without it these claims convoy: they lock queue rows ``FOR UPDATE``
with no ``SKIP LOCKED``, so a second run blocks on the first while holding a
worker slot.
Takes ``backend`` rather than a connection because the loop spans several
transactions one per claimed batch, plus a separate connection for the ANN
probe so it has to acquire its own.
``config`` is the caller's resolved configuration. The Postgres pass takes
its caps from retain's link_utils (so relink and retain agree on what "full"
means) and never reads it; it is accepted so a store that *does* tune its
relinking gets it.
``deadline`` is a ``time.monotonic()`` value past which no new batch is
claimed. Each batch commits before the next is claimed, so stopping early
keeps the work already done and leaves the rest queued for the next run.
Returns:
A :class:`RelinkPassResult`. ``queue_exhausted`` is False when the
deadline (or the iteration cap) stopped the drain with rows still queued.
"""
del config # accepted for symmetry with stores that tune their own relinking
ops = backend.ops
units_processed = 0
links_added = 0
iterations = 0
drained = True
while True:
if deadline is not None and time.monotonic() >= deadline:
drained = False
break
from ...memory_engine import acquire_with_retry
async with acquire_with_retry(backend) as conn:
async with conn.transaction():
unit_ids = await ops.claim_graph_maintenance_batch(
conn,
fq_table("graph_maintenance_queue"),
bank_id,
_DRAIN_BATCH_SIZE,
)
if not unit_ids:
break
links_added += await _relink_batch(conn, fq_table, bank_id, unit_ids, ops, backend)
units_processed += len(unit_ids)
iterations += 1
if iterations > _RELINK_ITERATION_CAP:
# Defensive guard against runaway loops — at 50 units/iter that's
# 500k targets, far beyond any realistic single-bank backlog.
logger.error(
f"[GRAPH_MAINT] bank={bank_id} hit iteration cap ({iterations}); aborting relink "
f"(units_processed={units_processed}, links_added={links_added})"
)
drained = False
break
return RelinkPassResult(
units_processed=units_processed,
links_added=links_added,
queue_exhausted=drained,
)
async def _relink_batch(
conn: DatabaseConnection,
fq_table: Callable[[str], str],
bank_id: str,
victim_ids: list[str],
ops: Any,
backend: Any,
) -> int:
"""Top up temporal/semantic links for a batch of victim units. Returns rows inserted."""
# Load each victim's metadata. Victims whose units were deleted between
# enqueue and now silently drop out — exactly the no-op behaviour we want
# for stale queue rows.
victim_uuids = [uuid_module.UUID(vid) for vid in victim_ids]
victim_rows = await conn.fetch(
f"""
SELECT id::text AS id, event_date, fact_type, embedding::text AS embedding
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
AND bank_id = $2
AND fact_type IN ('experience', 'world')
""",
victim_uuids,
bank_id,
)
if not victim_rows:
return 0
alive_uuids = [uuid_module.UUID(row["id"]) for row in victim_rows]
# Count current outgoing temporal/semantic links per victim so we only
# probe for the ones genuinely below cap. Saves the bulk of the work when
# most victims still have plenty of links.
count_rows = await conn.fetch(
f"""
SELECT from_unit_id, link_type, COUNT(*) AS cnt
FROM {fq_table("memory_links")}
WHERE from_unit_id = ANY($1::uuid[])
AND bank_id = $2
AND link_type IN ('temporal', 'semantic')
GROUP BY from_unit_id, link_type
""",
alive_uuids,
bank_id,
)
counts: dict[tuple[str, str], int] = {}
for row in count_rows:
counts[(str(row["from_unit_id"]), row["link_type"])] = int(row["cnt"])
# --- Temporal top-up ---
temporal_needs = [r for r in victim_rows if counts.get((r["id"], "temporal"), 0) < MAX_TEMPORAL_LINKS_PER_UNIT]
new_links: list[tuple] = []
if temporal_needs:
lateral_unit_ids = [uuid_module.UUID(r["id"]) for r in temporal_needs if r["event_date"] is not None]
lateral_event_dates = [
_normalize_datetime(r["event_date"]) for r in temporal_needs if r["event_date"] is not None
]
lateral_fact_types = [r["fact_type"] for r in temporal_needs if r["event_date"] is not None]
if lateral_unit_ids:
rows = await ops.fetch_temporal_neighbors(
conn,
fq_table("memory_units"),
bank_id,
lateral_unit_ids,
lateral_event_dates,
lateral_fact_types,
MAX_TEMPORAL_LINKS_PER_UNIT,
)
for row in rows:
time_diff_h = float(row["time_diff_hours"])
# Mirror the 24h window enforced at retain time. The bidirectional
# index scan returns the K closest neighbours regardless of
# window, so we filter here.
if time_diff_h > 24:
continue
weight = max(0.3, 1.0 - (time_diff_h / 24))
new_links.append((row["from_id"], str(row["id"]), "temporal", weight, None))
# --- Semantic top-up ---
# ANN must run on its own connection: it opens a nested transaction with
# SET LOCAL hnsw.ef_search + CREATE TEMP TABLE ON COMMIT DROP, and nesting
# that inside our current write transaction would commit our writes early.
semantic_needs = [
r
for r in victim_rows
if counts.get((r["id"], "semantic"), 0) < MAX_SEMANTIC_LINKS_PER_UNIT and r["embedding"] is not None
]
if semantic_needs:
from ...memory_engine import acquire_with_retry
seed_ids = [r["id"] for r in semantic_needs]
seed_embs = [r["embedding"] for r in semantic_needs]
seed_ftypes = [r["fact_type"] for r in semantic_needs]
async with acquire_with_retry(backend) as ann_conn:
try:
ann_links = await compute_semantic_links_ann(
ann_conn,
bank_id,
seed_ids,
seed_embs,
fact_types=seed_ftypes,
threshold=get_config().semantic_link_min_similarity,
)
# Strip self-links (rare but possible because the ANN probe
# has no exclude list — see the comment in compute_semantic_links_ann).
ann_links = [lnk for lnk in ann_links if lnk[0] != lnk[1]]
new_links.extend(ann_links)
except Exception as e:
# ANN uses PG-specific HNSW syntax; on dialects/configs where
# it isn't available we still want the temporal top-up to land.
logger.warning(f"[GRAPH_MAINT] Semantic top-up failed for bank={bank_id}: {type(e).__name__}: {e}")
if not new_links:
return 0
await _bulk_insert_links(
conn,
new_links,
bank_id=bank_id,
skip_exists_check=False,
ops=ops,
)
return len(new_links)
async def enqueue_entity_prune_candidates(
*,
conn: DatabaseConnection,
fq_table: Callable[[str], str],
bank_id: str,
affected_unit_ids: list,
) -> int:
"""Enqueue the entities ``affected_unit_ids`` reference as prune candidates.
Must run inside the same transaction that removes those units (or their
``unit_entities`` rows), *before* the delete or cascade fires afterwards
there is no posting left to read the entity ids from, and the entity is
stranded as an orphan nothing will ever look at again.
Enqueueing an entity that turns out to still be referenced is free: the
drain re-checks and keeps it. Over-enqueueing is always the safe direction.
Returns:
Number of candidate entities enqueued.
"""
if not affected_unit_ids:
return 0
ops = _ops_for(conn)
queue_table = fq_table("entity_maintenance_queue")
ue_table = fq_table("unit_entities")
unit_uuids = _as_uuids(list(affected_unit_ids))
# Chunked because a bulk delete can hand in thousands of unit ids and the
# lookup binds them with `= ANY(...)`, which ops_oracle expands into a
# literal IN list — Oracle caps those at 1000 elements.
enqueued = 0
for start in range(0, len(unit_uuids), _ENQUEUE_LOOKUP_CHUNK):
enqueued += await ops.enqueue_entity_maintenance(
conn,
queue_table,
ue_table,
bank_id,
unit_uuids[start : start + _ENQUEUE_LOOKUP_CHUNK],
)
return enqueued
@dataclass
class _PruneBatch:
"""One entity-prune iteration's counters (avoids a bare tuple return)."""
claimed: int
orphan_entities_pruned: int
stale_cooccurrences_pruned: int
async def entity_prune_pass(
*,
backend: Any,
fq_table: Callable[[str], str],
bank_id: str,
deadline: float | None = None,
) -> EntityPrunePassResult:
"""Drain ``entity_maintenance_queue`` for ``bank_id``, pruning what died.
Per-iteration loop: claim prune commit, mirroring :func:`relink_pass`.
Each iteration does two deletes over the claimed batch:
1. **Orphan entities** candidates with no remaining ``unit_entities`` row.
FK ON DELETE CASCADE on ``entity_cooccurrences`` takes their cooccurrence
rows with them, which is why this runs first.
2. **Stale cooccurrences** pairs incident to a surviving candidate where
both entities still exist but no current unit witnesses them together.
The cooccurrence was real when recorded; every unit that saw it has since
been deleted. The FK cascade above cannot see this case.
Both deletes are scoped to the claimed batch. They used to be bank-wide
statements re-run on every invocation the orphan prune probing once per
entity in the bank, the cooccurrence prune evaluating an INTERSECT per
cooccurrence row in the bank so their cost tracked the size of the bank
rather than the size of the delete, and past a few million rows they could
no longer finish inside asyncpg's command timeout. The job then failed on
every run, forever, on exactly the banks that most needed it (#3222).
Committing per batch is what makes the pass resumable: work already done
stays done when ``deadline`` cuts the drain short or the task dies, and the
next run picks up the remaining queue rows.
Args:
backend: Database backend the loop spans a transaction per batch, so
it acquires its own connections.
fq_table: Schema-qualifier for table names.
bank_id: Bank to drain.
deadline: ``time.monotonic()`` value past which no new batch is claimed.
``None`` drains to empty.
Returns:
An :class:`EntityPrunePassResult`. ``queue_exhausted`` is False when the
deadline stopped the drain with rows still queued.
"""
from ...db_utils import retry_with_backoff
from ...memory_engine import acquire_with_retry
examined = 0
orphans_pruned = 0
stale_pruned = 0
drained = True
while True:
if deadline is not None and time.monotonic() >= deadline:
drained = False
break
async def _run_batch() -> _PruneBatch:
async with acquire_with_retry(backend) as conn:
async with conn.transaction():
ops = backend.ops
entity_ids = await ops.claim_entity_maintenance_batch(
conn,
fq_table("entity_maintenance_queue"),
bank_id,
_ENTITY_PRUNE_BATCH_SIZE,
)
if not entity_ids:
return _PruneBatch(claimed=0, orphan_entities_pruned=0, stale_cooccurrences_pruned=0)
orphaned = await ops.prune_orphan_entities(
conn,
fq_table("entities"),
fq_table("unit_entities"),
bank_id,
entity_ids,
)
# The orphan prune above cascades cooccurrences via FK. This
# second delete catches the *stale-count* case: both entities
# still exist but no current unit witnesses them together.
stale = await ops.prune_stale_cooccurrences(
conn,
fq_table("entity_cooccurrences"),
fq_table("unit_entities"),
entity_ids,
)
return _PruneBatch(
claimed=len(entity_ids),
orphan_entities_pruned=orphaned,
stale_cooccurrences_pruned=stale,
)
# Retry the batch on deadlock. Both deletes take their row locks in the
# same order the concurrent retain writers do (entity id for the entity
# upsert, (entity_id_1, entity_id_2) for the cooccurrence upsert), so a
# cycle should not form on Postgres at all; this stays as the backstop
# for the paths that ordering can't cover — the FK cascade out of the
# orphan prune, and Oracle, whose DELETE can't carry the ordered-lock
# CTE. Both deletes are idempotent, so re-running the batch is safe.
#
# Deliberately narrower than the budget the bank-wide sweep used (8).
# `retry_with_backoff` treats a TimeoutError as transient, which was
# ruinous while the statement was O(bank): a sweep that could never
# finish inside the command timeout was re-run nine times, burning ten
# minutes of a worker slot per task attempt (#3222). A bounded batch
# that times out is not slow work, it is a sick database — retry a few
# times and let the failure surface.
batch = await retry_with_backoff(_run_batch, max_retries=_PRUNE_BATCH_MAX_RETRIES)
if batch.claimed == 0:
break
examined += batch.claimed
orphans_pruned += batch.orphan_entities_pruned
stale_pruned += batch.stale_cooccurrences_pruned
return EntityPrunePassResult(
entities_examined=examined,
orphan_entities_pruned=orphans_pruned,
stale_cooccurrences_pruned=stale_pruned,
queue_exhausted=drained,
)
__all__ = [
"MAX_SEMANTIC_LINKS_PER_UNIT",
"enqueue_entity_prune_candidates",
"enqueue_relink_victims",
"entities_for_units",
"entity_map_for_units",
"entity_memory_counts",
"entity_prune_pass",
"graph_direct_links",
"graph_entity_rows",
"graph_units",
"relink_pass",
"resolve_entity_names",
]
@@ -0,0 +1,540 @@
"""Addressed reads over `memory_units`: get, scan, count, tags, consolidation state.
Not retrieval nothing here ranks. These are the queries behind the curation
detail view, export, the bank-stats panel and the consolidation queue, lifted out
of the call sites that used to issue them inline (``memory_engine``,
``transfer/export``, ``consolidation/consolidator``) so
:class:`~hindsight_api.engine.memories.postgres.PostgresMemories` can delegate
rather than embed SQL.
Every function takes the live connection and Hindsight's ``fq_table`` resolver, so
each one runs inside whatever transaction the caller already holds; none of them
acquires a connection of its own.
**Cursor semantics.** ``scan_memories``'s ``page_token`` is opaque to callers, and
for Postgres it is simply a *numeric offset rendered as a decimal string* against
the scan's fixed ``ORDER BY created_at, id``. An empty token means "start at the
beginning", and an empty token comes back once the walk is exhausted (i.e. the
final short page). An offset cursor is a position rather than a snapshot exactly
the guarantee :class:`~hindsight_api.engine.memories.base.ScanPage` documents:
rows written or deleted mid-walk can shift later pages, so a scan is
eventually-complete browsing rather than a consistent iterator. ``skip`` is applied
*on top of* the decoded cursor, so a caller that pages with both should pass
``skip`` only on the first call the returned token already accounts for it.
"""
from __future__ import annotations
import json
import uuid
from collections.abc import Callable
from datetime import datetime
from typing import Any
from ...search.tags import (
build_tag_groups_where_clause,
build_tags_where_clause,
build_tags_where_clause_simple,
)
from ..base import ScanPage, StoredMemory
# The `memory_units` projection every read here shares. Superset of the by-id
# SELECT the recall source-facts path used (text/fact_type/context/timestamps/
# document_id/chunk_id/tags/metadata), plus the observation bookkeeping columns
# `StoredMemory` carries: source_memory_ids and consolidated_at.
_MEMORY_COLUMNS = """
id, text, fact_type, context, document_id, chunk_id, tags, metadata,
proof_count, event_date, occurred_start, occurred_end, mentioned_at,
created_at, source_memory_ids, consolidated_at, observation_scopes
"""
# The scan's order. Fixed (created_at, id) like the export loader's, because an
# offset cursor is only meaningful against a total order.
_SCAN_ORDER = "ORDER BY created_at, id"
def _as_json(value: Any) -> Any:
"""Coerce an asyncpg JSONB column (str or already-decoded) to a Python object.
Connections differ in whether a JSONB codec is registered, so the column
arrives either as text or as the decoded object.
"""
if value is None:
return None
if isinstance(value, str):
try:
return json.loads(value)
except json.JSONDecodeError:
# A valid scalar such as `"combined"` arrives already decoded on
# connections that do register a decoder.
return value
return value
def _as_uuids(unit_ids: list[Any]) -> list[uuid.UUID]:
"""Unit ids as UUIDs, dropping anything unparseable.
A malformed id is treated the same way a deleted one is simply absent from
the result rather than failing the whole read.
"""
out: list[uuid.UUID] = []
for unit_id in unit_ids or []:
if isinstance(unit_id, uuid.UUID):
out.append(unit_id)
continue
try:
out.append(uuid.UUID(str(unit_id)))
except (ValueError, AttributeError, TypeError):
continue
return out
def _column(row: Any, name: str, default: Any = None) -> Any:
"""One column of an asyncpg Record, tolerating a narrower projection."""
try:
return row[name]
except (KeyError, IndexError):
return default
def _stored_from_row(row: Any) -> StoredMemory:
"""Map a `memory_units` row onto :class:`StoredMemory`.
Shared by every read in this module so the row dataclass mapping exists
once. ``entity_ids`` stays empty: the unitentity posting lives in
`unit_entities` and is served by ``entities_for_units``, not by a join here.
"""
source_ids = _column(row, "source_memory_ids") or []
return StoredMemory(
unit_id=str(row["id"]),
text=row["text"],
fact_type=row["fact_type"],
context=_column(row, "context"),
document_id=_column(row, "document_id"),
chunk_id=str(_column(row, "chunk_id")) if _column(row, "chunk_id") else None,
tags=list(_column(row, "tags") or []),
metadata=_as_json(_column(row, "metadata")),
proof_count=_column(row, "proof_count") or 1,
event_date=_column(row, "event_date"),
occurred_start=_column(row, "occurred_start"),
occurred_end=_column(row, "occurred_end"),
mentioned_at=_column(row, "mentioned_at"),
created_at=_column(row, "created_at"),
source_memory_ids=[str(sid) for sid in source_ids],
consolidated_at=_column(row, "consolidated_at"),
# Consolidation routes a candidate by its scopes, so this has to survive
# the trip through the store rather than being re-queried per memory.
observation_scopes=_as_json(_column(row, "observation_scopes")),
)
def _decode_page_token(page_token: str) -> int:
"""Decode the offset cursor. Empty, malformed or negative all mean "start"."""
if not page_token:
return 0
try:
offset = int(page_token)
except (TypeError, ValueError):
return 0
return offset if offset > 0 else 0
async def get_memories(
*, conn, fq_table: Callable[[str], str], bank_id: str, unit_ids: list[str]
) -> list[StoredMemory]:
"""Fetch memories by id. Missing or deleted ids are simply absent."""
ids = _as_uuids(unit_ids)
if not ids:
return []
rows = await conn.fetch(
f"""
SELECT {_MEMORY_COLUMNS}
FROM {fq_table("memory_units")}
WHERE bank_id = $1 AND id = ANY($2::uuid[])
""",
bank_id,
ids,
)
return [_stored_from_row(row) for row in rows]
async def _semantic_edges(
*, conn, fq_table: Callable[[str], str], bank_id: str, unit_ids: list[uuid.UUID]
) -> dict[str, list[tuple[str, float]]]:
"""Derived kNN edges for ``unit_ids``, keyed by unit id.
Walked in both directions, like the graph arm's semantic expansion: a
`memory_links` row is written once, so a unit's neighbourhood is the union of
the edges leaving it and those arriving at it.
"""
if not unit_ids:
return {}
rows = await conn.fetch(
f"""
SELECT from_unit_id AS unit_id, to_unit_id AS target_id, weight
FROM {fq_table("memory_links")}
WHERE bank_id = $1 AND link_type = 'semantic' AND from_unit_id = ANY($2::uuid[])
UNION ALL
SELECT to_unit_id AS unit_id, from_unit_id AS target_id, weight
FROM {fq_table("memory_links")}
WHERE bank_id = $1 AND link_type = 'semantic' AND to_unit_id = ANY($2::uuid[])
""",
bank_id,
unit_ids,
)
edges: dict[str, list[tuple[str, float]]] = {}
for row in rows:
edges.setdefault(str(row["unit_id"]), []).append((str(row["target_id"]), float(row["weight"] or 0.0)))
return edges
async def scan_memories(
*,
conn,
fq_table: Callable[[str], str],
bank_id: str,
fact_types: list[str] | None = None,
limit: int = 100,
page_token: str = "",
tags: list[str] | None = None,
tags_match: str = "any",
tag_groups: list | None = None,
document_id: str | None = None,
metadata_equals: dict[str, str] | None = None,
skip: int = 0,
include_edges: bool = False,
) -> ScanPage:
"""Page through stored memories. A full walk — for browsing and export only.
See the module docstring for the ``page_token`` (offset) cursor semantics.
"""
if limit is None or limit <= 0:
return ScanPage()
where: list[str] = ["bank_id = $1"]
params: list[Any] = [bank_id]
if fact_types:
params.append(list(fact_types))
where.append(f"fact_type = ANY(${len(params)})")
if document_id is not None:
# A real column here, which is why it is not folded into
# `metadata_equals`: only a store without the column keeps it in the bag.
params.append(document_id)
where.append(f"document_id = ${len(params)}")
if metadata_equals:
# str→str equality across every key, which is exactly JSONB containment.
params.append(json.dumps(metadata_equals))
where.append(f"metadata @> ${len(params)}::jsonb")
# The tags clause owns its own `AND` prefix and, per the helper's contract,
# only consumes a bind param when `tags` is non-empty (match="exact" with no
# tags is the untagged/global scope and needs none).
tags_clause = build_tags_where_clause_simple(tags, len(params) + 1, match=tags_match)
if tags:
params.append(list(tags))
# Compound tag groups (AND/OR/NOT trees), AND-ed on. Also owns its `AND` prefix and appends
# one bind param per leaf; empty/absent groups yield no clause and no params.
groups_clause, group_params, _ = build_tag_groups_where_clause(tag_groups, param_offset=len(params) + 1)
params.extend(group_params)
offset = _decode_page_token(page_token) + max(int(skip or 0), 0)
params.append(limit)
limit_idx = len(params)
params.append(offset)
offset_idx = len(params)
rows = await conn.fetch(
f"""
SELECT {_MEMORY_COLUMNS}
FROM {fq_table("memory_units")}
WHERE {" AND ".join(where)} {tags_clause} {groups_clause}
{_SCAN_ORDER}
LIMIT ${limit_idx} OFFSET ${offset_idx}
""",
*params,
)
memories = [_stored_from_row(row) for row in rows]
if include_edges and memories:
edges = await _semantic_edges(
conn=conn, fq_table=fq_table, bank_id=bank_id, unit_ids=_as_uuids([m.unit_id for m in memories])
)
for memory in memories:
memory.semantic_edges = edges.get(memory.unit_id, [])
# A short page means the walk is exhausted, so the cursor goes empty.
next_token = str(offset + len(rows)) if len(rows) == limit else ""
return ScanPage(memories=memories, next_page_token=next_token)
async def count_memories(*, conn, fq_table: Callable[[str], str], bank_id: str) -> dict[str, int]:
"""Live memory count per fact_type. The bank-stats node counts."""
rows = await conn.fetch(
f"""
SELECT fact_type, COUNT(*) as count
FROM {fq_table("memory_units")}
WHERE bank_id = $1
GROUP BY fact_type
""",
bank_id,
)
return {row["fact_type"]: int(row["count"]) for row in rows}
async def list_tags(
*,
conn,
fq_table: Callable[[str], str],
bank_id: str,
pattern: str | None = None,
limit: int = 100,
offset: int = 0,
) -> dict[str, Any]:
"""One page of a bank's tag histogram: ``{"items": [{tag, count}], "total", "limit", "offset"}``.
``memory_units`` lives in SQL for this store, so the wildcard filter, the
``count DESC, tag ASC`` ordering and the paging all run in SQL the whole
histogram never crosses the wire. The dialect fragments come from
``build_tag_listing_parts`` (``unnest`` on Postgres, ``JSON_TABLE`` on Oracle):
this module backs both dialects, so it must not inline either one's SQL.
"""
from ...db import create_data_access_ops
ops = create_data_access_ops(getattr(conn, "backend_type", "postgresql"))
tag_parts = ops.build_tag_listing_parts(fq_table("memory_units"))
tag_source = tag_parts.tag_source
non_empty_check = tag_parts.non_empty_check
tag_col = tag_parts.tag_col
bank_prefix = tag_parts.bank_prefix
params: list[Any] = [bank_id]
pattern_clause = ""
if pattern:
# '*' is the wildcard, matched case-insensitively — same anchored ILIKE semantics as before.
params.append(pattern.replace("*", "%"))
pattern_clause = f"AND {tag_col} ILIKE $2"
total_row = await conn.fetchrow(
f"""
SELECT COUNT(DISTINCT {tag_col}) as total
FROM {tag_source}
WHERE {bank_prefix}bank_id = $1 {non_empty_check}
{pattern_clause}
""",
*params,
)
total = int(total_row["total"]) if total_row else 0
limit_param = len(params) + 1
offset_param = len(params) + 2
params.extend([limit, offset])
rows = await conn.fetch(
f"""
SELECT {tag_col} as tag, COUNT(*) as count
FROM {tag_source}
WHERE {bank_prefix}bank_id = $1 {non_empty_check}
{pattern_clause}
GROUP BY {tag_col}
ORDER BY count DESC, {tag_col} ASC
LIMIT ${limit_param} OFFSET ${offset_param}
""",
*params,
)
return {
"items": [{"tag": row["tag"], "count": int(row["count"])} for row in rows],
"total": total,
"limit": limit,
"offset": offset,
}
async def find_unconsolidated(
*,
conn,
fq_table: Callable[[str], str],
bank_id: str,
fact_types: list[str],
limit: int,
scope_tags: list[str] | None = None,
) -> list[StoredMemory]:
"""Memories not yet folded into an observation, oldest first.
The consolidator's candidate query: never consolidated, never *failed* to
consolidate (a memory the LLM could not handle must not be retried forever),
ordered by ``created_at`` so the queue drains in arrival order. ``scope_tags``
is the same ``tags @> scope`` containment the job's scope filter uses — the
job ORs several scopes together; one scope is passed here.
"""
where = [
"bank_id = $1",
"consolidated_at IS NULL",
"consolidation_failed_at IS NULL",
]
params: list[Any] = [bank_id]
if fact_types:
params.append(list(fact_types))
where.append(f"fact_type = ANY(${len(params)})")
if scope_tags:
params.append(list(scope_tags))
where.append(f"tags @> ${len(params)}::varchar[]")
params.append(limit)
rows = await conn.fetch(
f"""
SELECT {_MEMORY_COLUMNS}
FROM {fq_table("memory_units")}
WHERE {" AND ".join(where)}
ORDER BY created_at ASC
LIMIT ${len(params)}
""",
*params,
)
return [_stored_from_row(row) for row in rows]
async def count_unconsolidated(
*,
conn,
fq_table: Callable[[str], str],
bank_id: str,
fact_types: list[str],
scopes: list[list[str] | None],
limit: int,
) -> int:
"""Bounded ``COUNT(*)`` of unconsolidated candidates matching any scope — the cheap counterpart
to :func:`find_unconsolidated` that never ships a row.
Same predicates as ``find_unconsolidated`` (never consolidated, never failed, matching
fact_type), with the scopes OR'd as ``tags @> scope`` containment. ``id`` is the PK so each row
counts once; the inner ``LIMIT`` floors the count at ``limit`` exactly as walking that many rows
would, so a huge backlog stays a single index count instead of a 17-column fetch.
"""
where = ["bank_id = $1", "consolidated_at IS NULL", "consolidation_failed_at IS NULL"]
params: list[Any] = [bank_id]
if fact_types:
params.append(list(fact_types))
where.append(f"fact_type = ANY(${len(params)})")
# An unscoped entry (None) matches every row, collapsing the OR to no tag filter at all.
scope_clauses: list[str] = []
unscoped = any(scope is None for scope in scopes)
if not unscoped:
for scope in scopes:
params.append(list(scope or []))
scope_clauses.append(f"tags @> ${len(params)}::varchar[]")
if scope_clauses:
where.append("(" + " OR ".join(scope_clauses) + ")")
params.append(limit)
row = await conn.fetchrow(
f"""
SELECT COUNT(*) AS c FROM (
SELECT 1 FROM {fq_table("memory_units")}
WHERE {" AND ".join(where)}
LIMIT ${len(params)}
) sub
""",
*params,
)
return int(row["c"]) if row else 0
async def mark_consolidated(
*,
conn,
fq_table: Callable[[str], str],
bank_id: str,
unit_ids: list[str],
when: datetime | None,
failed: bool = False,
) -> None:
"""Stamp (or clear, with ``when=None``) the consolidated marker on sources.
``failed`` writes ``consolidation_failed_at`` instead of ``consolidated_at``,
which is what keeps a memory the LLM could not consolidate out of the queue.
``when=None`` clears the column rather than stamping it that is how a source
is requeued once the observation built on it is deleted. The clear keeps the
``fact_type IN ('experience', 'world')`` guard the requeue sites carry:
observations are never themselves consolidated, so nothing about them should
be reset by a requeue.
``updated_at`` is deliberately left alone the one exception to the contract
documented on ``META_UPDATED_AT`` (``memories.base``) that every other write
path owes the column. Consolidation bookkeeping is not an edit to the memory, and
bumping it would make every consolidation pass look like a write to the
staleness check below.
"""
ids = _as_uuids(unit_ids)
if not ids:
return
column = "consolidation_failed_at" if failed else "consolidated_at"
guard = "" if when is not None else " AND fact_type IN ('experience', 'world')"
await conn.execute(
f"""
UPDATE {fq_table("memory_units")}
SET {column} = $1
WHERE bank_id = $2 AND id = ANY($3::uuid[]){guard}
""",
when,
bank_id,
ids,
)
async def any_memory_updated_since(
*,
conn,
fq_table: Callable[[str], str],
bank_id: str,
since: datetime,
fact_types: list[str] | None = None,
tags: list[str] | None = None,
tags_match: str = "any",
tag_groups: list | None = None,
) -> bool:
"""Whether any memory in ``bank_id``'s scope was written after ``since``.
Backs the mental-model staleness check, so it is a bounded existence test
``LIMIT 1``, never a COUNT: the answer is "is there one", and the planner can
stop at the first hit. The scope is the mental model's: its flat tags (or the
compound ``tag_groups``) plus an optional ``fact_types`` restriction. This is
where the staleness query's WHERE lives, so the same scope that gates a
refresh decides whether one is due.
"""
params: list[Any] = [bank_id, since]
where = ["bank_id = $1", "updated_at > $2"]
tag_clause, tag_params, next_param = build_tags_where_clause(tags, param_offset=len(params) + 1, match=tags_match)
if tag_clause:
where.append(tag_clause.removeprefix("AND "))
params.extend(tag_params)
group_clause, group_params, _ = build_tag_groups_where_clause(tag_groups, param_offset=next_param)
if group_clause:
where.append(group_clause.removeprefix("AND "))
params.extend(group_params)
# Untagged, no tag_groups → no tag constraint, matching any memory in the bank.
if fact_types:
params.append(list(fact_types))
where.append(f"fact_type = ANY(${len(params)}::text[])")
row = await conn.fetchval(
f"SELECT 1 FROM {fq_table('memory_units')} WHERE {' AND '.join(where)} LIMIT 1",
*params,
)
return row is not None
__all__ = [
"any_memory_updated_since",
"count_memories",
"find_unconsolidated",
"get_memories",
"list_tags",
"mark_consolidated",
"scan_memories",
]
@@ -0,0 +1,588 @@
"""Writes against `memory_units`: the fact insert, the deletes, and observation invalidation.
Everything here mutates the memories slice and nothing else. The document row,
the chunks, the entity registry and the link tables stay with their own callers
what lands in this module is only the statements that touch `memory_units` (and,
on backends that keep one, the `observation_sources` junction that hangs off it).
Each function takes the live connection and Hindsight's ``fq_table`` resolver, so
it runs inside whatever transaction the caller already holds; ``ops`` is the
dialect ops object, which is what lets the same code serve the PG (native array)
and Oracle (junction table) shapes of the observationsource relation.
"""
from __future__ import annotations
import json
import logging
import uuid
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from ....config import get_config
from ..base import StoredMemory
if TYPE_CHECKING: # pragma: no cover - typing only
from ...retain.types import ProcessedFact
logger = logging.getLogger(__name__)
async def insert_facts(
*,
conn,
ops,
bank_id: str,
facts: list[ProcessedFact],
document_id: str | None = None,
) -> list[str]:
"""Insert facts into the database in batch.
Args:
conn: Database connection
bank_id: Bank identifier
facts: List of ProcessedFact objects to insert
document_id: Optional document ID to associate with facts
Returns:
List of unit IDs (UUIDs as strings) for the inserted facts, in the same
order as ``facts``.
"""
if not facts:
return []
# Imported here: `retain` reaches back into the engine for `fq_table`, so a
# module-level import would close the cycle once the engine imports this store.
from ...retain.fact_extraction import _sanitize_text
# Prepare data for batch insert
fact_texts = []
embeddings = []
event_dates = []
occurred_starts = []
occurred_ends = []
mentioned_ats = []
contexts = []
fact_types = []
metadata_jsons = []
chunk_ids = []
document_ids = []
tags_list = []
observation_scopes_list = []
text_signals_list = []
for fact in facts:
fact_texts.append(_sanitize_text(fact.fact_text))
# Convert embedding to string for asyncpg vector type
embeddings.append(str(fact.embedding))
# event_date: Use occurred_start if available, otherwise use mentioned_at
# This maintains backward compatibility while handling None occurred_start
event_dates.append(fact.occurred_start if fact.occurred_start is not None else fact.mentioned_at)
occurred_starts.append(fact.occurred_start)
occurred_ends.append(fact.occurred_end)
mentioned_ats.append(fact.mentioned_at)
contexts.append(_sanitize_text(fact.context))
fact_types.append(fact.fact_type)
metadata_jsons.append(json.dumps(fact.metadata))
chunk_ids.append(fact.chunk_id)
# Use per-fact document_id if available, otherwise fallback to batch-level document_id
document_ids.append(fact.document_id if fact.document_id else document_id)
# Convert tags to JSON string for proper batch insertion (PostgreSQL unnest doesn't handle 2D arrays well)
tags_list.append(json.dumps(fact.tags if fact.tags else []))
# observation_scopes: stored as JSONB (string or 2D array), None if not provided
observation_scopes_list.append(
json.dumps(fact.observation_scopes) if fact.observation_scopes is not None else None
)
# Build text_signals: entity names + date tokens for enriched BM25 indexing
signal_parts = []
if fact.entities:
signal_parts.extend(e.name for e in fact.entities)
if fact.occurred_start:
try:
signal_parts.append(fact.occurred_start.strftime("%B %d %Y").lstrip("0").replace(" 0", " "))
except (ValueError, AttributeError):
pass
if fact.occurred_end and fact.occurred_end != fact.occurred_start:
try:
signal_parts.append(fact.occurred_end.strftime("%B %d %Y").lstrip("0").replace(" 0", " "))
except (ValueError, AttributeError):
pass
text_signals_list.append(" ".join(signal_parts) if signal_parts else None)
# Batch insert all facts — delegates to DataAccessOps which handles
# unnest (PG) vs row-by-row (Oracle) transparently.
config = get_config()
return await ops.insert_facts_batch(
conn,
bank_id,
fact_texts,
embeddings,
event_dates,
occurred_starts,
occurred_ends,
mentioned_ats,
contexts,
fact_types,
metadata_jsons,
chunk_ids,
document_ids,
tags_list,
observation_scopes_list,
text_signals_list,
text_search_extension=config.text_search_extension,
)
async def delete_document(*, conn, fq_table: Callable[[str], str], bank_id: str, document_id: str) -> None:
"""Delete every memory unit belonging to ``document_id``.
Explicitly delete memory_units by document_id BEFORE deleting the
document row. The CASCADE from documentschunksmemory_units only
catches units that have a non-NULL chunk_id FK. Units with chunk_id=NULL
(e.g. from partial writes or edge cases) would survive the cascade.
This explicit delete ensures complete cleanup.
Called when a document is replaced, so it races the replacement's writes: it
must remove only what was written *before* this call, never the facts
arriving moments later which the ``document_id``/``bank_id`` predicate
gives for free inside the caller's transaction.
"""
await conn.execute(
f"DELETE FROM {fq_table('memory_units')} WHERE document_id = $1 AND bank_id = $2",
document_id,
bank_id,
)
async def delete_observations(*, conn, fq_table: Callable[[str], str], bank_id: str) -> None:
"""Delete all observations in a bank, leaving the facts behind them.
Only the observation rows: requeuing the surviving sources (clearing
``consolidated_at``) and resetting the bank's consolidation timestamp belong
to the caller, which owns the bank row.
"""
await conn.execute(
f"DELETE FROM {fq_table('memory_units')} WHERE bank_id = $1 AND fact_type = 'observation'",
bank_id,
)
async def observations_for_sources(
*,
conn,
ops,
fq_table: Callable[[str], str],
bank_id: str,
unit_ids: list[str | uuid.UUID],
) -> list[StoredMemory]:
"""Observations consolidated from any of ``unit_ids``.
Only ``unit_id`` and ``source_memory_ids`` are populated the caller uses
them to delete the observations and to work out which sources survive, and
the rest of the row is about to be deleted anyway.
"""
if not unit_ids:
return []
fact_uuids = [uuid.UUID(str(fid)) if not isinstance(fid, uuid.UUID) else fid for fid in unit_ids]
if ops is not None and not ops.uses_observation_sources_table:
# PG: use native array overlap operator
rows = await conn.fetch(
f"""
SELECT id, source_memory_ids
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND fact_type = 'observation'
AND source_memory_ids && $2::uuid[]
""",
bank_id,
fact_uuids,
)
else:
# Oracle / default: use observation_sources junction table
rows = await conn.fetch(
f"""
SELECT mu.id, mu.source_memory_ids
FROM {fq_table("memory_units")} mu
WHERE mu.bank_id = $1
AND mu.fact_type = 'observation'
AND EXISTS (
SELECT 1 FROM {fq_table("observation_sources")} os
WHERE os.observation_id = mu.id
AND os.source_id = ANY($2::uuid[])
)
""",
bank_id,
fact_uuids,
)
return [
StoredMemory(
unit_id=str(row["id"]),
text="",
fact_type="observation",
source_memory_ids=[str(src_id) for src_id in (row["source_memory_ids"] or [])],
)
for row in rows
]
async def delete_stale_observations(
*,
conn,
ops,
fq_table: Callable[[str], str],
bank_id: str,
fact_ids: list[str | uuid.UUID],
) -> int:
"""Delete observations whose source memories are about to be removed.
Mirrors the cleanup performed by ``MemoryEngine.delete_document`` so that
every code path that removes ``memory_units`` also removes the
observations derived from them. Without this, ingesting a fresh version
of a document via the retain pipeline (which does a full-replace
``DELETE FROM documents`` cascade) used to leave orphan observations
pointing at memory IDs that no longer existed.
For each observation referencing any of ``fact_ids``:
1. Delete the observation row (its text is stale once even one source
memory disappears).
2. Reset ``consolidated_at = NULL`` on the surviving source memories so
they get re-consolidated under fresh observations on the next run.
Must be called within an active transaction, before the source memories
are deleted.
Returns the number of observations deleted.
"""
if not fact_ids:
return 0
fact_uuids = [uuid.UUID(str(fid)) if not isinstance(fid, uuid.UUID) else fid for fid in fact_ids]
affected_obs = await observations_for_sources(
conn=conn, ops=ops, fq_table=fq_table, bank_id=bank_id, unit_ids=fact_uuids
)
if not affected_obs:
return 0
deleted_set = {str(uid) for uid in fact_uuids}
obs_ids = [uuid.UUID(obs.unit_id) for obs in affected_obs]
seen_remaining: set[str] = set()
remaining_source_ids: list[uuid.UUID] = []
for obs in affected_obs:
for src_str in obs.source_memory_ids:
if src_str not in deleted_set and src_str not in seen_remaining:
remaining_source_ids.append(uuid.UUID(src_str))
seen_remaining.add(src_str)
await conn.execute(
f"DELETE FROM {fq_table('memory_units')} WHERE id = ANY($1::uuid[])",
obs_ids,
)
# Their history is keyed by observation_id and no longer cascades from memory_units (that FK
# was dropped so history can be recorded for observations kept outside SQL), so drop the
# deleted observations' snapshots explicitly rather than leaving them to accumulate.
await conn.execute(
f"DELETE FROM {fq_table('observation_history')} WHERE bank_id = $1 AND observation_id = ANY($2::uuid[])",
bank_id,
obs_ids,
)
if remaining_source_ids:
# Requeue: consolidation bookkeeping, so `updated_at` is deliberately not
# stamped (see META_UPDATED_AT in ..base) — nothing about these facts changed.
await conn.execute(
f"""
UPDATE {fq_table("memory_units")}
SET consolidated_at = NULL
WHERE id = ANY($1::uuid[])
AND fact_type IN ('experience', 'world')
""",
remaining_source_ids,
)
logger.info(
f"[OBSERVATIONS] Deleted {len(obs_ids)} observations, reset {len(remaining_source_ids)} "
f"source memories for re-consolidation in bank {bank_id}"
)
return len(obs_ids)
# --------------------------------------------------------------------- curation archive
#
# Invalidation moves a rejected memory between two tables rather than flagging it,
# so recall / consolidation / graph never carry a "valid?" predicate: live facts
# live in `memory_units`, invalidated ones in `invalidated_memory_units`. The
# archive is cold storage — no index, so it drops the `embedding` and
# `search_vector` columns, which are recomputed on the way back.
# The two recall-surface columns the archive omits. Both follow server config
# (embedding dimension, search backend), so keeping them out of the INSERT…SELECT
# round-trip makes a model or text-backend switch structurally unable to trip a
# type/dimension mismatch (#2209, #2503); each is recomputed on revert.
_ARCHIVE_OMITTED = ('"embedding"', '"search_vector"')
async def _memory_unit_columns(conn, fq_table: Callable[[str], str]) -> str:
"""The quoted, ordinal column list of `memory_units`.
Read from the catalog rather than hardcoded so a schema migration cannot make
the archive round-trip drift from the live table (the archive is created via
``LIKE memory_units``, so the lists line up).
"""
rows = await conn.fetch(
f"SELECT a.attname FROM pg_attribute a "
f"WHERE a.attrelid = '{fq_table('memory_units')}'::regclass "
f"AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum"
)
return ", ".join(f'"{r["attname"]}"' for r in rows)
async def _archive_columns(conn, fq_table: Callable[[str], str]) -> str:
"""`_memory_unit_columns` minus the two the archive does not carry."""
collist = await _memory_unit_columns(conn, fq_table)
return ", ".join(c for c in (s.strip() for s in collist.split(",")) if c not in _ARCHIVE_OMITTED)
_ARCHIVE_SELECT = (
"id, text, fact_type, context, occurred_start, occurred_end, mentioned_at, "
"document_id, chunk_id, tags, metadata, proof_count, event_date, created_at, "
"consolidated_at, entity_ids"
)
def _archived_stored(row: Any) -> StoredMemory:
"""Map an `invalidated_memory_units` row onto :class:`StoredMemory`."""
return StoredMemory(
unit_id=str(row["id"]),
text=row["text"],
fact_type=row["fact_type"],
context=row["context"],
document_id=row["document_id"],
chunk_id=str(row["chunk_id"]) if row["chunk_id"] else None,
tags=list(row["tags"] or []),
metadata=row["metadata"] if isinstance(row["metadata"], dict) else None,
proof_count=row["proof_count"] or 1,
event_date=row["event_date"],
occurred_start=row["occurred_start"],
occurred_end=row["occurred_end"],
mentioned_at=row["mentioned_at"],
created_at=row["created_at"],
consolidated_at=row["consolidated_at"],
entity_ids=[str(e) for e in (row["entity_ids"] or [])],
)
async def get_archived_memory(*, conn, fq_table, bank_id: str, unit_id: str) -> StoredMemory | None:
row = await conn.fetchrow(
f"SELECT {_ARCHIVE_SELECT} FROM {fq_table('invalidated_memory_units')} WHERE id = $1 AND bank_id = $2",
str(unit_id),
bank_id,
)
return _archived_stored(row) if row else None
async def invalidate_memory(*, conn, fq_table, bank_id: str, unit_id: str, reason: str | None) -> bool:
mu = fq_table("memory_units")
arch = fq_table("invalidated_memory_units")
ue = fq_table("unit_entities")
arch_cols = await _archive_columns(conn, fq_table)
# Snapshot the entity ids before the delete cascade takes `unit_entities`, so
# revert can restore the postings the move is about to drop.
entity_ids = [
r["entity_id"] for r in await conn.fetch(f"SELECT entity_id FROM {ue} WHERE unit_id = $1", str(unit_id))
]
# Causal edges are retain-time extraction output the FK cascade would destroy for good —
# unlike temporal/semantic links they can't be recomputed, so snapshot their descriptors onto
# the archive row and revert rematerializes them (#2864).
from ...retain.link_utils import snapshot_causal_links
causal_links = await snapshot_causal_links(conn, bank_id, str(unit_id))
inserted = await conn.fetchval(
f"INSERT INTO {arch} ({arch_cols}, invalidation_reason, invalidated_at, entity_ids, causal_links) "
f"SELECT {arch_cols}, $2, now(), $3::uuid[], $5::jsonb FROM {mu} WHERE id = $1 AND bank_id = $4 "
f"RETURNING id",
str(unit_id),
reason,
entity_ids,
bank_id,
json.dumps([descriptor.as_json_dict() for descriptor in causal_links]),
)
if inserted is None:
return False
# The cascade prunes `unit_entities` and `memory_links` with the row.
await conn.execute(f"DELETE FROM {mu} WHERE id = $1 AND bank_id = $2", str(unit_id), bank_id)
return True
async def set_invalidation_reason(*, conn, fq_table, bank_id: str, unit_id: str, reason: str | None) -> None:
await conn.execute(
f"UPDATE {fq_table('invalidated_memory_units')} SET invalidation_reason = $3, updated_at = now() "
f"WHERE id = $1 AND bank_id = $2",
str(unit_id),
bank_id,
reason,
)
async def restore_memory(*, conn, fq_table, bank_id: str, unit_id: str) -> StoredMemory | None:
mu = fq_table("memory_units")
arch = fq_table("invalidated_memory_units")
ue = fq_table("unit_entities")
ent = fq_table("entities")
arch_cols = await _archive_columns(conn, fq_table)
arch_row = await conn.fetchrow(
f"SELECT {_ARCHIVE_SELECT} FROM {arch} WHERE id = $1 AND bank_id = $2", str(unit_id), bank_id
)
if arch_row is None:
return None
# Move the row back. The archive omits embedding/search_vector, so both default
# to NULL here; search_vector is rebuilt now, the embedding by the caller.
await conn.execute(
f"INSERT INTO {mu} ({arch_cols}) SELECT {arch_cols} FROM {arch} WHERE id = $1 AND bank_id = $2",
str(unit_id),
bank_id,
)
# Rebuild search_vector with the *current* backend, so a backend change while
# the fact sat archived cannot leave a stale/wrong-type vector (#2503). None
# means the backend indexes base columns directly and leaves it empty.
from ...db.ops_postgresql import pg_search_vector_expr
sv_expr = pg_search_vector_expr(get_config())
if sv_expr is not None:
await conn.execute(
f"UPDATE {mu} SET search_vector = {sv_expr} WHERE id = $1 AND bank_id = $2", str(unit_id), bank_id
)
# Re-consolidate from scratch; links are rebuilt by graph maintenance.
await conn.execute(
f"UPDATE {mu} SET consolidated_at = NULL, consolidation_failed_at = NULL, updated_at = now() "
f"WHERE id = $1 AND bank_id = $2",
str(unit_id),
bank_id,
)
# Restore the entity postings for entities that still exist — some may have
# been swept as orphans while the memory was archived.
if arch_row["entity_ids"]:
await conn.execute(
f"INSERT INTO {ue} (unit_id, entity_id) "
f"SELECT $1, eid FROM unnest($2::uuid[]) AS eid "
f"WHERE EXISTS (SELECT 1 FROM {ent} e WHERE e.id = eid AND e.bank_id = $3) "
f"ON CONFLICT DO NOTHING",
str(unit_id),
arch_row["entity_ids"],
bank_id,
)
# Rematerialize the causal edges parked at invalidation (#2864). Edges whose peer is still
# archived or permanently deleted are skipped — the peer keeps its own copy and recreates the
# edge when it reverts, so the restore is order-independent and idempotent.
from ...retain.link_utils import rematerialize_causal_links
from .graph import _ops_for
causal_json = await conn.fetchval(
f"SELECT causal_links FROM {arch} WHERE id = $1 AND bank_id = $2", str(unit_id), bank_id
)
if causal_json:
await rematerialize_causal_links(conn, bank_id, conn.parse_json(causal_json) or [], ops=_ops_for(conn))
# Invalidation cascaded away this unit's derived outgoing links; queue it so graph maintenance
# rebuilds them (the drain only touches queued units — it never scans for missing adjacency).
await _ops_for(conn).enqueue_graph_maintenance(
conn, fq_table("graph_maintenance_queue"), bank_id, [uuid.UUID(str(unit_id))]
)
await conn.execute(f"DELETE FROM {arch} WHERE id = $1 AND bank_id = $2", str(unit_id), bank_id)
return _archived_stored(arch_row)
async def set_memory_embedding(*, conn, fq_table, bank_id: str, unit_id: str, embedding) -> None:
await conn.execute(
f"UPDATE {fq_table('memory_units')} SET embedding = $3::vector, updated_at = now() "
f"WHERE id = $1 AND bank_id = $2",
str(unit_id),
bank_id,
embedding,
)
async def clear_unit_entities(*, conn, fq_table, bank_id: str, unit_id: str) -> None:
await conn.execute(f"DELETE FROM {fq_table('unit_entities')} WHERE unit_id = $1", str(unit_id))
async def apply_edit(
*,
conn,
fq_table,
bank_id: str,
unit_id: str,
text: str,
context: str | None,
fact_type: str,
occurred_start,
occurred_end,
event_date,
mentioned_at,
entity_ids: list[str] | None,
) -> None:
# `entity_ids` and `mentioned_at` are unused here: the entity postings are
# re-linked into `unit_entities` by the caller, and an edit does not move the
# mention time. Both are on the signature for a store that carries entities on
# the memory and rebuilds it wholesale.
from ...causal_links import CAUSAL_LINK_TYPES
from ...db.ops_postgresql import pg_search_vector_expr
mu = fq_table("memory_units")
ml = fq_table("memory_links")
# The caller enqueues the relink victims (and the edited unit itself, via
# ``include_affected_units``) before invoking this — one combined queue insert keeps the
# graph-maintenance queue's lock ordering intact.
# Keep the stored text-search vector in sync with the edited text/context.
# Reference the bind parameters, not the columns: PostgreSQL evaluates the
# UPDATE's RHS before the sibling SET assignments land, so a column reference
# would see the pre-edit values.
sv_expr = pg_search_vector_expr(get_config(), text_col="$3", context_col="$4")
sv_clause = f", search_vector = {sv_expr}" if sv_expr else ""
await conn.execute(
f"""
UPDATE {mu}
SET text = $3, context = $4, fact_type = $5, occurred_start = $6, occurred_end = $7,
event_date = $8, consolidated_at = NULL, consolidation_failed_at = NULL,
edited_at = now(), updated_at = now(){sv_clause}
WHERE id = $1 AND bank_id = $2
""",
str(unit_id),
bank_id,
text,
context,
fact_type,
occurred_start,
occurred_end,
event_date,
)
# Drop only the DERIVED links — graph maintenance recomputes temporal/semantic. Causal edges
# are retain-time extraction output that nothing recreates, so an edit preserves them (#2864).
await conn.execute(
f"DELETE FROM {ml} WHERE (from_unit_id = $1 OR to_unit_id = $1) AND NOT (link_type = ANY($2::text[]))",
str(unit_id),
list(CAUSAL_LINK_TYPES),
)
__all__ = [
"apply_edit",
"clear_unit_entities",
"delete_document",
"delete_observations",
"delete_stale_observations",
"get_archived_memory",
"insert_facts",
"invalidate_memory",
"observations_for_sources",
"restore_memory",
"set_invalidation_reason",
"set_memory_embedding",
]
@@ -0,0 +1,670 @@
"""The default memories store: Postgres holds the memories and the links.
This is the behaviour Hindsight has always had, stated as an implementation of
:class:`~hindsight_api.engine.memories.base.MemoriesExtension` rather than as the
absence of one. Rows go in `memory_units`, the joins around it are `memory_links`
and `unit_entities`, and every read is SQL writing a row *is* indexing it, so
:meth:`index_facts` has nothing left to do.
The class is deliberately thin. Each method delegates to a plain function in
:mod:`hindsight_api.engine.memories.pg`, split by what calls it curation,
graph, reads, writes so a change to one area is a change to one file, and the
SQL is grouped by concern rather than piled behind a class. The two retrieval
arms delegate further out still, to the query functions that already own them in
:mod:`hindsight_api.engine.search.retrieval`.
Keeping this as an explicit store (rather than an ``if store is None`` branch at
each call site) means the default path is the one the whole test suite exercises,
and a second implementation cannot change it by accident.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from .base import (
DeletePredicate,
EntityPrunePassResult,
MemoriesExtension,
MemoryPatch,
RecallArms,
RelinkPassResult,
ScanPage,
StoredMemory,
)
from .pg import counts, curation, graph, reads, writes
class PostgresMemories(MemoriesExtension):
"""Memories in `memory_units`, links in `memory_links` / `unit_entities`."""
name = "postgres"
# ------------------------------------------------------------------ writes
async def insert_facts(
self,
*,
conn,
ops,
bank_id: str,
facts: list,
document_id: str | None = None,
defer_index: bool = False,
txn=None,
) -> list[str]:
# `txn` is ignored: Postgres memories live in the caller's own transaction, so the
# write is already atomic with it — there is no separate store to hold invisible.
# `defer_index` is meaningless here: the INSERT that returns the ids is
# also what indexes the facts, so there is nothing to defer.
return await writes.insert_facts(conn=conn, ops=ops, bank_id=bank_id, facts=facts, document_id=document_id)
async def delete_facts(self, bank_id: str, unit_ids: list[str], *, txn=None) -> None:
"""No-op: the caller's `memory_units` DELETE (or its FK cascade) removed them."""
async def delete_where(self, bank_id: str, predicate: DeletePredicate, txn=None) -> int:
"""No-op: predicate deletes are issued as SQL by the caller that owns the transaction."""
return 0
async def delete_document(self, *, conn, fq_table, bank_id: str, document_id: str, txn=None) -> None:
# `txn` ignored: Postgres memories are covered by the caller's own transaction.
await writes.delete_document(conn=conn, fq_table=fq_table, bank_id=bank_id, document_id=document_id)
async def drop_bank_storage(self, bank_id: str) -> None:
"""No-op: deleting the bank cascades to its memories."""
async def delete_observations(self, *, conn, fq_table, bank_id: str, txn=None) -> None:
await writes.delete_observations(conn=conn, fq_table=fq_table, bank_id=bank_id)
async def update_memories(self, bank_id: str, patches: list[MemoryPatch], txn=None) -> None:
"""No-op: the caller's UPDATE already wrote the row it holds open."""
# ------------------------------------------------------------------ recall
async def recall_unified(
self,
*,
conn,
bank_id: str,
fact_types: list[str],
query_embedding: str,
query_text: str,
limit: int,
temporal_window: "tuple[datetime, datetime] | None" = None,
temporal_semantic_threshold: float = 0.1,
tags: list[str] | None = None,
tags_match: str = "any",
tag_groups: list | None = None,
created_after: datetime | None = None,
created_before: datetime | None = None,
min_semantic: float | None = None,
min_keyword: float | None = None,
enable_graph: bool = True,
) -> "dict[str, RecallArms]":
"""Run every recall arm for Postgres by orchestrating the split per-arm SQL internally.
The per-arm split is Postgres's own business, kept off the interface: this reproduces the
exact orchestration recall used before it was unified one dense+BM25 UNION query and the
temporal query share a single connection, then the graph retriever runs per fact_type on the
pool in parallel, seeded by the same dense over-fetch. Result is byte-identical to running
the arms separately; fusion/rerank still happen downstream.
"""
import asyncio
from ..db_utils import acquire_with_retry
from ..search.retrieval import get_default_graph_retriever
# `conn` is the connection pool: this store owns the per-arm orchestration and acquires its
# own connections from it (and runs the graph arm on it).
pool = conn
# graph_seed_min_similarity restricts which dense hits seed the graph arm; only the graph
# arm consumes the seeds, so it is resolved only when that arm runs. It does not affect the
# semantic/bm25 lists, so the dense+BM25 result is identical whether or not it is passed.
graph_seed_min_similarity = None
retriever = None
if enable_graph:
from ...config import get_config
graph_seed_min_similarity = get_config().graph_seed_min_similarity
# Resolving the retriever can lazily construct one, so only do it when the arm is on.
retriever = get_default_graph_retriever()
# Semantic + BM25 (+ temporal) share ONE connection, exactly as before: the dense/keyword
# UNION runs first, then the temporal query on the same connection, which is then released
# before the graph arm opens its own connections.
async with acquire_with_retry(pool) as db_conn:
semantic_bm25 = await self.search(
conn=db_conn,
bank_id=bank_id,
fact_types=fact_types,
query_embedding=query_embedding,
query_text=query_text,
limit=limit,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
created_after=created_after,
created_before=created_before,
min_semantic=min_semantic,
min_keyword=min_keyword,
graph_seed_min_similarity=graph_seed_min_similarity,
)
temporal_by_ft: dict[str, list] = {}
if temporal_window is not None:
start_date, end_date = temporal_window
temporal_by_ft = await self.temporal_search(
conn=db_conn,
bank_id=bank_id,
fact_types=fact_types,
query_embedding=query_embedding,
start_date=start_date,
end_date=end_date,
limit=limit,
semantic_threshold=temporal_semantic_threshold,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
created_after=created_after,
created_before=created_before,
)
# Graph per fact_type in parallel, on the pool, after the dense connection is released —
# seeded by the dense over-fetch (preselected_semantic_seeds), matching the prior path.
graph_by_ft: dict[str, list] = {ft: [] for ft in fact_types}
if enable_graph:
assert retriever is not None # only resolved when the arm is on
async def _run_graph(ft: str) -> list:
results, _timing = await retriever.retrieve(
pool=pool,
query_embedding_str=query_embedding,
bank_id=bank_id,
fact_type=ft,
budget=limit,
query_text=query_text,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
created_after=created_after,
created_before=created_before,
preselected_semantic_seeds=semantic_bm25[ft].graph_seeds,
)
return results
# gather preserves input order, so zip back onto fact_types positionally.
graph_lists = await asyncio.gather(*[_run_graph(ft) for ft in fact_types])
graph_by_ft = dict(zip(fact_types, graph_lists))
return {
ft: RecallArms(
semantic=semantic_bm25[ft].semantic,
bm25=semantic_bm25[ft].bm25,
graph=graph_by_ft.get(ft, []),
temporal=temporal_by_ft.get(ft, []),
)
for ft in fact_types
}
# ---- per-arm SQL helpers, private to Postgres (called only by recall_unified) ----
async def search(
self,
*,
conn,
bank_id: str,
fact_types: list[str],
query_embedding: str,
query_text: str,
limit: int,
tags: list[str] | None = None,
tags_match: str = "any",
tag_groups: list | None = None,
created_after: datetime | None = None,
created_before: datetime | None = None,
min_semantic: float | None = None,
min_keyword: float | None = None,
graph_seed_min_similarity: float | None = None,
) -> "dict[str, SemanticBm25Result]":
# Imported here: retrieval imports this package, so a module-level import
# would close the cycle.
from ..search.retrieval import retrieve_semantic_bm25_combined_sql
return await retrieve_semantic_bm25_combined_sql(
conn,
query_embedding,
query_text,
bank_id,
fact_types,
limit,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
created_after=created_after,
created_before=created_before,
min_semantic=min_semantic,
min_keyword=min_keyword,
graph_seed_min_similarity=graph_seed_min_similarity,
)
async def temporal_search(
self,
*,
conn,
bank_id: str,
fact_types: list[str],
query_embedding: str,
start_date: datetime,
end_date: datetime,
limit: int,
semantic_threshold: float = 0.1,
tags: list[str] | None = None,
tags_match: str = "any",
tag_groups: list | None = None,
created_after: datetime | None = None,
created_before: datetime | None = None,
) -> dict[str, list]:
from ..search.retrieval import retrieve_temporal_combined_sql
return await retrieve_temporal_combined_sql(
conn,
query_embedding,
bank_id,
fact_types,
start_date,
end_date,
limit,
semantic_threshold=semantic_threshold,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
created_after=created_after,
created_before=created_before,
)
# ------------------------------------------------------------------ addressed reads
async def get_memories(self, *, conn, fq_table, bank_id: str, unit_ids: list[str]) -> list[StoredMemory]:
return await reads.get_memories(conn=conn, fq_table=fq_table, bank_id=bank_id, unit_ids=unit_ids)
async def scan_memories(
self,
*,
conn,
fq_table,
bank_id: str,
fact_types: list[str] | None = None,
limit: int = 100,
page_token: str = "",
tags: list[str] | None = None,
tags_match: str = "any",
tag_groups: list | None = None,
document_id: str | None = None,
metadata_equals: dict[str, str] | None = None,
skip: int = 0,
include_edges: bool = False,
) -> ScanPage:
return await reads.scan_memories(
conn=conn,
fq_table=fq_table,
bank_id=bank_id,
fact_types=fact_types,
limit=limit,
page_token=page_token,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
document_id=document_id,
metadata_equals=metadata_equals,
skip=skip,
include_edges=include_edges,
)
async def count_memories(self, *, conn, fq_table, bank_id: str) -> dict[str, int]:
return await reads.count_memories(conn=conn, fq_table=fq_table, bank_id=bank_id)
async def list_tags(
self,
*,
conn,
fq_table,
bank_id: str,
pattern: str | None = None,
limit: int = 100,
offset: int = 0,
) -> dict[str, Any]:
return await reads.list_tags(
conn=conn, fq_table=fq_table, bank_id=bank_id, pattern=pattern, limit=limit, offset=offset
)
async def find_unconsolidated(
self,
*,
conn,
fq_table,
bank_id: str,
fact_types: list[str],
limit: int,
scope_tags: list[str] | None = None,
) -> list[StoredMemory]:
return await reads.find_unconsolidated(
conn=conn,
fq_table=fq_table,
bank_id=bank_id,
fact_types=fact_types,
limit=limit,
scope_tags=scope_tags,
)
async def count_unconsolidated(
self,
*,
conn,
fq_table,
bank_id: str,
fact_types: list[str],
scopes: list[list[str] | None],
limit: int,
) -> int:
return await reads.count_unconsolidated(
conn=conn, fq_table=fq_table, bank_id=bank_id, fact_types=fact_types, scopes=scopes, limit=limit
)
async def mark_consolidated(
self,
*,
conn,
fq_table,
bank_id: str,
unit_ids: list[str],
when: datetime | None,
failed: bool = False,
txn=None,
) -> None:
await reads.mark_consolidated(
conn=conn, fq_table=fq_table, bank_id=bank_id, unit_ids=unit_ids, when=when, failed=failed
)
async def any_memory_updated_since(
self,
*,
conn,
fq_table,
bank_id: str,
since: datetime,
fact_types: list[str] | None = None,
tags: list[str] | None = None,
tags_match: str = "any",
tag_groups: list | None = None,
) -> bool:
return await reads.any_memory_updated_since(
conn=conn,
fq_table=fq_table,
bank_id=bank_id,
since=since,
fact_types=fact_types,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
)
# -- count surfaces --
async def consolidation_freshness(self, *, conn, fq_table, bank_id: str) -> dict[str, Any]:
return await counts.consolidation_freshness(conn=conn, fq_table=fq_table, bank_id=bank_id)
async def document_memory_counts(self, *, conn, fq_table, bank_id: str, document_ids: list[str]) -> dict[str, int]:
return await counts.document_memory_counts(
conn=conn, fq_table=fq_table, bank_id=bank_id, document_ids=document_ids
)
async def link_counts(self, *, conn, fq_table, bank_id: str) -> dict[str, int]:
return await counts.link_counts(conn=conn, fq_table=fq_table, bank_id=bank_id)
async def memories_timeseries(
self, *, conn, fq_table, bank_id: str, time_field: str, trunc: str, since: datetime
) -> list[dict[str, Any]]:
return await counts.memories_timeseries(
conn=conn, fq_table=fq_table, bank_id=bank_id, time_field=time_field, trunc=trunc, since=since
)
async def observation_scope_counts(self, *, conn, fq_table, bank_id: str) -> list[dict[str, Any]]:
return await counts.observation_scope_counts(conn=conn, fq_table=fq_table, bank_id=bank_id)
# ------------------------------------------------------------------ observations
async def upsert_observation(self, *, conn, bank_id: str, record, txn=None) -> None:
"""No-op: the observation was written as a `memory_units` row by the caller."""
async def observations_for_sources(
self, *, conn, ops, fq_table, bank_id: str, unit_ids: list[str]
) -> list[StoredMemory]:
return await writes.observations_for_sources(
conn=conn, ops=ops, fq_table=fq_table, bank_id=bank_id, unit_ids=unit_ids
)
async def delete_stale_observations(self, *, conn, ops, fq_table, bank_id: str, fact_ids: list) -> int:
return await writes.delete_stale_observations(
conn=conn, ops=ops, fq_table=fq_table, bank_id=bank_id, fact_ids=fact_ids
)
# ------------------------------------------------------------------ curation reads
async def list_memory_units(
self,
*,
conn,
ops,
fq_table,
bank_id: str,
fact_type: str | None = None,
search_query: str | None = None,
consolidation_state: str | None = None,
state: str | None = None,
document_id: str | None = None,
entity_id: str | None = None,
tags: list[str] | None = None,
tags_match: str = "any",
created_before: datetime | None = None,
limit: int = 100,
offset: int = 0,
) -> dict[str, Any]:
return await curation.list_memory_units(
conn=conn,
ops=ops,
fq_table=fq_table,
bank_id=bank_id,
fact_type=fact_type,
search_query=search_query,
consolidation_state=consolidation_state,
state=state,
document_id=document_id,
entity_id=entity_id,
tags=tags,
tags_match=tags_match,
created_before=created_before,
limit=limit,
offset=offset,
)
async def get_memory_unit(self, *, conn, ops, fq_table, bank_id: str, unit_id: str) -> dict[str, Any] | None:
return await curation.get_memory_unit(conn=conn, ops=ops, fq_table=fq_table, bank_id=bank_id, unit_id=unit_id)
# -- curation archive --
async def get_archived_memory(self, *, conn, fq_table, bank_id: str, unit_id: str) -> StoredMemory | None:
return await writes.get_archived_memory(conn=conn, fq_table=fq_table, bank_id=bank_id, unit_id=unit_id)
async def invalidate_memory(
self, *, conn, fq_table, bank_id: str, unit_id: str, reason: str | None, txn=None
) -> bool:
return await writes.invalidate_memory(
conn=conn, fq_table=fq_table, bank_id=bank_id, unit_id=unit_id, reason=reason
)
async def set_invalidation_reason(self, *, conn, fq_table, bank_id: str, unit_id: str, reason: str | None) -> None:
await writes.set_invalidation_reason(
conn=conn, fq_table=fq_table, bank_id=bank_id, unit_id=unit_id, reason=reason
)
async def restore_memory(self, *, conn, fq_table, bank_id: str, unit_id: str, txn=None) -> StoredMemory | None:
return await writes.restore_memory(conn=conn, fq_table=fq_table, bank_id=bank_id, unit_id=unit_id)
async def set_memory_embedding(self, *, conn, fq_table, bank_id: str, unit_id: str, embedding, txn=None) -> None:
await writes.set_memory_embedding(
conn=conn, fq_table=fq_table, bank_id=bank_id, unit_id=unit_id, embedding=embedding
)
async def clear_unit_entities(self, *, conn, fq_table, bank_id: str, unit_id: str) -> None:
await writes.clear_unit_entities(conn=conn, fq_table=fq_table, bank_id=bank_id, unit_id=unit_id)
async def apply_edit(
self,
*,
conn,
fq_table,
bank_id: str,
unit_id: str,
text: str,
context: str | None,
fact_type: str,
occurred_start,
occurred_end,
event_date,
mentioned_at,
entity_ids: list[str] | None,
txn=None,
) -> None:
await writes.apply_edit(
conn=conn,
fq_table=fq_table,
bank_id=bank_id,
unit_id=unit_id,
text=text,
context=context,
fact_type=fact_type,
occurred_start=occurred_start,
occurred_end=occurred_end,
event_date=event_date,
mentioned_at=mentioned_at,
entity_ids=entity_ids,
)
async def list_entities(
self,
*,
conn,
fq_table,
bank_id: str,
search: str | None = None,
limit: int = 100,
offset: int = 0,
) -> dict[str, Any]:
return await curation.list_entities(
conn=conn, fq_table=fq_table, bank_id=bank_id, search=search, limit=limit, offset=offset
)
# ------------------------------------------------------------------ graph
async def graph_units(
self,
*,
conn,
fq_table,
bank_id: str,
fact_type: str | None = None,
search_query: str | None = None,
document_id: str | None = None,
chunk_id: str | None = None,
tags: list[str] | None = None,
tags_match: str = "all_strict",
limit: int = 1000,
) -> dict[str, Any]:
return await graph.graph_units(
conn=conn,
fq_table=fq_table,
bank_id=bank_id,
fact_type=fact_type,
search_query=search_query,
document_id=document_id,
chunk_id=chunk_id,
tags=tags,
tags_match=tags_match,
limit=limit,
)
async def graph_entity_rows(self, *, conn, fq_table, bank_id: str, unit_ids: list[str]) -> list[dict[str, Any]]:
return await graph.graph_entity_rows(conn=conn, fq_table=fq_table, bank_id=bank_id, unit_ids=unit_ids)
async def graph_direct_links(self, *, conn, fq_table, bank_id: str, unit_ids: list[str]) -> list[dict[str, Any]]:
return await graph.graph_direct_links(conn=conn, fq_table=fq_table, bank_id=bank_id, unit_ids=unit_ids)
async def entity_memory_counts(
self, *, conn, fq_table, bank_id: str, entity_ids: list[str] | None = None
) -> dict[str, int]:
return await graph.entity_memory_counts(conn=conn, fq_table=fq_table, bank_id=bank_id, entity_ids=entity_ids)
async def entities_for_units(self, *, conn, fq_table, bank_id: str, unit_ids: list[str]) -> dict[str, list[str]]:
return await graph.entities_for_units(conn=conn, fq_table=fq_table, bank_id=bank_id, unit_ids=unit_ids)
async def entity_map_for_units(
self, *, conn, fq_table, bank_id: str, unit_ids: list[str]
) -> dict[str, list[dict[str, str]]]:
return await graph.entity_map_for_units(conn=conn, fq_table=fq_table, bank_id=bank_id, unit_ids=unit_ids)
async def resolve_entity_names(self, *, conn, fq_table, bank_id: str, entity_ids: list[str]) -> dict[str, str]:
return await graph.resolve_entity_names(conn=conn, fq_table=fq_table, bank_id=bank_id, entity_ids=entity_ids)
# ------------------------------------------------------------------ maintenance
async def record_unit_entities(
self,
*,
conn,
ops,
fq_table,
bank_id: str | None = None,
unit_ids: list[Any],
entity_ids: list[Any],
txn=None,
) -> None:
# The join is keyed by global unit id, so bank_id is not needed here. `txn` is inert: this
# posting is an ordinary INSERT in the caller's own transaction, which is already the unit
# of atomicity — there is no second store to coordinate with.
await ops.bulk_insert_unit_entities(conn, fq_table("unit_entities"), unit_ids, entity_ids)
async def enqueue_relink_victims(
self, *, conn, fq_table, bank_id: str, affected_unit_ids: list, include_affected_units: bool = False
) -> int:
return await graph.enqueue_relink_victims(
conn=conn,
fq_table=fq_table,
bank_id=bank_id,
affected_unit_ids=affected_unit_ids,
include_affected_units=include_affected_units,
)
async def relink_pass(
self, *, backend, fq_table, bank_id: str, config, deadline: float | None = None
) -> RelinkPassResult:
return await graph.relink_pass(
backend=backend, fq_table=fq_table, bank_id=bank_id, config=config, deadline=deadline
)
async def enqueue_entity_prune_candidates(self, *, conn, fq_table, bank_id: str, affected_unit_ids: list) -> int:
return await graph.enqueue_entity_prune_candidates(
conn=conn,
fq_table=fq_table,
bank_id=bank_id,
affected_unit_ids=affected_unit_ids,
)
async def entity_prune_pass(
self, *, backend, fq_table, bank_id: str, deadline: float | None = None
) -> EntityPrunePassResult:
return await graph.entity_prune_pass(backend=backend, fq_table=fq_table, bank_id=bank_id, deadline=deadline)
__all__ = ["PostgresMemories"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,244 @@
"""Models describing what a mental model refresh did.
A refresh resolves a scope, picks full-vs-delta, runs reflect over a bounded
snapshot, and (in delta mode) applies structured operations to the existing
document. Every one of those steps can quietly produce a document that isn't
what the user expected, and until now the reasoning behind each only ever
reached a log line.
These models carry that reasoning out to callers, so both the dry run (preview,
nothing persisted) and ``trigger.keep_trace`` (recorded on every real refresh,
including the cron- and consolidation-driven ones no human is watching) can
report it.
Kept out of ``response_models`` on purpose: these reference the tag-group types
from ``search.tags``, and ``response_models`` is imported early enough in the
engine's import graph that pulling the search package in from there is a cycle.
"""
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from .response_models import LLMCallTrace, TokenUsage
from .search.tags import TagGroup, TagsMatch
RefreshMode = Literal["full", "delta"]
ModeFallbackReason = Literal[
"no_baseline_content",
"source_query_changed",
"structured_doc_unreadable",
"delta_ops_failed",
"delta_ops_all_skipped",
]
RefreshOutcome = Literal[
"content_written",
"content_preserved_no_new_facts",
"refresh_failed_empty_candidate",
"refresh_failed_delta_not_applied",
]
class MentalModelRefreshScope(BaseModel):
"""The memory scope a refresh actually resolved to.
A model's stored ``tags`` are not what filters memories — ``tags_match``
defaults to ``all_strict`` when tags are present, and ``tag_groups``
override flat tags entirely. This reports the resolved result.
"""
tags: list[str] | None = Field(default=None, description="Flat tags used to filter memories (null when unused).")
tags_match: TagsMatch = Field(description="Resolved tag match mode.")
tag_groups: list[TagGroup] | None = Field(
default=None, description="Compound tag expressions used instead of flat tags, when set."
)
fact_types: list[str] | None = Field(default=None, description="Fact types retrieved (null means all).")
exclude_mental_models: bool = Field(description="Whether other mental models were excluded from the reflect loop.")
exclude_mental_model_ids: list[str] = Field(
default_factory=list, description="Mental models excluded by ID (always includes the model being refreshed)."
)
class MentalModelRefreshWindow(BaseModel):
"""The time window a refresh read memories from."""
created_after: datetime | None = Field(
default=None,
description=(
"Lower bound on when a memory last changed. Set only in delta mode, where it is the "
"model's last_memory_seen_at — so a delta refresh only sees memories written or edited "
"since the newest one the previous refresh saw."
),
)
created_before: datetime = Field(
description=(
"Database-time snapshot bounding the refresh. Memories written or edited after this are "
"not read, so they stay newer than the persisted watermark and are caught by the next "
"refresh."
)
)
watermark: datetime | None = Field(
default=None,
description=(
"The last_memory_seen_at a real refresh would persist: the newest in-scope memory visible at "
"the snapshot, not now(). Null means no in-scope memory was visible."
),
)
class MentalModelFactCounts(BaseModel):
"""Facts the refresh saw, keyed by fact type.
``retrieved`` and ``used`` diverging is the single most common cause of a
disappointing refresh: recall found plenty, but the reflect agent declared
none of it relevant to the topic, so none of it reached the document.
"""
retrieved: dict[str, int] = Field(
default_factory=dict, description="Facts the reflect agent's tool calls returned, by fact type."
)
used: dict[str, int] = Field(
default_factory=dict, description="Facts the agent declared it actually based the answer on, by fact type."
)
class MentalModelDeltaOperations(BaseModel):
"""Structured operations a delta refresh emitted against the existing document."""
applied: list[dict[str, Any]] = Field(
default_factory=list, description="Operations applied to the document, in order."
)
skipped: list[dict[str, Any]] = Field(
default_factory=list, description="Operations dropped as invalid, each with a reason."
)
class MentalModelTraceToolCall(BaseModel):
"""One reflect tool call made during a refresh.
``output`` is carried only by the dry run, which persists nothing. The trace
stored on the model row keeps ``result_count`` instead: it is re-read on every
fetch, so embedding full recall payloads there would bloat the row without
bound. Raw prompts and responses are available separately via LLM request
tracing.
"""
tool: str = Field(description="Tool name: recall, search_observations, get_mental_model, expand, …")
reason: str | None = Field(default=None, description="The agent's stated reason for the call.")
input: dict[str, Any] = Field(default_factory=dict, description="Tool input parameters.")
output: dict[str, Any] | None = Field(
default=None,
description=(
"What the tool returned. Present on a dry run, which stores nothing; omitted from the "
"trace persisted by a real refresh to keep that row bounded."
),
)
updated_at: datetime | None = Field(
default=None,
description=(
"The refresh window's lower bound as given to this call — the delta watermark. Named "
"for what it actually filters: the predicate is on the memory's updated_at, so a "
"memory merely touched since the last refresh qualifies. Null means the tool applies "
"no time bound at all, so its results are not limited to the window (mental-model "
"lookup and chunk expansion behave this way)."
),
)
result_count: int | None = Field(default=None, description="Number of items the tool returned, when countable.")
duration_ms: int = Field(description="Execution time in milliseconds.")
iteration: int = Field(default=0, description="Agent loop iteration (1-based) this call belongs to.")
class MentalModelRefreshTrace(BaseModel):
"""Execution trace of a mental model refresh, recorded when trigger.keep_trace is on.
Deliberately shaped like reflect's trace — the calls the agent made, plus the
refresh-specific decision and nothing more. This is persisted on the mental
model row and re-read on every fetch, so anything derivable from elsewhere is
left out: the evidence lives in ``reflect_response.based_on``, and the
resolved scope and snapshot window are reported by the dry run.
"""
recorded_at: datetime | None = Field(default=None, description="When this trace was recorded.")
effective_mode: RefreshMode = Field(description="Whether the refresh ran as full or delta.")
mode_fallback_reason: ModeFallbackReason | None = Field(
default=None, description="Why delta was requested but not applied, if that happened."
)
outcome: RefreshOutcome = Field(description="What the refresh did with the document.")
tool_calls: list[MentalModelTraceToolCall] = Field(
default_factory=list, description="Reflect tool calls made during the refresh."
)
llm_calls: list[LLMCallTrace] = Field(default_factory=list, description="LLM calls made during the refresh.")
delta_operations: MentalModelDeltaOperations | None = Field(
default=None, description="Structured operations emitted, in delta mode."
)
usage: TokenUsage | None = Field(default=None, description="Token usage across the refresh's LLM calls.")
duration_ms: int = Field(default=0, description="Wall-clock duration of the refresh.")
warnings: list[str] = Field(
default_factory=list, description="Conditions worth a human's attention, in plain language."
)
class MentalModelDryRunRefreshResult(BaseModel):
"""Preview of what a mental model refresh would do, having changed nothing.
Runs the real pipeline same scope resolution, same reflect call, same
delta operations then reports the result instead of persisting it. The
model's content, structured content, watermark, and last_refreshed_at are
all left untouched, so a delta dry run is repeatable: it reads the same
window the next real refresh would.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"mental_model_id": "coding-style",
"name": "Coding Style",
"requested_mode": "delta",
"effective_mode": "full",
"mode_fallback_reason": "source_query_changed",
"outcome": "content_written",
"would_persist": True,
"facts": {"retrieved": {"observation": 12}, "used": {"observation": 4}},
"warnings": [],
}
}
)
mental_model_id: str = Field(description="The mental model previewed.")
name: str = Field(description="Display name of the mental model.")
requested_mode: RefreshMode = Field(description="The mode asked for (from the model's trigger, or overridden).")
effective_mode: RefreshMode = Field(description="The mode the refresh actually ran in.")
mode_fallback_reason: ModeFallbackReason | None = Field(
default=None, description="Why delta was requested but not applied, if that happened."
)
outcome: RefreshOutcome = Field(description="What a real refresh would do with the document.")
would_persist: bool = Field(description="Whether a real refresh would write new content.")
scope: MentalModelRefreshScope = Field(description="The resolved memory scope.")
window: MentalModelRefreshWindow = Field(description="The snapshot window read from.")
facts: MentalModelFactCounts = Field(description="Facts retrieved versus actually used.")
based_on: dict[str, list[dict[str, Any]]] = Field(
default_factory=dict,
description=(
"The evidence this run would ground the document on, keyed by fact type — the same "
"shape a refresh persists under reflect_response.based_on. Returned so a preview can "
"show its sources without having to write them anywhere."
),
)
current_content: str = Field(description="The model's content as it stands now.")
candidate_content: str = Field(description="Raw reflect synthesis, before any delta operations.")
preview_content: str = Field(
description="The content a real refresh would store: the delta-edited document, or the candidate in full mode."
)
diff: str = Field(description="Unified diff from current_content to preview_content. Empty when identical.")
delta_operations: MentalModelDeltaOperations | None = Field(
default=None, description="Structured operations emitted, in delta mode."
)
trace: MentalModelRefreshTrace = Field(description="Execution trace of the run, always included for a dry run.")
usage: TokenUsage = Field(default_factory=TokenUsage, description="Token usage across the run's LLM calls.")
duration_ms: int = Field(default=0, description="Wall-clock duration of the run.")
warnings: list[str] = Field(
default_factory=list, description="Conditions worth a human's attention, in plain language."
)
@@ -19,10 +19,18 @@ class BatchRetainParentMetadata:
total_tokens: int
num_sub_batches: int
is_parent: bool = True
# Set only when the whole batch targets a single document, so the operations
# list surfaces which document an in-flight retain is (re)writing. The
# documents UI cross-checks this to badge rows as "updating". Multi-document
# batches leave it None and are matched per single-document child instead.
document_id: str | None = None
def to_dict(self) -> dict[str, Any]:
"""Convert to dict for JSON serialization."""
return asdict(self)
"""Convert to dict for JSON serialization, omitting document_id when unset."""
data = asdict(self)
if data.get("document_id") is None:
data.pop("document_id", None)
return data
@dataclass
@@ -33,10 +41,15 @@ class BatchRetainChildMetadata:
parent_operation_id: str
sub_batch_index: int
total_sub_batches: int
# Set only when this child processes a single document (see the parent's note).
document_id: str | None = None
def to_dict(self) -> dict[str, Any]:
"""Convert to dict for JSON serialization."""
return asdict(self)
"""Convert to dict for JSON serialization, omitting document_id when unset."""
data = asdict(self)
if data.get("document_id") is None:
data.pop("document_id", None)
return data
@dataclass
@@ -156,6 +169,12 @@ class RefreshMentalModelOutcomeMetadata:
content_len: int
populated_content: bool
based_on_counts: dict[str, int] = field(default_factory=dict)
# Delta operations the model emitted, as applied vs rejected. A refresh whose
# ops are routinely rejected still completes successfully with a plausible
# document, so the count is the only signal that some of this run's new facts
# never reached it. Both are 0 for a full-mode refresh, which emits no ops.
delta_ops_applied: int = 0
delta_ops_skipped: int = 0
def to_dict(self) -> dict[str, Any]:
"""Convert to dict for JSON serialization."""
@@ -72,6 +72,7 @@ class MarkitdownParser(FileParser):
ocr_base_url: str | None = None,
ocr_model: str | None = None,
ocr_prompt: str | None = None,
ocr_default_headers: dict[str, str] | None = None,
):
"""Initialize markitdown parser."""
# Lazy import to avoid requiring markitdown for all users
@@ -89,6 +90,7 @@ class MarkitdownParser(FileParser):
base_url=ocr_base_url,
model=ocr_model,
prompt=ocr_prompt,
default_headers=ocr_default_headers,
)
self._markitdown = MarkItDown(
llm_client=ocr_options.llm_client,
@@ -105,6 +107,7 @@ class MarkitdownParser(FileParser):
base_url: str | None,
model: str | None,
prompt: str | None,
default_headers: dict[str, str] | None,
) -> MarkitdownOcrOptions:
"""Build MarkItDown options for OpenAI-compatible image OCR."""
if not model or not model.strip():
@@ -129,8 +132,15 @@ class MarkitdownParser(FileParser):
except ImportError as e:
raise RuntimeError("openai package is required when Markitdown OCR is enabled.") from e
client_kwargs: dict[str, object] = {
"api_key": api_key,
"base_url": base_url.strip(),
}
if default_headers:
client_kwargs["default_headers"] = default_headers
return MarkitdownOcrOptions(
llm_client=OpenAI(api_key=api_key, base_url=base_url.strip()),
llm_client=OpenAI(**client_kwargs),
llm_model=model.strip(),
llm_prompt=prompt or DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT,
)
@@ -15,6 +15,7 @@ from .llamacpp_llm import LlamaCppLLM
from .mock_llm import MockLLM
from .none_llm import NoneLLM
from .openai_compatible_llm import OpenAICompatibleLLM
from .openai_responses_llm import OpenAIResponsesLLM
__all__ = [
"AnthropicLLM",
@@ -28,4 +29,5 @@ __all__ = [
"MockLLM",
"NoneLLM",
"OpenAICompatibleLLM",
"OpenAIResponsesLLM",
]
@@ -12,13 +12,15 @@ import asyncio
import json
import logging
import time
from typing import Any
from contextlib import AbstractAsyncContextManager, nullcontext
from typing import Any, Callable
from hindsight_api.engine.llm_interface import LLM_TOOL_CHOICE_AUTO, LLMInterface, LLMToolChoice
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
from hindsight_api.engine.providers.llm_debug import dump_request_on_4xx
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
from hindsight_api.worker.stage import set_stage
logger = logging.getLogger(__name__)
@@ -86,7 +88,7 @@ class AnthropicLLM(LLMInterface):
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
reasoning_effort: str | None = None,
timeout: float = 300.0,
default_headers: dict[str, str] | None = None,
extra_body: dict[str, Any] | None = None,
@@ -113,6 +115,7 @@ class AnthropicLLM(LLMInterface):
**kwargs: Additional provider-specific parameters.
"""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
self._warn_reasoning_effort_unsupported()
if not self.api_key:
raise ValueError("API key is required for Anthropic provider")
@@ -173,6 +176,7 @@ class AnthropicLLM(LLMInterface):
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> Any:
"""
Make an LLM API call with retry logic.
@@ -263,7 +267,9 @@ class AnthropicLLM(LLMInterface):
for attempt in range(max_retries + 1):
try:
response = await self._client.messages.create(**call_params)
async with attempt_context() if attempt_context is not None else nullcontext():
set_stage(f"llm.{self.provider}.{scope}.attempt={attempt + 1}/{max_retries + 1}")
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))
@@ -424,6 +430,7 @@ class AnthropicLLM(LLMInterface):
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support.
@@ -513,7 +520,9 @@ class AnthropicLLM(LLMInterface):
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._client.messages.create(**call_params)
async with attempt_context() if attempt_context is not None else nullcontext():
set_stage(f"llm.{self.provider}.{scope}.attempt={attempt + 1}/{max_retries + 1}")
response = await self._client.messages.create(**call_params)
stash_response_usage(_usage_from_anthropic_response(response))
# Extract content and tool calls
@@ -808,3 +817,6 @@ class AnthropicLLM(LLMInterface):
"""Clean up resources (close Anthropic client connections)."""
if hasattr(self, "_client") and self._client:
await self._client.close()
def supports_attempt_scoped_concurrency(self) -> bool:
return True
@@ -11,7 +11,8 @@ import json
import logging
import tempfile
import time
from typing import Any
from contextlib import AbstractAsyncContextManager, nullcontext
from typing import Any, Callable
from pydantic import ValidationError
@@ -19,6 +20,7 @@ from hindsight_api.engine.llm_interface import LLM_TOOL_CHOICE_AUTO, LLMInterfac
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
logger = logging.getLogger(__name__)
@@ -77,11 +79,12 @@ class ClaudeCodeLLM(LLMInterface):
api_key: str, # Will be ignored, uses CLI auth
base_url: str,
model: str,
reasoning_effort: str = "low",
reasoning_effort: str | None = None,
**kwargs: Any,
):
"""Initialize Claude Code LLM provider."""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
self._warn_reasoning_effort_unsupported()
# Verify Claude Agent SDK is available
try:
@@ -162,6 +165,7 @@ class ClaudeCodeLLM(LLMInterface):
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> Any:
"""
Make an LLM API call with retry logic.
@@ -255,16 +259,18 @@ class ClaudeCodeLLM(LLMInterface):
# Collect streaming response
full_text = ""
async for message in query(prompt=user_content, options=options):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
full_text += block.text
elif isinstance(message, ResultMessage) and message.is_error:
# Surface the CLI's actual error text (e.g. quota
# exhaustion) instead of the SDK's subtype-based
# fallback exception (issue #2702).
raise RuntimeError(_result_error_detail(message))
async with attempt_context() if attempt_context is not None else nullcontext():
set_stage(f"llm.claude_code.{scope}.attempt={attempt + 1}/{max_retries + 1}")
async for message in query(prompt=user_content, options=options):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
full_text += block.text
elif isinstance(message, ResultMessage) and message.is_error:
# Surface the CLI's actual error text (e.g. quota
# exhaustion) instead of the SDK's subtype-based
# fallback exception (issue #2702).
raise RuntimeError(_result_error_detail(message))
# The Claude Agent SDK doesn't report exact counts; stash the same
# char/4 estimate the success path traces so a later parse/validate
@@ -324,7 +330,7 @@ class ClaudeCodeLLM(LLMInterface):
# Record trace span
try:
from hindsight_api.tracing import get_span_recorder
from hindsight_api.tracing import _serialize_for_span, get_span_recorder
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
@@ -332,15 +338,17 @@ class ClaudeCodeLLM(LLMInterface):
model=self.model,
scope=scope,
messages=messages,
response_content=result if isinstance(result, str) else result.model_dump_json(),
response_content=_serialize_for_span(result),
input_tokens=estimated_input,
output_tokens=estimated_output,
duration=duration,
finish_reason=None,
error=None,
)
except Exception:
pass # logging failure must never affect the operation
except Exception as span_error:
# Tracing must remain best-effort, but expose instrumentation
# bugs that would otherwise silently erase spans (#3025).
logger.debug("Claude Code span recording failed: %s", span_error, exc_info=True)
# Log slow calls
if duration > 10.0:
@@ -400,6 +408,7 @@ class ClaudeCodeLLM(LLMInterface):
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support using Claude Agent SDK.
@@ -576,47 +585,49 @@ class ClaudeCodeLLM(LLMInterface):
full_text = ""
tool_calls: list[LLMToolCall] = []
# Use ClaudeSDKClient for tool calling support
# Note: query() does NOT support custom tools, only ClaudeSDKClient does
async with ClaudeSDKClient(options=options) as client:
# Send the query
await client.query(user_content)
async with attempt_context() if attempt_context is not None else nullcontext():
set_stage(f"llm.claude_code.tools.attempt={attempt + 1}/{max_retries + 1}")
# Use ClaudeSDKClient for tool calling support
# Note: query() does NOT support custom tools, only ClaudeSDKClient does
async with ClaudeSDKClient(options=options) as client:
# Send the query
await client.query(user_content)
# Receive response
async for message in client.receive_response():
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
full_text += block.text
elif isinstance(block, ToolUseBlock):
# SDK returns tool names with MCP prefix (mcp__hindsight_tools__{name})
# Strip the prefix to return original tool name expected by caller
tool_name = block.name
if tool_name.startswith("mcp__hindsight_tools__"):
tool_name = tool_name.replace("mcp__hindsight_tools__", "", 1)
# Receive response
async for message in client.receive_response():
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
full_text += block.text
elif isinstance(block, ToolUseBlock):
# SDK returns tool names with MCP prefix (mcp__hindsight_tools__{name})
# Strip the prefix to return original tool name expected by caller
tool_name = block.name
if tool_name.startswith("mcp__hindsight_tools__"):
tool_name = tool_name.replace("mcp__hindsight_tools__", "", 1)
tool_calls.append(
LLMToolCall(
id=block.id,
name=tool_name,
arguments=block.input,
tool_calls.append(
LLMToolCall(
id=block.id,
name=tool_name,
arguments=block.input,
)
)
)
if tool_calls:
# This round proposed tool call(s). Stop consuming the
# stream so the SDK does not run another turn against our
# placeholder handlers (issue #2966) — the caller executes
# the real tools and calls us again with the results.
break
elif isinstance(message, ResultMessage) and message.is_error:
# With max_turns=1 the CLI reports error_max_turns whenever
# the model spent its single turn issuing a tool call (there
# was no follow-up turn to emit final text). That is expected
# here and not a failure: we already captured the tool call
# above and break before reaching this branch. Only a genuine
# error with nothing to return should surface (issue #2702).
if not tool_calls:
raise RuntimeError(_result_error_detail(message))
if tool_calls:
# This round proposed tool call(s). Stop consuming the
# stream so the SDK does not run another turn against our
# placeholder handlers (issue #2966) — the caller executes
# the real tools and calls us again with the results.
break
elif isinstance(message, ResultMessage) and message.is_error:
# With max_turns=1 the CLI reports error_max_turns whenever
# the model spent its single turn issuing a tool call (there
# was no follow-up turn to emit final text). That is expected
# here and not a failure: we already captured the tool call
# above and break before reaching this branch. Only a genuine
# error with nothing to return should surface (issue #2702).
if not tool_calls:
raise RuntimeError(_result_error_detail(message))
# Record metrics
duration = time.time() - start_time
@@ -678,3 +689,6 @@ class ClaudeCodeLLM(LLMInterface):
async def cleanup(self) -> None:
"""Clean up resources (no HTTP client to close for Claude Agent SDK)."""
pass
def supports_attempt_scoped_concurrency(self) -> bool:
return True
@@ -20,8 +20,9 @@ import json
import logging
import time
import uuid
from contextlib import AbstractAsyncContextManager, nullcontext
from pathlib import Path
from typing import Any
from typing import Any, Callable
import httpx
@@ -31,6 +32,7 @@ from hindsight_api.engine.providers.llm_debug import dump_request_on_4xx
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.engine.structured_output import strict_json_schema
from hindsight_api.metrics import get_metrics_collector
from hindsight_api.worker.stage import set_stage
from .codex_auth import (
_CODEX_CLIENT_ID,
@@ -124,7 +126,8 @@ class CodexLLM(LLMInterface):
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",
reasoning_effort: str | None = None,
extra_body: dict[str, Any] | None = None,
**kwargs: Any,
):
"""Initialize Codex LLM provider."""
@@ -175,6 +178,7 @@ class CodexLLM(LLMInterface):
# Reasoning summary controls presentation separately from the backend's
# reasoning effort, which is sent unchanged in each request payload.
self.reasoning_summary = self._map_reasoning_effort(reasoning_effort)
self._extra_body = dict(extra_body or {})
# HTTP client for SSE streaming
self._client = httpx.AsyncClient(timeout=120.0)
@@ -325,12 +329,26 @@ class CodexLLM(LLMInterface):
except CodexRefreshExpiredError:
raise
def _map_reasoning_effort(self, effort: str) -> str:
def _reasoning_payload(self, summary: str) -> dict[str, str]:
"""Build the ``reasoning`` request object.
``effort`` is present only when the operator configured one: an unset
HINDSIGHT_API_*_REASONING_EFFORT means the model runs at its own default
effort, and Hindsight does not pick one on the operator's behalf.
"""
payload = {"summary": summary}
if self.reasoning_effort is not None:
payload["effort"] = self.reasoning_effort
return payload
def _map_reasoning_effort(self, effort: str | None) -> str:
"""
Map standard reasoning effort to Codex reasoning summary format.
Args:
effort: Standard effort level ("low", "medium", "high", "xhigh").
effort: Standard effort level ("low", "medium", "high", "xhigh"), or None
when unconfigured the summary then stays "auto", the same neutral
presentation an unrecognised level gets.
Returns:
Codex reasoning summary: "concise", "detailed", or "auto".
@@ -341,7 +359,7 @@ class CodexLLM(LLMInterface):
"high": "detailed",
"xhigh": "detailed",
}
return mapping.get(effort.lower(), "auto")
return mapping.get(effort.lower(), "auto") if effort else "auto"
async def verify_connection(self) -> None:
"""Verify Codex connection by making a simple test call."""
@@ -376,6 +394,7 @@ class CodexLLM(LLMInterface):
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> Any:
"""Make API call to Codex backend with SSE streaming.
@@ -448,12 +467,13 @@ class CodexLLM(LLMInterface):
"tools": [],
"tool_choice": "auto",
"parallel_tool_calls": True,
"reasoning": {"effort": self.reasoning_effort, "summary": reasoning_summary},
"reasoning": self._reasoning_payload(reasoning_summary),
"store": False, # Codex uses stateless mode
"stream": True, # SSE streaming
"include": ["reasoning.encrypted_content"],
"prompt_cache_key": str(uuid.uuid4()),
}
payload.update(self._extra_body)
if use_forced_tool and schema is not None:
# Single function tool whose parameters ARE the response schema;
@@ -480,18 +500,20 @@ class CodexLLM(LLMInterface):
attempt = 0
while True:
try:
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
response.raise_for_status()
async with attempt_context() if attempt_context is not None else nullcontext():
set_stage(f"llm.codex.{scope}.attempt={attempt + 1}/{max_retries + 1}")
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
response.raise_for_status()
# Forced-tool path: read structured output from the function-call
# arguments (already a JSON string in a dedicated channel) rather
# than from free-form assistant text.
if use_forced_tool:
text_content, tool_calls = await self._parse_sse_tool_stream(response)
content = text_content or ""
else:
tool_calls = []
content = await self._parse_sse_stream(response)
# Forced-tool path: read structured output from the function-call
# arguments (already a JSON string in a dedicated channel) rather
# than from free-form assistant text.
if use_forced_tool:
text_content, tool_calls = await self._parse_sse_tool_stream(response)
content = text_content or ""
else:
tool_calls = []
content = await self._parse_sse_stream(response)
# Codex SSE carries no usage block; stash the same char/4 estimate
# the success path traces so a later parse/validate failure records
@@ -573,7 +595,7 @@ class CodexLLM(LLMInterface):
# Record trace span
try:
from hindsight_api.tracing import get_span_recorder
from hindsight_api.tracing import _serialize_for_span, get_span_recorder
# Estimate tokens for tracing
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
@@ -584,15 +606,17 @@ class CodexLLM(LLMInterface):
model=self.model,
scope=scope,
messages=messages,
response_content=result if isinstance(result, str) else result.model_dump_json(),
response_content=_serialize_for_span(result),
input_tokens=estimated_input,
output_tokens=estimated_output,
duration=duration,
finish_reason=None,
error=None,
)
except Exception:
pass # logging failure must never affect the operation
except Exception as span_error:
# Tracing must remain best-effort, but expose instrumentation
# bugs that would otherwise silently erase spans (#3025).
logger.debug("Codex span recording failed: %s", span_error, exc_info=True)
if return_usage:
# Codex doesn't provide token counts, estimate based on content
@@ -747,6 +771,7 @@ class CodexLLM(LLMInterface):
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> LLMToolCallResult:
"""
Make API call with tool calling support.
@@ -831,12 +856,13 @@ class CodexLLM(LLMInterface):
else tool_choice.mode.value
),
"parallel_tool_calls": True,
"reasoning": {"effort": self.reasoning_effort, "summary": reasoning_summary},
"reasoning": self._reasoning_payload(reasoning_summary),
"store": False,
"stream": True,
"include": ["reasoning.encrypted_content"],
"prompt_cache_key": str(uuid.uuid4()),
}
payload.update(self._extra_body)
headers = self._build_request_headers()
@@ -851,10 +877,28 @@ class CodexLLM(LLMInterface):
# surfaces immediately to keep behavior identical for callers.
attempted_refresh_after_auth_error = False
try:
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
async def _request_attempt(attempt: int) -> tuple[str | None, list[LLMToolCall]]:
async with attempt_context() if attempt_context is not None else nullcontext():
set_stage(f"llm.codex.tools.attempt={attempt}/2")
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
if response.status_code != 200:
# 401/403 on the first attempt may still be recovered by the
# reactive token refresh below — don't log those as errors yet.
detail = f"Codex API error {response.status_code}: {response.text[:500]}"
if response.status_code in (401, 403) and not attempted_refresh_after_auth_error:
logger.warning(f"{detail} (will attempt token refresh)")
else:
logger.error(detail)
response.raise_for_status()
return await self._parse_sse_tool_stream(response)
if response.status_code in (401, 403) and not attempted_refresh_after_auth_error:
try:
try:
content, tool_calls = await _request_attempt(1)
except httpx.HTTPStatusError as auth_error:
response = auth_error.response
if response.status_code not in (401, 403) or attempted_refresh_after_auth_error:
raise
attempted_refresh_after_auth_error = True
try:
await self._refresh_oauth_tokens(
@@ -863,7 +907,7 @@ class CodexLLM(LLMInterface):
)
headers["Authorization"] = f"Bearer {self.access_token}"
logger.info("Codex auth refreshed after auth error; retrying tool-call request once")
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
content, tool_calls = await _request_attempt(2)
except CodexRefreshExpiredError as refresh_err:
logger.error(
"Codex refresh_token is permanently invalid; cannot recover from auth error in tool-call path"
@@ -876,16 +920,7 @@ class CodexLLM(LLMInterface):
logger.error(
f"Codex token refresh attempt failed in tool-call path: {type(refresh_err).__name__}: {refresh_err}"
)
# Fall through to the normal error path below.
# Log response details on error
if response.status_code != 200:
logger.error(f"Codex API error {response.status_code}: {response.text[:500]}")
response.raise_for_status()
# Parse SSE for tool calls and content
content, tool_calls = await self._parse_sse_tool_stream(response)
raise auth_error
duration = time.time() - start_time
metrics = get_metrics_collector()
@@ -1006,3 +1041,6 @@ class CodexLLM(LLMInterface):
"""Clean up HTTP clients."""
await self._client.aclose()
self._auth_manager.close()
def supports_attempt_scoped_concurrency(self) -> bool:
return True
@@ -63,7 +63,7 @@ class FireworksLLM(OpenAICompatibleLLM):
api_key: str,
base_url: str = "",
model: str,
reasoning_effort: str = "low",
reasoning_effort: str | None = None,
account_id: str | None = None,
batch_base_url: str | None = None,
max_wait_seconds: int | None = None,
@@ -12,9 +12,10 @@ import io
import json
import logging
import time
from contextlib import AbstractAsyncContextManager, nullcontext
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Any
from typing import Any, Callable
from google import genai
from google.genai import errors as genai_errors
@@ -173,11 +174,12 @@ class GeminiLLM(LLMInterface):
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
reasoning_effort: str | None = None,
**kwargs: Any,
):
"""Initialize Gemini/VertexAI LLM provider."""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
self._warn_reasoning_effort_unsupported()
self._client = None
self._is_vertexai = self.provider == "vertexai"
@@ -311,6 +313,7 @@ class GeminiLLM(LLMInterface):
strict_schema: bool = False,
return_usage: bool = False,
cached_prefix: str | None = None,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> Any:
"""
Make a Gemini/VertexAI API call with retry logic.
@@ -424,16 +427,17 @@ class GeminiLLM(LLMInterface):
last_exception = None
for attempt in range(max_retries + 1):
set_stage(f"llm.gemini.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await asyncio.wait_for(
self._client.aio.models.generate_content(
model=self.model,
contents=gemini_contents,
config=generation_config,
),
timeout=90.0, # Safety net for network hangs; valid slow responses are <90s
)
async with attempt_context() if attempt_context is not None else nullcontext():
set_stage(f"llm.gemini.{scope}.attempt={attempt + 1}/{max_retries + 1}")
response = await asyncio.wait_for(
self._client.aio.models.generate_content(
model=self.model,
contents=gemini_contents,
config=generation_config,
),
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))
@@ -507,6 +511,21 @@ class GeminiLLM(LLMInterface):
if hasattr(response, "candidates") and response.candidates:
if hasattr(response.candidates[0], "finish_reason"):
finish_reason = str(response.candidates[0].finish_reason)
# Surface silent truncation. A non-empty response that stopped on
# MAX_TOKENS was cut off (often mid-word) yet still returns as a
# success — on thinking models the reasoning tokens can consume the
# whole max_output_tokens budget, leaving the visible answer
# truncated (#3365). Make it visible in the logs rather than let a
# half-written page look healthy.
if finish_reason and "MAX_TOKENS" in finish_reason and content:
logger.warning(
"Gemini response truncated at max_output_tokens "
f"(scope={scope}, model={self.model}, max_output_tokens={max_completion_tokens}, "
f"output_tokens={output_tokens}, thoughts_tokens={thoughts_tokens}). The visible "
"output was cut off; raise the cap or leave it unset for reasoning models."
)
span_recorder = get_span_recorder()
from hindsight_api.tracing import _serialize_for_span
@@ -573,23 +592,14 @@ class GeminiLLM(LLMInterface):
logger.error(f"Gemini auth error (HTTP {e.code}), not retrying: {str(e)}")
raise
# Diagnostic dump (opt-in) of the exact request behind any 4xx, captured
# before the cache-drop retry below rebuilds the config so we see what failed.
dump_request_on_4xx(
scope=scope,
provider=self.provider,
model=self.model,
err=e,
request=generation_config,
messages=gemini_contents,
)
# Cached-request safety net: a stale/invalid/expired CachedContent
# (or an incompatibility like cache + tool_config) surfaces as a 400.
# Retrying the same cached request can't recover, so on the first
# such failure drop the cache, invalidate it so later operations
# recreate it, and retry THIS call inline with the prefix inlined.
# Caching must never break a request.
# Caching must never break a request. Handled before the 400
# fail-fast below so a recoverable cache-400 isn't mistaken for a
# deterministic rejection.
if cache_active and e.code == 400:
logger.warning(f"Gemini cached call failed (400); retrying uncached. Reason: {str(e)}")
if self._cache_manager is not None and cached_prefix is not None:
@@ -598,8 +608,31 @@ class GeminiLLM(LLMInterface):
generation_config = _build_generation_config(cache_active)
continue
# Retry on retryable errors (rate limits, server errors, client errors)
if e.code in (400, 429, 500, 502, 503, 504) or (e.code and e.code >= 500):
# Diagnostic dump of the exact request behind any 4xx. Forced on for a
# non-recoverable 400 (see below) so its content-free structural profile
# is always in the log on first occurrence; other 4xx dump only under
# the opt-in HINDSIGHT_API_LLM_DEBUG_DUMP_4XX flag.
dump_request_on_4xx(
scope=scope,
provider=self.provider,
model=self.model,
err=e,
request=generation_config,
messages=gemini_contents,
force=e.code == 400,
)
# HTTP 400 INVALID_ARGUMENT is a deterministic client-side rejection
# (malformed schema, oversized prompt section, bad generation param).
# Now that the recoverable cache-400 is ruled out, retrying it — and
# the batch retry ladder above — just repeats an identical rejected
# call, so fail fast instead of burning the retry budget (#3256).
if e.code == 400:
logger.error(f"Gemini rejected request (HTTP 400 INVALID_ARGUMENT), not retrying: {str(e)}")
raise
# Retry on retryable errors (rate limits, server errors)
if e.code in (429, 500, 502, 503, 504) or (e.code and e.code >= 500):
last_exception = e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
@@ -633,6 +666,7 @@ class GeminiLLM(LLMInterface):
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
cached_prefix: str | None = None,
cached_prefix_message_count: int = 0,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> LLMToolCallResult:
"""
Make a Gemini/VertexAI API call with tool/function calling support.
@@ -768,20 +802,21 @@ class GeminiLLM(LLMInterface):
last_exception = None
for attempt in range(max_retries + 1):
set_stage(f"llm.gemini.tools.attempt={attempt + 1}/{max_retries + 1}")
try:
# With the cache active, send only the un-cached tail (delta);
# on the uncached fallback path send the full conversation so the
# re-inlined system+tools prefix has its whole context.
active_contents = delta_contents if cache_active else full_contents
response = await asyncio.wait_for(
self._client.aio.models.generate_content(
model=self.model,
contents=active_contents,
config=config,
),
timeout=90.0, # Safety net for network hangs; valid slow responses are <90s
)
async with attempt_context() if attempt_context is not None else nullcontext():
set_stage(f"llm.gemini.tools.attempt={attempt + 1}/{max_retries + 1}")
response = await asyncio.wait_for(
self._client.aio.models.generate_content(
model=self.model,
contents=active_contents,
config=config,
),
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
@@ -880,21 +915,12 @@ class GeminiLLM(LLMInterface):
logger.error(f"Gemini auth error (HTTP {e.code}), not retrying: {str(e)}")
raise
# Diagnostic dump (opt-in) of the exact request behind any 4xx, captured
# before the cache-drop retry below rebuilds the config so we see what failed.
dump_request_on_4xx(
scope=scope,
provider=self.provider,
model=self.model,
err=e,
request=config,
messages=active_contents,
)
# Cached-request safety net (see ``call``): a stale/invalid cache or
# a cache+tool_config conflict surfaces as a 400. Drop the cache,
# invalidate it for later operations, and retry THIS call inline
# with the prefix + tools re-sent. Caching must never break a call.
# Handled before the 400 fail-fast below so a recoverable cache-400
# isn't mistaken for a deterministic rejection.
if cache_active and e.code == 400:
logger.warning(f"Gemini cached tool call failed (400); retrying uncached. Reason: {str(e)}")
if self._cache_manager is not None and cached_prefix is not None:
@@ -903,6 +929,28 @@ class GeminiLLM(LLMInterface):
config = _build_tools_config(cache_active)
continue
# Diagnostic dump of the exact request behind any 4xx. Forced on for a
# non-recoverable 400 so its content-free structural profile is always
# in the log on first occurrence; other 4xx dump only under the opt-in
# HINDSIGHT_API_LLM_DEBUG_DUMP_4XX flag.
dump_request_on_4xx(
scope=scope,
provider=self.provider,
model=self.model,
err=e,
request=config,
messages=active_contents,
force=e.code == 400,
)
# HTTP 400 INVALID_ARGUMENT is a deterministic client-side rejection;
# now that the recoverable cache-400 is ruled out, retrying it — and
# the batch retry ladder above — just repeats an identical rejected
# call, so fail fast instead of burning the retry budget (#3256).
if e.code == 400:
logger.error(f"Gemini rejected tool request (HTTP 400 INVALID_ARGUMENT), not retrying: {str(e)}")
raise
# Retry on retryable errors
last_exception = e
if attempt < max_retries:
@@ -1302,3 +1350,6 @@ class GeminiLLM(LLMInterface):
"""Clean up resources (close connections, etc.)."""
# Gemini client doesn't require explicit cleanup
pass
def supports_attempt_scoped_concurrency(self) -> bool:
return True
@@ -17,7 +17,8 @@ import json
import logging
import os
import time
from typing import Any
from contextlib import AbstractAsyncContextManager, nullcontext
from typing import Any, Callable
from litellm.exceptions import Timeout as LiteLLMTimeout
@@ -39,6 +40,10 @@ from hindsight_api.worker.stage import set_stage
logger = logging.getLogger(__name__)
# Name of the single tool used when structured output is routed through a forced
# tool call instead of ``response_format`` (see ``structured_output_forced_tool``).
_STRUCTURED_TOOL_NAME = "structured_response"
def _usage_from_litellm_response(response: Any) -> LLMResponseUsage:
"""Extract prompt/completion/cached token counts from a LiteLLM (OpenAI-shaped) usage block."""
@@ -56,6 +61,22 @@ def _usage_from_litellm_response(response: Any) -> LLMResponseUsage:
)
def _forced_tool_arguments(message: Any) -> str | None:
"""Return the structured-output tool call's arguments as a JSON string.
``None`` when the model answered with plain text instead some gateways drop
``tool_choice`` so the caller can fall back to parsing the message content.
"""
for tool_call in message.tool_calls or []:
if tool_call.function.name != _STRUCTURED_TOOL_NAME:
continue
# LiteLLM normalizes to the OpenAI shape (a JSON string), but some
# providers hand back an already-decoded object.
arguments = tool_call.function.arguments
return arguments if isinstance(arguments, str) else json.dumps(arguments)
return None
class LiteLLMLLM(LLMInterface):
"""
LLM provider using the LiteLLM SDK for universal model support.
@@ -76,11 +97,12 @@ class LiteLLMLLM(LLMInterface):
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
reasoning_effort: str | None = None,
timeout: float | None = None,
extra_body: dict[str, Any] | None = None,
bedrock_service_tier: str | None = None,
default_headers: dict[str, Any] | None = None,
structured_output_forced_tool: bool = False,
**kwargs: Any,
):
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
@@ -102,6 +124,12 @@ class LiteLLMLLM(LLMInterface):
# 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
# Ask for structured output via a single forced tool call instead of
# ``response_format``. Opt-in (HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL)
# for backends that reject the response_format route — Bedrock Claude's
# Converse layer refuses the translated ``outputConfig`` in some regions but
# accepts the same schema as a tool (#3300).
self.structured_output_forced_tool = structured_output_forced_tool
try:
import litellm
@@ -157,6 +185,14 @@ class LiteLLMLLM(LLMInterface):
kwargs["max_completion_tokens"] = self._cap_max_completion_tokens(max_completion_tokens)
if temperature is not None:
kwargs["temperature"] = temperature
# LiteLLM translates reasoning_effort per target provider (Anthropic thinking
# budgets, Gemini thinking config, OpenAI's flat param), so forwarding the
# operator's setting is all that is needed to honour it here — dropping it was
# a silent no-op on every model behind this lane (issue #3449). Only sent when
# configured; ``litellm.drop_params = True`` discards it for models that have
# no reasoning knob rather than raising.
if self.reasoning_effort is not None:
kwargs["reasoning_effort"] = self.reasoning_effort
# User-configured extras fill in only where the caller didn't set a value,
# so explicit per-call params (model, messages, temperature, …) always win.
@@ -235,41 +271,74 @@ class LiteLLMLLM(LLMInterface):
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> Any:
start_time = time.time()
call_kwargs = self._build_common_kwargs(messages, max_completion_tokens, temperature)
# Add JSON schema response format if provided
use_forced_tool = False
if response_format is not None and hasattr(response_format, "model_json_schema"):
schema = strict_json_schema(response_format) if strict_schema else response_format.model_json_schema()
call_kwargs["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": response_format.__name__ if hasattr(response_format, "__name__") else "response",
"schema": schema,
"strict": strict_schema,
},
}
schema_name = response_format.__name__ if hasattr(response_format, "__name__") else "response"
if self.structured_output_forced_tool:
# The schema travels as the tool's parameters and the model is forced
# to call it; the arguments are substituted for the message content
# below, so the parse/validate, retry and usage paths are unchanged.
use_forced_tool = True
call_kwargs["tools"] = [
{
"type": "function",
"function": {
"name": _STRUCTURED_TOOL_NAME,
"description": f"Return the structured response ({schema_name}).",
"parameters": schema,
},
}
]
call_kwargs["tool_choice"] = {
"type": "function",
"function": {"name": _STRUCTURED_TOOL_NAME},
}
else:
call_kwargs["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": schema_name,
"schema": schema,
"strict": strict_schema,
},
}
last_exception = None
for attempt in range(max_retries + 1):
set_stage(f"llm.{self._stage_label}.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await asyncio.wait_for(
self._acompletion(**call_kwargs),
timeout=self.timeout,
)
async with attempt_context() if attempt_context is not None else nullcontext():
set_stage(f"llm.{self._stage_label}.{scope}.attempt={attempt + 1}/{max_retries + 1}")
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 ""
message = response.choices[0].message
content = message.content or ""
finish_reason = response.choices[0].finish_reason
model_name = self._resolve_completion_model(response)
if use_forced_tool:
# Forced tool call: its arguments ARE the structured response.
# Absent (a gateway that drops tool_choice) -> keep the text
# content so the existing parse path still has a chance.
forced_arguments = _forced_tool_arguments(message)
if forced_arguments is not None:
content = forced_arguments
# Check for length-limited output
if finish_reason == "length":
raise OutputTooLongError("LiteLLM response was truncated due to token limit")
@@ -430,6 +499,7 @@ class LiteLLMLLM(LLMInterface):
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> LLMToolCallResult:
start_time = time.time()
@@ -446,12 +516,13 @@ class LiteLLMLLM(LLMInterface):
last_exception = None
for attempt in range(max_retries + 1):
set_stage(f"llm.{self._stage_label}.tools.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await asyncio.wait_for(
self._acompletion(**call_kwargs),
timeout=self.timeout,
)
async with attempt_context() if attempt_context is not None else nullcontext():
set_stage(f"llm.{self._stage_label}.tools.attempt={attempt + 1}/{max_retries + 1}")
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
@@ -574,3 +645,6 @@ class LiteLLMLLM(LLMInterface):
async def cleanup(self) -> None:
"""Clean up resources."""
pass
def supports_attempt_scoped_concurrency(self) -> bool:
return True
@@ -66,7 +66,7 @@ class LiteLLMRouterLLM(LiteLLMLLM):
base_url: str,
model: str,
config: dict[str, Any],
reasoning_effort: str = "low",
reasoning_effort: str | None = None,
timeout: float | None = None,
**kwargs: Any,
):
@@ -146,6 +146,13 @@ class LiteLLMRouterLLM(LiteLLMLLM):
kwargs["max_completion_tokens"] = self._cap_max_completion_tokens(max_completion_tokens)
if temperature is not None:
kwargs["temperature"] = temperature
# Like api_key/base_url, per-deployment reasoning could live in the Router config,
# but the operator-level setting is cross-cutting and LiteLLM translates it per
# target provider — so this override forwards it exactly as the base provider does.
# Omitting it made HINDSIGHT_API_*_REASONING_EFFORT a no-op on the router lane
# alone (issue #3449); only sent when configured.
if self.reasoning_effort is not None:
kwargs["reasoning_effort"] = self.reasoning_effort
# Forward operator-configured default headers as ``extra_headers`` so they
# reach the provider behind the Router (proxies / request-tracing middleware).
@@ -19,8 +19,9 @@ import socket
import subprocess
import sys
import time
from contextlib import AbstractAsyncContextManager
from pathlib import Path
from typing import Any
from typing import Any, Callable
from hindsight_api.engine.llm_interface import LLM_TOOL_CHOICE_AUTO, LLMInterface, LLMToolChoice
from hindsight_api.engine.response_models import LLMToolCallResult
@@ -272,7 +273,8 @@ class LlamaCppLLM(LLMInterface):
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
reasoning_effort: str | None = None,
extra_body: dict[str, Any] | None = None,
model_path: str | None = None,
gpu_layers: int = -1,
context_size: int = 8192,
@@ -288,6 +290,7 @@ class LlamaCppLLM(LLMInterface):
model=model or DEFAULT_LLAMACPP_MODEL_ALIAS,
reasoning_effort=reasoning_effort,
)
self._extra_body = extra_body
self._model_path_str = model_path
self._gpu_layers = gpu_layers
self._context_size = context_size
@@ -335,7 +338,10 @@ class LlamaCppLLM(LLMInterface):
api_key="llamacpp",
base_url=self._server.base_url,
model=self.model,
# None (unconfigured) must stay None so the delegate omits the parameter
# rather than inventing a level for the local model.
reasoning_effort=self.reasoning_effort,
extra_body=self._extra_body,
)
self._initialized = True
@@ -367,6 +373,7 @@ class LlamaCppLLM(LLMInterface):
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> Any:
"""Delegate call to the OpenAI-compatible API."""
await self._ensure_initialized()
@@ -382,6 +389,7 @@ class LlamaCppLLM(LLMInterface):
skip_validation=skip_validation,
strict_schema=strict_schema,
return_usage=return_usage,
attempt_context=attempt_context,
)
async def call_with_tools(
@@ -395,6 +403,7 @@ class LlamaCppLLM(LLMInterface):
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> LLMToolCallResult:
"""Delegate tool calls to the OpenAI-compatible API."""
await self._ensure_initialized()
@@ -408,8 +417,12 @@ class LlamaCppLLM(LLMInterface):
initial_backoff=initial_backoff,
max_backoff=max_backoff,
tool_choice=tool_choice,
attempt_context=attempt_context,
)
def supports_attempt_scoped_concurrency(self) -> bool:
return True
async def cleanup(self) -> None:
"""Stop the shared llama.cpp server."""
global _shared_server
@@ -135,25 +135,41 @@ def dump_request_on_4xx(
err: Any,
request: Any = None,
messages: Any = None,
force: bool = False,
) -> None:
"""Log the exact request behind an LLM 4xx when the diagnostic is enabled.
No-op unless ``HINDSIGHT_API_LLM_DEBUG_DUMP_4XX`` is truthy and ``err`` carries a
4xx status. ``request`` is whatever the provider assembled (a Pydantic config, a
kwargs dict, ...); ``messages`` overrides where the per-message previews come from
No-op unless ``err`` carries a 4xx status AND either the
``HINDSIGHT_API_LLM_DEBUG_DUMP_4XX`` flag is truthy or ``force`` is set.
``request`` is whatever the provider assembled (a Pydantic config, a kwargs
dict, ...); ``messages`` overrides where the per-message previews come from
(defaults to the message list found inside ``request``).
``force`` is for deterministic rejections a retry can't fix (e.g. a 400
``INVALID_ARGUMENT``): the structural profile request config, per-message
sizes is logged on the first (and only) failure so an otherwise-opaque
black box is diagnosable in production without flipping a flag and
reproducing (#3256). Message *previews* stay gated behind the opt-in flag, so
the forced structural dump never spills user content only per-part sizes.
"""
if not _enabled():
enabled = _enabled()
if not (enabled or force):
return
code = status_code_of(err)
if code is None or not (400 <= code < 500):
return
# Previews carry user content, so they ride only on the explicit opt-in; a
# forced dump logs the structural profile (config + per-part sizes) alone.
include_previews = enabled
try:
cfg_repr = _serialize_config(request)
summary = []
for msg in _resolve_messages(request, messages) or []:
m = _message_preview(msg)
summary.append({"role": m.role, "chars": len(m.text), "preview": m.text[:_PREVIEW_CHARS]})
entry: dict[str, Any] = {"role": m.role, "chars": len(m.text)}
if include_previews:
entry["preview"] = m.text[:_PREVIEW_CHARS]
summary.append(entry)
logger.error(
"[LLM_4XX_DUMP] provider=%s model=%s scope=%s code=%s err=%s config=%s contents=%s",
provider,
@@ -7,6 +7,7 @@ without making actual API calls to external LLM services.
import logging
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager
from typing import Any
from ..llm_interface import LLM_TOOL_CHOICE_AUTO, LLMInterface, LLMToolChoice, LLMToolChoiceMode
@@ -47,7 +48,7 @@ class MockLLM(LLMInterface):
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
reasoning_effort: str | None = None,
**kwargs: Any,
):
"""
@@ -91,6 +92,7 @@ class MockLLM(LLMInterface):
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> Any:
"""
Make a mock LLM API call.
@@ -201,6 +203,7 @@ class MockLLM(LLMInterface):
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> LLMToolCallResult:
"""
Make a mock LLM API call with tool/function calling support.

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