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.
* 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.
* 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.
* 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.
* 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.
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.
`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.
* 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]>
`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.
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.
`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.
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.
`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.
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.
* 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.
* 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).
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.
* 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
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.
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).
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.
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.
* 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
`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.
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.
* 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'
* 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]>
* 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.
* 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.
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.
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.
- 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)
* 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]>
* 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.
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.
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.
* 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.
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.
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.
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.
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.
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
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.
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
* 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.
* 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]>
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.
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.
* 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).
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]>
* 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).
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).
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.
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.
* 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]>
* 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.
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.
* 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]>
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.
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.
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().
* 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.
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.
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.
`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.
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.
* 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.
* 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]>
* 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]>
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.
* 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]>
`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.
## 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.
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).
* 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.
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
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.
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.
* 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.
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.
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.
* 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]>
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.
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.
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.
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).
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.
* 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.
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.
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.
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).
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
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.
* 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.
* 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.
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.
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.
* 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]>
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.
* 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]>
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.
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.
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.
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
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.
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.
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
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.
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.
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.
* 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
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.
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.
* 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]>
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.
* 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]>
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.
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: ".
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.
* 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).
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
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.
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.
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>`.
* 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
* 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.
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.
- 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
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.
* 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.
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.
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.
`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.
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
`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.
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
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).
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.
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.
* 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.
* 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.
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.
* 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).
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).
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
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.
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.
* 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]>
* 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]>
* 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.
* 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.
* 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]>
* 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]>
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.
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.
* 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
* 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]>
* 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).
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.
* 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.
#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.
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
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.
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
* 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.
* 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.
* 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.
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.
* 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.
`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.
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.
`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.
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.
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.
* 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.
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.
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).
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.
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]>
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]>
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).
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.
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.
* 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
* 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]>
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]>
* 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]>
`/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
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.
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
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
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
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
* 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.
#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.
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]>
* 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]>
* 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.
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.
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
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.
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.
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.
* 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.
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.
* 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
* 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]>
* 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
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.
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.
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).
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.
* 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.
* 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.
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.
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.
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.
* 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]>
* 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]>
* 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
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
* 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
- **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:
(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) |
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
# 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)
@@ -298,7 +298,7 @@ client.reflect(bank_id="my-bank", query="What should I know about Alice?")
---
## Star History
[](https://www.star-history.com/#vectorize-io/hindsight&type=date&legend=top-left)
- 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}
- 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}
"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.",
"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.
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.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.