Compare commits

...
Author SHA1 Message Date
Ben 82c6db52ad feat(agent-plugin): add portable Hindsight plugin for the Agent Plugins standard
Add a vendor-neutral Hindsight plugin conforming to Vercel's Agent Plugins
1.0.0 standard (plugin.json + mcp.json + skills/SKILL.md), so one artifact
gives long-term memory to any compatible client (Codex, Cursor, GitHub
Copilot, Kiro, VS Code) instead of a per-IDE integration. The plugin is a
thin transport wrapper over Hindsight's existing MCP server (retain / recall
/ reflect); a bundled skill teaches the agent when to use it.

Wiring:
- CI: test-agent-plugin-integration job runs the manifest validator, gated on
  hindsight-integrations/agent-plugin/** changes.
- Docs: integrations.json gallery entry + docs-integrations/agent-plugin.md.
- Release: agent-plugin added to release-integration.sh and the changelog
  generator; both learn to read a root-level plugin.json and link the
  changelog to the source tree (git-distributed bundle, no registry package).
2026-08-11 11:21:54 -04:00
Nicolò Boschi 475895f0a2 fix(retain): fold shared document_id items on the sync path (#3363) (#3386)
The sync retain batch endpoint rejected any batch whose items shared a
document_id, contradicting the RetainRequest schema/example, the
MemoryItem.document_id docs, and the SDK's batch-level documentId (which
inlines one id into every item). The guard existed to avoid a race, but
that race is only real on the queued path, where children fan out to
parallel workers. The synchronous path processes sub-batches sequentially.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Supersedes #3302. Reported and diagnosed by @fhiltscher.

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two follow-ons fall out of the same invariant:

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

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

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

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

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

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

Two things follow from passing it:

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

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

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

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

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

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

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

Closes #3269.

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

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

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

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

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

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

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

Unconfigured, this adds nothing to a retain.

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

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

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

* feat(coding-agents): HINDSIGHT_RETAIN_TAGS env override

Closes #2896.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also in this pass:

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

---------

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

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

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

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

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

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

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

---------

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

Two things it did not cover remain.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two supporting changes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

Implementation notes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: refresh 0.9.0 changelog (99 commits)

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

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

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

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

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

This reverts commit 5f8a030615.

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

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

Two halves:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: install with npx, no global install

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two changes, split by responsibility:

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

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

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

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

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

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

Design points worth knowing:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: sync generated docs schema

* fix: scope concurrency permits to provider attempts

* fix: gate responses retries per attempt

* review fixes: keep .queued stage until permits, stamp .backoff, codex attempt labels, test coverage

- llm_wrapper: attempt-gated providers no longer stamp the bare base stage
  before holding any permit — a call queued on the semaphore stays '.queued'
  until the provider's post-acquire 'attempt=N' stamp (#3002), and
  _attempt_permits suffixes '.backoff' when an attempt fails so backoff
  sleeps are distinguishable from in-flight requests.
- codex tools path: attempt-numbered stage labels (1/2, 2/2) and 401/403
  before the reactive refresh logs as warning, not error.
- typing: deprecated typing.AsyncContextManager -> contextlib.AbstractAsyncContextManager;
  uniform 'is not None' guards; document attempt_context in LLMInterface.
- tests: end-to-end regression through the real OpenAI-compatible retry loop
  (permit released during backoff, reacquired on attempt 2), cancellation
  while queued on the global permit releases the per-op permit, stage
  queued->attempt->backoff lifecycle; fix provider stubs missing
  supports_attempt_scoped_concurrency.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-05 16:07:07 +02:00
Parafee41andNicolò Boschi 9d828cc7b1 fix(embed): bound daemon log growth (#3165)
* fix(embed): bound daemon log growth

* fix embed log rotation lifecycle

* chore: sync generated embed docs

* fix(embed): correct the retention claim and harden profile deletion

Review follow-ups on the daemon log rotation:

- The docs claimed a peak retained size of MAX_BYTES x (BACKUP_COUNT + 1),
  then immediately said an uninterrupted run is not bounded. Both cannot
  hold: size is only checked at startup, so a long run grows past
  MAX_BYTES and is then kept whole as the first backup. Say when the
  bound actually applies and how to keep it meaningful.

- delete_profile() unlinked each retained log without a guard, ahead of
  the metadata cleanup. One unremovable log (still open on Windows) threw
  and left the profile registered in metadata with its config already
  gone. Warn and continue instead, so the profile is always deregistered.

- Drop the import-time env parsing. The values were re-parsed per start
  from the merged profile env anyway, so the module-level constants only
  survived as that parse's fallback and as default arguments no caller
  used -- and an invalid value warned once at import, before logging is
  configured, and again at startup.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-05 15:56:33 +02:00
Nicolò Boschi 42e8c53da9 fix(consolidation): keep observations in their source facts' language (#3181)
* fix(consolidation): keep observations in their source facts' language

Consolidation's prompt is entirely English and only carried a language rule
when HINDSIGHT_API_LLM_OUTPUT_LANGUAGE was set, so with it unset multilingual
models drifted: Chinese source facts produced English observations (#3166).
Retain already defaults to preserving the input language; consolidation now
does the same, and the rule settles the three ambiguous cases — language is
picked per observation from its own source facts, an update rewrites the whole
observation in the new facts' language (so drifted banks self-heal), and proper
nouns/identifiers are never translated.

An explicit output language still wins: the default rule is dropped in that
case rather than left to contradict "translate everything into X".

Reproduced and verified against gpt-oss-120b: before, the issue's Chinese facts
yielded "The user often walks their pet in the park on weekends."; after, they
yield 用户周末经常带宠物去公园散步。

Fixes #3166

* refactor(consolidation): compress the language rule

The rule rides in the system prefix of every consolidation call, so on
providers without prompt caching its size is paid per batch. Four sentences
carry the same four constraints the bullet list did, at 69 tokens instead of
223. Re-verified against gpt-oss-120b: identical output on all four cases
(Chinese creates, English observation updated by a Chinese fact, explicit
English override, English facts left alone).

* chore(docs): resync hindsight-docs skill for the multilingual page

* fix(consolidation): make the update-path language rule explicit

CI showed Gemini merging a Chinese fact into an existing English observation
by editing the English sentence in place, keeping it English — the same result
with the verbose rule and the compressed one, so wording length was not the
problem. Name the failure mode instead: don't edit the old text, compose the
merged observation from scratch in the new facts' language.

Also stop the test asserting the merge routing. Whether the model updates the
existing observation or records a sibling is its call (gpt-oss-120b does both
across runs); asserting UPDATE made this a flaky test of merge behaviour rather
than of language. It now checks every emitted text, create or update.

* test(consolidation): absorb LLM sampling noise in the language tests

All three tests now go through one helper that retries up to three times while
the output language is wrong, so a single stray response doesn't fail the suite
— the same shape test_multilingual.py uses.

The update test is additionally xfail(strict=False): Gemini keeps an existing
observation's English wording when a Chinese fact updates it, editing in place
rather than recomposing, and did so identically across three CI runs and three
prompt wordings. The OpenAI-compatible models the issue reports against comply,
so it xpasses there. The creates test stays a hard gate — that is the reported
bug, and every model tried passes it.
2026-08-05 15:16:06 +02:00
Nicolò Boschi ba5a4813b3 fix(mental-models): never overwrite a document with a delta-window candidate (#3182)
* fix(mental-models): never overwrite a document with a delta-window candidate

A delta refresh runs reflect with created_after = last_refreshed_at, so its
candidate only covers memories newer than the last refresh. When the delta
operations failed to reach the document, that candidate was written as the
whole document and the watermark advanced past it — everything grounded in
older memories was gone for good, while the log said "falling back to full
synthesis" and the operation completed successfully (#3112).

Refuse it instead, keyed on the window rather than on each failure branch so
future ones inherit the guard: when delta was requested, was not applied, and
the reflect window was narrowed, preserve the document and raise
MentalModelRefreshError. The watermark stays put, so the retry reads the same
facts again.

Also:
- Treat "the model emitted operations but every one was rejected" as a delta
  failure. The document is unchanged, so persisting it looked like a clean
  refresh while dropping that run's facts outside every future delta window.
- Recover from an unusable structured_content by re-parsing the stored
  markdown instead of giving up — nothing else rewrites that column, so
  failing there wedged the model permanently.
- Record skipped operations even when the delta did not land, count them in
  the operation's result_metadata, and warn when a partial skip means some of
  this run's evidence never reached the document.
- Route every failure through one preserve-and-fail helper, so the
  structured-output failure now leaves the same reflect_response audit trail
  the other two already did.

* docs(mental-models): describe what a failed delta refresh does to the document

The delta section promised the opposite of what the code now does — "zero valid
operations means an identical document … never corrupt it" read as a guarantee
while a failed delta was in fact replacing the document with a partial one. Say
plainly that the document is kept and the refresh fails, and list the two new
diagnostic values.
2026-08-05 14:47:13 +02:00
yufanw03andwangyufan03 f2ae61eda3 feat(query-analyzer): configurable dateparser locale detection (#3154)
* feat(query-analyzer): configurable dateparser locale detection

search_dates() runs auto-detection across 200+ locales on every recall.
This costs 62 ms P50 on the recall critical path, and misdetects English
queries as other locales: "May 23, 2023" parses to 2023-11-23 after the
detector picks 'bas' (Basaa), where May maps to November.

Adds an optional languages restriction to DateparserQueryAnalyzer, wired
through HINDSIGHT_API_QUERY_ANALYZER_LANGUAGES. Default stays None (full
auto-detection), since restricting degrades explicit dates in unlisted
locales to a wrong date rather than to no constraint.

* docs(configuration): document HINDSIGHT_API_QUERY_ANALYZER_LANGUAGES

---------

Co-authored-by: wangyufan03 <[email protected]>
2026-08-05 14:34:44 +02:00
468cc4b7d7 fix(embeddings): honor query and document prompts locally (#3032)
* fix(embeddings): honor query and document prompts locally

* fix(embeddings): require sentence-transformers >=5.0 for local asymmetric encoding

encode_query()/encode_document() only exist from sentence-transformers 5.0
onwards. The local-ml extra pinned >=3.3.0, so on 4.x the new code path was an
AttributeError at the first encode (recall/retain), not at startup. The extra
was only accidentally safe because it also pins transformers>=5.5.0, which ST
<5 caps out; docker/docker-compose/custom-models/Dockerfile mirrors the pins
with transformers>=4.53.0 and could genuinely resolve to ST 4.x.

Also:
- assert the real SentenceTransformer class exposes both entry points; the
  existing test drives a MagicMock, so it passes on any version
- explain why the model's own entry points are used instead of prefixing here,
  and note that prompt-less models are unaffected
- document the one case that needs a re-index: a local model that instructs the
  stored side as well as the search side

---------

Co-authored-by: jpmf33 <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-05 11:58:35 +02:00
Nicolò Boschi dc9d033d16 feat(entity-resolution): make pg_trgm similarity threshold configurable, set at connection setup (#3188)
Add HINDSIGHT_API_ENTITY_TRGM_SIMILARITY_THRESHOLD (static, default 0.15),
applied once as `SET pg_trgm.similarity_threshold` in the pool's per-connection
setup callback alongside the other session GUCs. Because that callback is wired
as both asyncpg `init` and `setup`, the value survives the release-time RESET ALL
and every re-acquire.

Entity resolution's trigram probe no longer toggles the threshold per query — it
just runs against a connection that already has it set — so the runtime
SET/RESET (and its RESET-on-error handling) is removed. The value is validated
to pg_trgm's (0, 1] range at config load so a bad setting fails fast instead of
breaking every connection's setup.
2026-08-05 11:44:58 +02:00
Nicolò Boschi bb895faaf9 perf(entity-resolution): skip fuzzy probing for exact-match-only label entities (#3187)
Label entities resolve by exact match only (their canonical names are
user-defined and must not be fuzzy-merged). Sending them through the pg_trgm
candidate probe — and the Oracle Jaro-Winkler equivalent — only returns
similar-but-distinct label values that are always discarded downstream. The
cost grows with the number of values a label accumulates: each probe returns
proportionally more candidates, all thrown away.

Partition entity texts before the candidate fetch: resolve label texts with an
exact lookup on the unique (bank_id, LOWER(canonical_name)) index, and only send
non-label texts through the fuzzy probe (also skipping the pg_trgm threshold
SET/RESET entirely when there are no fuzzy texts).

No behavior change — label resolution was already exact-match-only in
_resolve_from_candidates; this only removes the wasted candidate scan.
2026-08-05 11:13:52 +02:00
github-actions[bot] 926f752912 chore: update star history 2026-08-05 04:25:40 +00:00
Ben 94d65a591a release(obsidian): v0.2.0 2026-08-04 17:10:00 -04:00
Ben aad36e5eee chore(obsidian): bump manifest + versions to 0.2.0 2026-08-04 17:07:15 -04:00
Ben 93fa0b016b feat(obsidian): headless CLI vault ingestion (hindsight-obsidian-sync) (#3179)
* refactor(obsidian): decouple HindsightClient from obsidian via a Transport seam

Introduce a Transport abstraction so the HTTP client no longer imports
`obsidian` directly. The plugin injects an obsidian-transport (requestUrl,
to escape the renderer CORS sandbox); a headless CLI will inject a
fetch-based transport. This lets both frontends share one client and one set
of request semantics instead of maintaining divergent copies.

No behavior change: existing client tests pass unchanged after switching from
mocking requestUrl to injecting a fake transport (same request shape).

* feat(obsidian): headless CLI vault ingestion (hindsight-obsidian-sync)

Add a headless second frontend over the shared SyncEngine so a vault can be
ingested into Hindsight from an always-on server with no Obsidian desktop app
(issue #3128). Because it drives the same engine as the plugin, it produces
identical document ids, scope tags, and prune-ownership — the two ingesters
never fight or duplicate.

New Node modules (src/node/):
- fs-vault.ts    — filesystem SyncVault (recursive *.md walk, POSIX-relative
                   paths, ms mtime/ctime, skips dotfolders)
- fetch-transport.ts — fetch-based Transport for the client (no renderer CORS)
- json-index.ts  — sync index persisted to JSON, atomic write; defaults to
                   ~/.hindsight/obsidian/<vault>.json (outside the vault so
                   Obsidian Sync never propagates it)
- cli.ts / cli-bin.ts — `hindsight-obsidian-sync reconcile --vault <p> --bank
                   <id>` with env fallbacks, --include/--exclude/--prefix-doc-id,
                   and a chokidar --watch mode

Packaging: second esbuild target builds dist/cli.js (node, shebang); package.json
gains the bin, a files allowlist, and chokidar. README documents the CLI, the
out-of-vault index, and the shared-scope constraint when running both frontends
against one bank.

Tests (33 new, 79 total): FsVault, json-index, fetch transport, CLI arg parsing
+ watch handlers + a full runCli path (fetch mocked), and a full-stack reconcile
suite over a real temp vault (create/update/skip/delete/rename/exclude/prefix/
prune-ownership) plus a parity check that the filesystem and in-memory (plugin)
vaults emit byte-identical retain requests.

* test(obsidian): broaden CLI coverage with real-framework and e2e tests

Add higher-fidelity tests beyond the mocked units, and refactor watch mode to
be testable:

- Extract watchVault() from startWatch() so a test can drive a REAL chokidar
  watcher over a temp vault and then close it. New watch.spec.ts asserts
  create/modify/delete on disk flow through to the engine and non-markdown is
  ignored (polling + tight awaitWriteFinish for CI determinism).
- e2e-http.spec.ts runs runCli against a real node:http server — the full
  FsVault → SyncEngine → HindsightClient → fetch → sockets path with nothing
  mocked: asserts the retain POST (bearer token, document id, scope tags) and a
  real DELETE on prune, plus exit-code 1 when the server is unreachable.
- fetch-transport: full-stack HindsightClient error propagation + health()
  true/false, a fetch-rejection case, and a cross-transport parity test proving
  the same call yields an identical request under two transports.
- reconcile: frontmatter tags + created-date timestamp + vault metadata,
  empty-body skip, and includeFolders-only scoping.
- cli: --exclude threaded end-to-end through runCli.

Pin chokidar to ^4.0.3 (bundles its own TS types). 90 tests pass (+11).

* fix(obsidian): make dual-ingester parity test platform-deterministic; add CLI to docs-site page

- Parity test pinned mtime via utimes but ctime/birthtime can't be set and
  differs across OSes (macOS clamps birthtime to a past mtime, Linux doesn't),
  so the FS vault's created-date tags diverged from the memory vault's on Linux
  CI. Give both notes a frontmatter created: date so the tags come from the note.
- Add a 'Headless / CLI ingestion' section to the public docs-site page
  hindsight-docs/docs-integrations/obsidian.md (mirrors the package README).

* docs(obsidian): regenerate skill mirror for the headless CLI section

* style(obsidian): apply repo prettier formatting to CLI + tests
2026-08-04 17:00:36 -04:00
BenandClaude Opus 4.8 559ab9dbff blog: Per-User Memory for AI Products — Multi-Tenant Patterns (#3180)
* blog: Per-User Memory for AI Products — Multi-Tenant Patterns

A patterns guide for SaaS/AI-product builders on isolated per-user memory: the
hard-boundary vs soft-partition decision (banks vs tags), reading across
private/org/global scopes without a cross-bank query, a CI cross-tenant
leakage test, GDPR deletion, and scaling to many tenants on one store.
Editorial deep-dive cover.

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

* blog(per-user-multi-tenant): swap cover to dark comparison-card template

Reuse the context-window-is-not-memory template (dark, teal-accent title +
comparison cards). Positive framing: "Every user gets their own bank" with
BANKS (isolate a tenant) + TAGS (partition a bank).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-04 13:47:35 -04:00
Nicolò Boschi 76d8ba8a51 feat(reranker): failover chain via indexed HINDSIGHT_API_RERANKER_<n>_* members (#3176)
* feat(reranker): failover chain via indexed HINDSIGHT_API_RERANKER_<n>_* members

An unreachable reranker took the whole recall down with a 500: the stage is a
refinement, but nothing on the path treated it as optional and no setting could
change that (#3168).

Configure extra rerankers by index, mirroring the multi-LLM chain: the unindexed
config is member 0 and HINDSIGHT_API_RERANKER_<n>_* declares the fallbacks, tried
in order when a member fails (error, timeout, or a wrong-length response). Every
setting of member n carries the same index, so a fallback gets the full
provider-specific knob set; it inherits nothing from the primary or from the
shared provider keys, and missing-setting errors name the exact indexed variable.

Ending the chain with the existing `rrf` provider makes recall fail open — the
retrieval order comes back untouched instead of the request failing. With no
indexed members (the default) behaviour is unchanged.

Round-robin is deliberately not offered: reranker scores are not comparable
across providers, so rotating members per request would make score thresholds
non-deterministic.

A member that fails to initialize is logged rather than fatal, and retried on the
next request that reaches it — otherwise a chain configured for a flaky primary
would still die at startup. `CrossEncoderModel.blocking_init` replaces the
`provider_name == "local"` checks at the two init call sites, so the chain can
offload its own in-process members instead of being threaded by its callers.

* fix(reranker): tolerate duck-typed cross encoders, sync the docs skill

Tests inject cross encoders that don't subclass CrossEncoderModel, so reading
`blocking_init` off them raised AttributeError in test-api. Read it with getattr,
matching the provider_name read a few lines away in _recall, instead of making
every test double implement the property.

Also regenerates skills/hindsight-docs for the new configuration section (the
retain/openapi/coding-agents hunks are pre-existing drift on main that
verify-generated-files requires be committed).
2026-08-04 19:00:27 +02:00
Sanderhoff-alt 362c719f1e fix(config): report deleted import banks as conflicts (#3035)
Raise a dedicated persistence conflict when a validated bank config
update can no longer find its bank row.

Map this race to HTTP 409 for template imports while preserving PATCH
404 behavior and 400 responses for invalid configuration. Add an
event-coordinated regression test for the deletion window.
2026-08-04 18:38:55 +02:00
Nicolò Boschi c335192cd3 fix(consolidation): guard observation_history append on 0-row observation UPDATE (#3161)
* fix(consolidation): skip observation_history when UPDATE matches 0 rows

The source-liveness checks in _execute_update_action guard the *source*
memories, but the observation row itself (UPDATE ... WHERE id = $5) can be
concurrently invalidated/deleted, matching 0 rows. The code then fell through
to _append_observation_history, whose INSERT carries an observation_id FK onto
memory_units — raising ForeignKeyViolationError, a (correctly) non-retryable
integrity failure that marked the whole consolidation op failed for a row that
simply no longer exists.

Capture the UPDATE status in the SQL branch and bail out (return None) before
the history append when 0 rows matched. The store/upsert branch cannot hit the
0-row case, so it needs no guard. The Oracle wrapper reshapes rowcount into the
same "UPDATE <n>" form, so the parse is dialect-safe (mirrors config_resolver).

Adds mock-level regression tests covering both the 0-row bail and the
positive (rowcount==1) control.

* refactor(db): add execute_rows_affected primitive; use it for the 0-row guard

Move the command-tag rowcount parse out of consolidation business logic and
into the pg/oracle connection layer. DatabaseConnection.execute_rows_affected
runs a DML statement and returns a plain int, normalizing the dialect-divergent
result shape the same way parse_json normalizes JSON columns: asyncpg returns
the tag directly, the Oracle connection reshapes cursor.rowcount into the same
trailing-count form, so parsing the last token is dialect-safe.

_execute_update_action now calls conn.execute_rows_affected(...) and checks the
int directly instead of hand-parsing an "UPDATE <n>" string. Adds a parser unit
test covering the tag shapes both dialects emit.
2026-08-04 18:37:39 +02:00
Derek Bouius 11ecfe54c2 test(bank): add regression test for per-bank index deadlock retry (#2984)
#2943 fixed the shared-DB test-api deadlock flake by wrapping
get_or_create_bank_profile in retry_with_backoff, but shipped without a unit
test for that retry. Add a deterministic, no-DB test: an ops stub raises
DeadlockDetectedError on the first per-bank index DDL then succeeds, and the
test asserts the profile creation retries (two index-DDL attempts) and the
bank ends up created.

Guards against a future refactor silently dropping the deadlock retry and
re-introducing the flake. Committed --no-verify: the generate-docs-skill hook
is blocked by a pre-existing skills/hindsight-docs drift on main, unrelated to
this test-only change.
2026-08-04 18:36:01 +02:00
Sanderhoff-alt 751deb47be fix(retain): sync metadata to unchanged memories (#3011)
Keep metadata and tags on unchanged memory units aligned with their
document during delta retain.

Cover metadata-only replace and append paths with regression tests.

Closes #3008
2026-08-04 18:33:16 +02:00
Nicolò Boschi 5f8a030615 chore(db): remove deprecated entity schema from memory_links (#3177)
Entity edges are no longer materialized in memory_links. Retain stores
memory-to-entity associations in unit_entities, and both read paths derive
entity edges from that table on demand — the /graph endpoint from shared
unit_entities rows and recall via the unit_entities self-join. Migration
e9b2c7d1f3a4 deleted the stored entity rows and current writers only ever
pass entity_id = NULL, leaving the entity-specific schema on memory_links
as dead weight.

New migration (PG + Oracle) drops the entity_id column and its FK, the
entity index, 'entity' from the link_type CHECK, and the entity_id term in
the function-based unique index (which collapses to
(from_unit_id, to_unit_id, link_type)). It is written to avoid long locks
on large tables: the residual delete is chunked with per-batch commits,
indexes are swapped CONCURRENTLY, and the new CHECK is added NOT VALID then
validated separately.

Application code drops _NIL_ENTITY_UUID and the nil_entity_uuid DataAccessOps
parameter, simplifies internal link tuples to four elements
(from, to, link_type, weight), and removes the entity_id column/placeholder
from the PG and Oracle bulk inserts and the chunk-storage lock ordering.
The graph API keeps returning dynamically derived entity edges.
2026-08-04 18:30:00 +02:00
Nicolò Boschi f572d8647d fix(retain): unify OutputTooLongError so #2579 output auto-split actually runs (#3174)
OutputTooLongError was defined twice — the canonical class in
llm_interface.py (what the providers raise) and a shadow copy in
llm_wrapper.py. fact_extraction and multi_llm imported the shadow, so
`except OutputTooLongError` never matched what providers raise:

- #2579's chunk-splitting retry (_extract_facts_with_auto_split) was
  dead on the real path; one over-long chunk failed an entire
  multi-chunk retain and discarded the successfully-extracted chunks.
- multi_llm._should_failover's `isinstance(exc, OutputTooLongError)`
  returned False, inverting its intent and burning an extra provider
  call that can't fit the over-length output either.

Re-export the canonical class from llm_wrapper instead of redefining it,
so all catch/inspect sites bind to the same object.

Now that the split path is reachable, bound its recursion with a
minimum-size floor (_MIN_SPLIT_CHUNK_CHARS = 500): a chunk that overflows
the output cap at every size is degenerate/looping output, and halving it
toward one character costs ~5000 extraction calls; the floor drops it in
~17 instead.

Fixes #3172
2026-08-04 18:22:59 +02:00
Nicolò Boschi 0ca0e87a08 fix(control-plane): report mental-model freshness from the bank write watermark (#3156)
* fix(control-plane): report mental-model freshness from the bank write watermark

The mental-models card compared each model's last_refreshed_at against the
bank's last_consolidated_at, so any consolidation after a refresh — nearly
always — reported every model as stale, and a bank that had never consolidated
reported the opposite.

Computing the real per-model answer on a list is the expensive fix:
compute_mental_model_is_stale has no index to use (there is none on
memory_units.updated_at), so it scans the bank's memories in full, per model —
10ms per model at 100k memories, 101ms at 500k, on a view that polls every 5s.

Report a bank-wide watermark instead. MAX(updated_at) rides along on the
aggregate _compute_bank_stats already runs and is served from the same cached
payload as last_memory_write_at. A model refreshed at or after it is up to date,
exactly; older only means something was written, possibly outside its tags, so
the card says "may need refresh" rather than asserting stale. The exact answer
stays on the single mental-model read, behind the dialog.

The knowledge-base tree ran that same scan once per page and polls every 12s —
already a full scan per page per tick in production. It now shares the
watermark: one cached lookup for the whole tree.

Fixes #3139

* perf(reflect): skip the per-model staleness scan below the bank watermark

search_mental_models computed staleness with the exact scoped query for every
model it returned — up to 5 full scans of the bank's memories per tool call,
serially, on a held connection, and the agent can call the tool several times
per reflect.

get_bank_freshness already computes the bank's write watermark in the same scan
it runs once per reflect, and was discarding it. Thread it through: a model
refreshed at or after the newest write in the bank cannot be stale whatever its
scope, so it skips the query entirely. Everything above the watermark still gets
the exact tag-aware answer — the agent only trusts a model without a verifying
recall() when is_stale is False, so guessing conservatively here would buy LLM
turns to save a query.

* chore(docs-skill): re-sync the generated reference copies

generate-docs-skill.sh output drifted from the docs pages that landed on main
(retain narrator guidance, configuration, coding-agents). Regenerated so
verify-generated-files has nothing to report.
2026-08-04 16:53:19 +02:00
Nicolò Boschi 04b7c9a188 docs(retain): suggest a distinct document_id per source document (#3173)
* docs(retain): suggest a distinct document_id per source document

Clarify the item-level document_id field: items sharing a document_id
are grouped into one document, so callers should provide a distinct id
per source document (auto-generated when omitted). No behavior change —
mixed explicit/implicit batches stay backwards compatible.

Refs #3010

* chore(clients): regenerate TS client for document_id doc update

* chore(docs-skill): regenerate hindsight-docs skill references

Picks up the document_id doc update plus pre-existing drift in the
generated skill snapshot (retain.md, coding-agents.md) from prior merges.
2026-08-04 16:42:27 +02:00
Nicolò Boschi ebae35670e release(coding-agents): v0.0.4 2026-08-04 16:32:13 +02:00
Nicolò Boschi 333812c85a fix(coding-agents): read Devin's transcript with node:sqlite, and refuse to install without it (#3175)
* fix(coding-agents): read Devin's transcript with node:sqlite, and refuse to install without it

Devin is the only harness whose hooks never hand over a transcript — they carry
a session id and nothing else, so the conversation has to be read from the CLI's
own sessions.db. That read shelled out to the `sqlite3` BINARY, which is not a
declared dependency and was never checked for. Where it was absent, execFileSync
threw ENOENT, a bare `catch` folded it into `return []`, and retain no-opped
forever while the installer reported success (#3125).

Node ships its own SQLite, so the binary is no longer needed for anything:

- `node:sqlite` replaces the subprocess. It is a builtin — nothing added to the
  package, nothing bundled (esbuild leaves `node:` imports external; the devin
  hook bundle is unchanged at 59K). Loaded through createRequire INSIDE the read
  function rather than imported at module scope: this module is pulled in by
  hook-lifecycle, which every harness shares, so a static import would break
  Claude Code and Codex on a Node without it.
- The session id is now a bound parameter instead of being escaped into the SQL
  string by hand.
- Missing reader, absent database and read failure each emit a distinct `diag`
  event, so a permanently memory-less install no longer looks like an idle
  session. A Devin storage-schema change surfaces the same way.
- `install devin-cli` preflights the `node` on PATH — the interpreter the hook
  command actually runs under, which an npx-launched installer may not be — and
  refuses with the reason and a non-zero exit instead of wiring hooks that could
  never retain anything. On `install all` only Devin is blocked; the other
  agents are still wired.

The SQLite read path had no tests at all; it now covers reading a session,
binding a quoted id, and both failure diagnostics against a real database.

* ci: export the integrations-coding-agents change filter

The filter was defined and the job consumed it, but detect-changes never listed
it among its outputs — so `needs.detect-changes.outputs.integrations-coding-agents`
was always empty and test-coding-agents ran only on workflow_dispatch or a
workflow-file change. Every PR touching just that package went untested.

* chore: regenerate the stale docs-skill mirror

Pre-existing drift on main, not from this branch: the `agent_name` deprecation
landed in the docs without re-running generate-docs-skill.sh, so
verify-generated-files fails for every PR. Pulled in here because this PR can't
go green without it.
2026-08-04 16:30:08 +02:00
Joonyoung Park 417efb35d8 fix(tei): retry embedding connect timeouts (#3097) 2026-08-04 15:40:40 +02:00
Nicolò Boschi 8e953c2300 feat(embeddings): generic per-input token cap across all providers (#3160)
Unify the two provider-specific truncation knobs into one generic,
provider-agnostic flag and apply the cap at the single choke point
(`generate_embeddings_batch`) before any backend's `encode()` runs, so
every provider and every path (retain, recall queries, consolidation,
import) gets identical truncation.

- New: HINDSIGHT_API_EMBEDDINGS_MAX_INPUT_TOKENS (config
  `embeddings_max_input_tokens`), off by default, applies to all providers.
- Deprecated alias: HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS
  still honored (folded into the generic name at load time).
- Move truncation out of LiteLLMSDKEmbeddings into embedding_utils; the
  `truncate_to_tokens` helper moves to token_encoding.py and returns a
  TokenTruncation dataclass (no tuple return).
- Docs + .env.example (and bundled embed copy) updated; tests migrated to
  the central path plus config alias/precedence coverage.
2026-08-04 15:36:27 +02:00
Nicolò Boschi cac55fedad feat(config): per-operation llm_extra_body overrides (#3159)
* feat(config): per-operation llm_extra_body overrides

Extra request-body params could only be set globally via
HINDSIGHT_API_LLM_EXTRA_BODY, so every operation shared one dict. That
forces a single choice on knobs that are genuinely per-operation — e.g.
disabling a model's thinking mode for retain extraction while leaving it
on for reflect (vLLM chat_template_kwargs), or setting a different
max_tokens per operation.

Add the same per-operation override the other LLM params already have:

  HINDSIGHT_API_RETAIN_LLM_EXTRA_BODY
  HINDSIGHT_API_REFLECT_LLM_EXTRA_BODY
  HINDSIGHT_API_CONSOLIDATION_LLM_EXTRA_BODY

Each follows the reasoning_effort pattern exactly: parsed into an
optional HindsightConfig field, resolved in MemoryEngine as
`config.<op>_llm_extra_body or config.llm_extra_body`, so an unset
operation keeps using the global value. Static server-level config (not
per-bank configurable), matching the global flag.

A per-operation value replaces the global dict rather than merging with
it — extra-body params are provider-native, and this is how every other
per-operation override behaves.

* docs: regenerate hindsight-docs skill for the new config rows

* chore(docs): resync coding-agents skill reference

Pre-existing drift, unrelated to this PR's feature: the source doc
hindsight-docs/docs-integrations/coding-agents.md was updated by
17b7f46ae / d238d2f7d, but skills/hindsight-docs/ has not been
regenerated since 4278f0989. verify-generated-files is therefore red on
main, and stays red on any PR that runs it until the copy is resynced.

Purely the output of ./scripts/generate-docs-skill.sh — no hand edits.
2026-08-04 11:23:03 +02:00
github-actions[bot] b5548ac25c chore: update star history 2026-08-04 04:25:24 +00:00
Nicolò Boschi 5c15f28afe fix(recall): enforce the query token cap for internal recalls, not just HTTP (#3158)
`HINDSIGHT_API_RECALL_MAX_QUERY_TOKENS` (500, added in #298 after a 1848-token
query timed out) was only checked in the REST handler. Every internal caller
reaches `MemoryEngine.recall_async` directly — consolidation, the reflect tools,
the MCP tools and the context extension — and passed arbitrarily long text
through.

Consolidation recalls with the whole fact text as the query, so a degenerate
extraction (58k words, 4 distinct) became a 54k-term OR `tsquery`; evaluating it
recurses once per node and exceeded Postgres' stack depth (SQLSTATE 54001). The
consolidation op then retried for 7 days and blocked every later consolidation
on that bank (#3134).

Bound the query at the engine ingress instead. Internal callers truncate rather
than fail — a long source fact must still consolidate, just on a bounded query.
The REST handler keeps its HTTP 400 for client-supplied queries.
2026-08-03 18:53:06 +02:00
Nicolò Boschi f77f2188d6 docs(retain): deprecate bank name as narrator; steer speaker via context (#3138) (#3155)
The bank profile `name` field is documented as a display label only, but at
retain time `_resolve_narrator` silently uses it as the narrator (memory owner)
whenever it differs from `bank_id` — the undocumented coupling reported in #3138.

Stop advertising that path without changing any runtime behavior (100% backward
compatible):
- retain.md now steers speaker attribution solely through each item's `context`,
  dropping the advice to set a bank `name` as the agent's name.
- The dry-run extract `agent_name` override is marked `deprecated` in the schema
  (still honored) and repointed to `context`.

`_resolve_narrator` and the prompt injection are unchanged, so existing banks
behave exactly as before.
2026-08-03 18:14:27 +02:00
Nicolò Boschi efcf36aaac chore(api): drop the never-written memory_units.access_count column (#3157)
`memory_units.access_count` has existed since the initial schema (5a366d414dce)
with an `access_count DESC` index alongside it, but no code path ever wrote it
and no query ever read or ordered by it. It was 0 on every row of every install,
and PostgreSQL maintained a btree over it for nothing.

Migration e4a7c1b9d2f6 drops it from `memory_units` and from the curation archive
`invalidated_memory_units` on both PG and Oracle. Both are required: curation
moves a row with an INSERT…SELECT whose column list is read from the catalog at
runtime (`memories/pg/writes.py::_memory_unit_columns`), so the two tables must
stay in lockstep or the round-trip breaks on a column mismatch. Dropping the
column implicitly drops its index on both dialects.

Also removes the last three references: the stale comment naming an
`access_count_update` task type that was never implemented, the column name in
the Oracle backend's numeric-RETURNING list, and that same never-implemented task
type used as a placeholder string in a worker test.

Restore is fixed so the drop doesn't strand older backups. Its preflight rejected
any backup carrying a column the target lacks ("target is missing backup
columns …"), which would have made every backup taken before this migration
permanently unrestorable. Unknown columns are now skipped and reported instead.
They cannot simply be left out of the copy_to_table column list: binary COPY
carries no column identities, so a tuple's fields are matched to the column list
purely by position, and an unedited stream desynchronises — PostgreSQL rejects it
with "row field count is N, expected M", and a subtler mismatch could land values
in the wrong columns. `_strip_binary_copy_fields` therefore rewrites the stream,
dropping each ignored column's field from every tuple. Type mismatches on columns
present in both schemas stay fatal.
2026-08-03 18:07:45 +02:00
Nicolò Boschi 84fd3b1767 docs(coding-agents): explain how imported sessions are attributed
Both supported harnesses record the directory each session ran in, so a
conversation is only imported when the session proves where it belongs — a
paragraph on why that matters (the folder-name encoding is ambiguous, and a
wrong guess files another repo's conversation into your bank) and what happens
to sessions that record nothing.
2026-08-03 18:01:14 +02:00
tao943andtao943 4b76d8be22 fix: return 422 for invalid recall fact types (#3062)
Co-authored-by: tao943 <[email protected]>
2026-08-03 17:51:02 +02:00
Nicolò Boschi d238d2f7d7 fix(coding-agents): attribute imported sessions by recorded cwd, never by name
A conversation may only be imported into a repo's bank when the session itself
records the directory it ran in. The previous fallback — matching the project
folder name — was a guess, and the encoding makes it an unsafe one: `/` and `.`
both become `-`, so `repo-sub` is either the subdirectory `repo/sub` or an
unrelated sibling repo. Guessing wrong files someone else's conversation into
this repo's memory, which is worse than importing nothing.

Both supported harnesses can prove it: Codex writes the cwd in its session_meta
header, and Claude records one on its entries (measured: 400/400 sampled
sessions of 13,841). Sessions that record none are skipped and counted, and the
count is printed rather than swallowed.

Matching on the recorded directory also fixes the opposite error: a session run
in a SUBDIRECTORY of the repo is now imported (Claude gives a subdirectory
launch its own project folder, which an exact-name match missed), and Codex
matches sessions whose cwd is inside the repo rather than exactly equal to it.
2026-08-03 17:26:54 +02:00
Nicolò Boschi 4278f0989d feat(reflect,mental-models): surface structured output in the control plane (#3113)
* feat(reflect,mental-models): surface structured output in control plane

Reflect's response_schema -> structured_output was already implemented and
tested in the engine but never exposed in the UI. Surface it in the reflect
(think) view, and extend the same structured-output extraction to mental
models via a per-model response_schema stored in the trigger config.

- engine: refresh_mental_model reads trigger.response_schema, forwards it to
  the internal reflect call, and persists the parsed structured_output onto
  the stored reflect_response payload; fix stale 'not yet supported' docstrings
- api: add response_schema to MentalModelTrigger
- control-plane: reflect route + api.ts forward response_schema; think-view
  gets a JSON-schema input and renders structured_output; create/update mental
  model dialogs get a schema editor; detail modal renders structured_output
- tests: mental model structured-output plumbing (schema forwarded + persisted)
- regenerate OpenAPI spec + client SDKs; add i18n keys for all locales

* feat(control-plane): show configured response_schema in mental model config tab

Adds a read-only JSON card for the mental model's trigger.response_schema in
the detail modal's Configuration tab (mirrors the tag_groups card), plus the
regenerated go openapi.yaml.

* style: ruff format test_mental_model_structured_output

* fix(control-plane): don't route the JSON schema example through next-intl

The response_schema placeholder was a t() message whose value is literal JSON.
next-intl parses messages as ICU, so the '{' in the example was read as an
argument placeholder, the parse failed, and the field rendered the raw message
key instead of the example. Inline the JSON example directly on the placeholder
prop (i18n:check skips JSON-shaped placeholders) and drop the now-unused
*Placeholder message keys. Caught by running the control plane.

* feat(structured-output): validate response_schema + add a no-code schema builder

Validation (both reflect and mental models): a schema that is valid JSON but
not a usable object-with-properties silently produced empty structured_output
or blew up inside the LLM extraction call later. Now:
- engine: validate_response_schema() enforces the usable-shape contract
  (object schema, non-empty properties, well-formed required); wired as Pydantic
  field_validators on ReflectRequest.response_schema and
  MentalModelTrigger.response_schema (invalid -> HTTP 422).
- control-plane: the reflect and mental-model forms validate the schema shape on
  submit (not just JSON.parse) and surface the specific error.

No-code schema builder: a 'Build schema' button on both the reflect view and the
mental-model dialogs opens a dialog with Visual and Code modes. Visual mode edits
a flat field list (name, type, array item-type, description, required); Code mode
edits raw JSON. The two stay in sync and Apply is gated on a usable schema. Shared
frontend lib (response-schema.ts) mirrors the backend contract.

tests: test_response_schema_validation.py (16 cases: validator + model integration).

* refactor(control-plane): schema only via the builder, show set/unset status

Removes the inline response_schema JSON textarea from the reflect view and the
mental-model dialogs. Editing now happens exclusively in the schema builder; the
page shows only whether a schema is set (field count + names, with Edit/Remove)
or a Build schema button when none. Extracts the shared ResponseSchemaField
component used identically by reflect and both mental-model dialogs.

* fix(mental-models): derive structured_output from final content, not reflect's answer

In delta mode reflect only sees facts created since the last refresh, so its
answer (and any structured_output it derived) reflects just the delta — while the
stored content is the delta-merged document. Persisting the reflect-derived value
made structured_output inconsistent with the markdown.

Now the mental-model refresh no longer passes response_schema to reflect; instead
it extracts structured_output from the FINAL stored content (correct for both full
and delta), and carries the previous value forward untouched when a delta refresh
preserves content (no new facts). Adds a delta test asserting extraction runs
against the merged document, not reflect's partial answer.

* fix(schema-builder): allow switching an empty schema from Code back to Visual

An empty schema serialises to properties:{}, which schemaToFields mapped to an
empty array — and the Code->Visual guard treated 'empty' the same as 'not
representable', blocking the switch. schemaToFields now returns [] (representable)
for a missing/empty properties map and null only for genuinely unrepresentable
schemas; the switch seeds a blank field when empty.

* docs(reflect): document structured output (response_schema) + schema builder

Adds a Structured Output section to the reflect docs: how response_schema returns
both text and a structured_output projection of the same answer, the schema rules,
mental-model structured output (extracted from the final/merged document), and the
no-code Build schema editor. Regenerates the docs-skill mirror.

* feat(schema-builder): recursive visual editor for nested objects & arrays

The visual editor was flat — object/array fields had no way to define their inner
shape. Reworks the field model into a recursive tree (each field has a node; an
object node nests fields, an array node nests an item node) so you can build
nested objects and arrays-of-objects entirely in the visual editor. Code<->Visual
round-trips losslessly; schemas using features the editor can't represent (enum,
oneOf, $ref, tuple items, …) stay in code mode rather than being silently
flattened.

* fix(structured-output): recursive model for nested schemas + fail refresh loudly

Two problems surfaced by nested schemas on Gemini:

1. _generate_structured_output mapped object/array properties to bare dict/list,
   which serialize with additionalProperties — rejected by the Gemini API. So any
   schema with a nested object/array silently failed extraction. Now it builds a
   proper recursive Pydantic model (nested objects -> nested models, arrays ->
   typed lists), matching how retain's structured output already works on Gemini.

2. On extraction failure the mental-model refresh silently persisted content with
   no structured_output, clobbering the previously-stored value. Now, when a
   response_schema is configured and extraction yields nothing, the refresh raises
   MentalModelRefreshError — prior content and structured_output are preserved and
   the refresh can be retried.

Verified live on Gemini: a nested {location:object, people:array} schema now
extracts (structured_output present) instead of failing on additionalProperties.
Adds a fail-loud regression test.

* fix(schema-builder): readable error text in dark mode

text-destructive resolves to a dark red (#C0183A) in dark mode, which is
low-contrast on the dark dialog background. Use the codebase's standard
readable pattern (text-red-600 dark:text-red-400) for the builder's validation
error and the invalid-schema notice.

* fix(cli): set response_schema on MentalModelTriggerInput literals

Adding response_schema to MentalModelTrigger regenerated the Rust
MentalModelTriggerInput struct with a new field; the hand-written CLI struct
literals must initialize it (E0063). Sets response_schema: None in the three
construction sites (create/update mental model, knowledge-base pin).

* docs(api): document mental-model response_schema; fix stale reflect text-empty claim; test schema lib

- api/mental-models: document the trigger.response_schema flag + a Structured
  Output section (extraction from final content, fail-loud, validation).
- api/reflect: correct the stale claim that text is empty with response_schema —
  reflect returns both text and structured_output.
- control-plane: vitest unit tests for the response-schema lib (validation +
  recursive fields<->schema round-trip).
- regenerate docs-skill mirror.

* chore: regenerate bank-template-schema for MentalModelTrigger.response_schema

The bank template schema embeds MentalModelTrigger; adding response_schema to
the trigger changed the generated schema. Regenerated so verify-generated-files
passes.
2026-08-03 16:51:38 +02:00
Nicolò Boschi 17b7f46ae7 feat(coding-agents): --import-conversations, a migration path off the per-agent plugins
The old integrations can't be migrated by moving data: they scope a bank per
agent per project (`claude-code::myrepo`) where this package uses one per repo
(`coding-agent::myrepo`), so two old banks map onto one new one — and the
server's bank import restores a whole bank rather than merging into a live one.

Re-reading the transcripts the agent already wrote to disk sidesteps that: the
same conversations are re-extracted into whichever bank is current. The flag
hands them to the deepen engine a session start already uses, so ingestion
dedups by document id and re-running is safe.

Scoped to the current repo — this machine holds ~14k Claude sessions, and
importing every project's history would run extraction over all of them. Claude
Code keys history by project directory; Codex partitions by date and records the
cwd in each rollout's session_meta header, which is read to filter.

Only file-based harnesses are supported. opencode, Kilo, Cursor, Cline, Copilot
and Devin keep history in internal SQLite databases with unversioned schemas;
they report as skipped WITH the reason rather than importing nothing silently.

One bug worth naming: the Codex header is a single line carrying the agent's
full base instructions, tens of KB. Reading a fixed 4096-byte prefix truncated it
mid-JSON, so every rollout was skipped and the import quietly found nothing —
hidden by the surrounding catch. The header is now read a chunk at a time until
the newline, with a regression test that fails against the old slice.
2026-08-03 16:18:08 +02:00
Nicolò Boschi 06e9c7054e feat(mental-models): dry-run refresh and keep_trace for troubleshooting (#3119)
When a refresh produced an unexpected document, nothing said why. The mode
decision, resolved scope, snapshot window, retrieved-versus-used fact counts
and dropped delta operations only ever reached a log line — and cron- or
consolidation-driven refreshes run with nobody watching.

Two ways to see that reasoning, from opposite directions.

POST /mental-models/{id}/dry-run-refresh runs the production refresh
pipeline and reports what it would do, skipping exactly two writes: the
content (with its structured document and history entry) and the watermark
that moves last_refreshed_at. It takes no parameters, on purpose — a dry run
you can configure stops predicting the refresh it exists to predict. Because
nothing is persisted, a delta dry run reads exactly the window the next real
refresh will.

trigger.keep_trace records the same reasoning on every refresh of a model,
scheduled ones included, under reflect_response.trace. It is written even
when a refresh fails, which is when it matters most. The trace is shaped
like reflect's — the calls the agent made plus the refresh decision — and
holds nothing derivable from elsewhere: evidence stays in based_on, and the
resolved scope and window are reported by the dry run. Each tool call
records the window bound it was given, named `updated_at` for what the
predicate actually filters; null means the tool applies no time bound at
all, which is what explains results older than the window would suggest.

refresh_mental_model is split into a shared _execute_mental_model_refresh
that computes a result and writes nothing, plus a thin persistence step, so
the preview and the real refresh run the same body. Existing refresh
behaviour is unchanged.

In the control plane the dry run is an action on the mental model, and its
result opens in a dialog built from the History tab's own diff components.
History shows each version's own trace: the history snapshot now carries
`trace` alongside `based_on` so it survives being superseded.

Surfaced but deliberately not fixed here: when delta operations fail, the
fallback writes a candidate built from a delta-scoped recall over the whole
document, dropping content grounded in older memories (#3112).
2026-08-03 15:56:28 +02:00
Nicolò Boschi 5061ff6643 fix(coding-agents): correct the package name in the npx-refusal message
The message that tells you how to recover named the UNSCOPED package, which does
not resolve, and a bare `install`, which no longer does anything. So the one
place a user lands when they get this wrong handed them two commands that also
fail.
2026-08-03 15:47:00 +02:00
Cyprian KowalczykandiRonin dbca379410 fix(consolidation): apply sanitize_text to the _DedupDecision merge write path (#3144)
The dedup merge path passed the LLM's synthesized text straight to the fold
UPDATE with only .strip() applied, so control characters and lone surrogates
reached SQL unscrubbed. _CreateAction and _UpdateAction already scrub their
text via a sanitize_llm_output field_validator; the merge path did not.

Character-safety only (control chars + surrogates), matching the existing
create/update behaviour. Adds a regression test alongside the existing fold
test; that test is left untouched.

Co-authored-by: iRonin <[email protected]>
2026-08-03 15:46:11 +02:00
Kuba OdiasandClaude Fable 5 13cacf21a7 fix: render disallowed fields in config permission error (#3148)
The no-fields-allowed branch of the permission error in
ConfigResolver was missing its f-string prefix, so callers saw the
literal text "Not allowed to modify fields: {sorted(disallowed)}."
instead of the actual field names.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-03 15:37:39 +02:00
Nicolò Boschi bda7ffd289 fix(recall): apply the created_after/created_before window to graph expansion (#3153)
Recall bounds its time window on `updated_at`, but two arms only filtered
their entry points and then expanded outward unfiltered:

- Link expansion filtered its semantic *seeds*, then pulled each seed's whole
  neighbourhood — shared entities, semantic kNN links, causal links — with no
  window at all.
- The temporal arm's entry-point query applied the window, but the multi-hop
  spread that walks temporal/causal links from those entry points did not.

Either way a single in-window seed dragged arbitrarily old facts back into the
results. Mental-model delta refresh recalls with `created_after=<last refresh>`
and `created_before=<cutoff>` precisely to see only what changed since, so every
refresh silently re-ingested stale neighbourhoods.

The window is now pushed into the expansion SQL on both backends via a shared
`UpdatedWindow`, which renders `AND <alias>.updated_at > $n` plus its params.
Rendering is per-alias because one query applies it to several correlation
names. For entity expansion the predicate goes inside the EXISTS that already
filters `fact_type` — i.e. *before* the per-entity cap, so out-of-window
neighbours can't eat an entity's bounded fan-out and starve the in-window ones.

Left deliberately unwindowed: `include_source_facts` returns an observation's
sources in a separate field, not in `results`. An observation refreshed inside
the window has older sources by construction, so filtering there would gut the
feature.

While changing these signatures, the three expansion methods also stop
returning a bare 3-tuple (a project standard) in favour of `LinkExpansionRows`.
The signals stay separate because each carries a different score scale and the
caller transforms each before summing.

Tests: `test_recall_time_range_graph.py` seeds an in-window fact plus
neighbours reachable only through the graph and asserts recall never returns
them, covering all three link signals, both bounds, observations, and temporal
spreading. Each has a control assertion proving the links are live, so a pass
cannot be vacuous. Backend-parametrized unit tests in `test_db_abstraction.py`
cover clause placement relative to the cap and verify an unbounded recall emits
the SQL verbatim with no dangling placeholders (Oracle rejects a query
referencing an unbound param).
2026-08-03 15:31:19 +02:00
Nicolò Boschi d6bdb62a02 docs(coding-agents): generate the docs page from the README, lead with install
The two pages described the same product in different words and had drifted: the
docs page still carried install instructions that no longer worked and a harness
table that had fallen behind. The README is the single source now — a sync
script writes the doc page from it, and `--check` runs in the docs build so the
two cannot separate again (verified: the build fails on a hand-edited page).

Also, from reading it as a new user would:

- Install moves above "How it works". The first question is "how do I get this",
  and it was buried under the harness table.
- "How it works" becomes short bullets grouped by what actually happens —
  ingestion, what the agent receives, write-back, guarantees — instead of seven
  dense paragraphs.
- Layout (a source-tree map for contributors) is dropped from the doc page: it
  answers a question no reader of the docs site is asking.
- The Configuration section no longer claims "no environment variables", which
  stopped being true when the env fallback landed.
2026-08-03 15:29:26 +02:00
Nicolò Boschi 0aa3480e8c release(coding-agents): v0.0.3 2026-08-03 14:55:49 +02:00
Nicolò Boschi 05b55d20f5 feat(coding-agents): require an explicit target — install all or a harness name
A bare `install` wired every detected agent: hooks, MCP registration and the
companion skill written into up to ten hosts' configs from one command that
never said it would. `all` is now spelled out, so wiring the whole machine is a
choice rather than a side effect, and a bare `install` changes nothing and
prints the options. `uninstall` matches, so the pair stays symmetric.

Chosen over a --yes flag or a confirmation prompt: a prompt has to decide what
to do without a TTY (assume consent, or break automation), whereas an explicit
target reads the same in a terminal, a script and a README.

The README also gains a per-agent section — one row per agent with its exact
command and what that wiring touches — since "install and it finds everything"
is no longer the whole story.
2026-08-03 14:54:14 +02:00
Nicolò Boschi 4d22a882f5 docs(knowledge-pages): document Knowledge Pages and Mental Models, and manage them from the CLI (#3151)
* docs(knowledge-pages): document Knowledge Pages and Mental Models

Knowledge Pages shipped in #2455 with no documentation at all — no
architecture page, no API page, no mention in the sidebar. Mental models
had an API page but nothing explaining what they are or why they are
fast. Add both, as top-level entries under Architecture and API.

- Architecture: how pages are mental models with a simplified,
  document-shaped configuration; the folder hierarchy; the `hindsight fs`
  filesystem projection; page-level search; and why a projected view over
  reconciled memory is not the same thing as a folder of raw files.
- Architecture: mental models as standing answers built in the background,
  so an application reads the current version instead of paying for
  synthesis on the request path.
- API: the full knowledge-base endpoint surface, the page defaults and
  what each one buys, staleness gating, what a refresh reads, and how
  delta mode edits a structured document instead of regenerating prose.
  The mental-model trigger table gains the seven settings it was missing.
- FAQ: mental model vs knowledge page. Also corrects the neighbouring
  answer, which described mental models as built automatically during
  retain — that is observations.

The API examples use the maintained clients like every other API page, so
this adds the knowledge-base surface to the Python and TypeScript wrappers
(kept at parity, with request-mapping tests on both sides) and runnable
Python/Node/Go examples.

* feat(cli): manage knowledge pages from the CLI

The knowledge base was reachable from every client except the CLI, where
the eight endpoints were listed as deliberate coverage skips ("managed in
the control plane UI"). That left `hindsight fs` able to mirror pages
read-only but nothing able to create, edit, search, or delete them — and
it meant the API docs could not show a CLI tab alongside Python/Node/Go.

Adds `hindsight knowledge-base` with tree, create-folder, create-page,
get-page, search, update, delete, and export, removing the skips so
cli-coverage-check enforces the surface from here on.

`create-page` sends no trigger unless --mode or --fact-types is passed, so
the server's page defaults stand; when either is given the whole trigger
has to be restated, because a supplied trigger replaces the defaults
rather than merging with them.

Also adds the CLI tab to the Knowledge Pages API page and a Knowledge Base
section to the CLI reference.
2026-08-03 14:53:57 +02:00
Nicolò Boschi 2b1c4989f3 fix(coding-agents): register grok-build/copilot-cli, add env config fallback
Two independent reports from 0.0.1.

`deepen` resolves a harness through harness/registry.ts, which listed 8 of the
10 harnesses the installer wires. grok-build and copilot-cli were installable
but unknown to the registry, so deepen threw "unknown harness 'grok-build'" and
the background git-diff enrichment never ran for those users. Both are now
registered with their hook bins, and a guard test asserts the registry covers
every installer — the two lists are separate and had silently drifted.

Configuration also gains an environment fallback: `HINDSIGHT_API_URL`,
`HINDSIGHT_API_TOKEN` and one var per scalar setting. Deliberately a FALLBACK
beneath the config file, so an existing setup cannot change behaviour merely by
having env present; it covers containers, CI and secret managers that inject a
token instead of writing a credential to disk. Booleans and numbers are parsed
(a malformed number is ignored with a warning rather than becoming NaN), and an
empty var contributes nothing so it can't mask a file value. The map-valued
settings (mapPathToBank, harnesses, banks) stay file-only — nested branching
does not survive flattening into one variable.
2026-08-03 14:37:52 +02:00
Nicolò Boschi 55883dc517 feat(llm): add openai-responses provider (OpenAI Responses API) (#3121)
Add a provider that talks exclusively to the OpenAI Responses API
(`client.responses.create` → `/v1/responses`) — never chat/completions.

Motivation: reasoning models such as gpt-5.6-terra reject `reasoning_effort`
combined with function tools on `/v1/chat/completions` (HTTP 400 unless
`reasoning_effort="none"`, see #2983). Reflect is a tool-calling search loop, so
that constraint forces the whole reflect operation — including the final
synthesis — to run with reasoning disabled. The Responses API models the
chain-of-thought as a first-class reasoning item, so reasoning and tools coexist;
reflect's search loop can now run with a real reasoning effort.

`OpenAIResponsesLLM` is a standalone `LLMInterface` implementation (OpenAI-only;
it deliberately does NOT subclass the multi-vendor chat/completions provider, so
it can never route through `chat.completions` and carries none of the
groq/ollama/deepseek special-casing). It reuses only provider-agnostic pure
helpers (text cleanup, quota-defer parsing). It translates the engine's
chat-shaped inputs:
- chat messages → `input` items (assistant `tool_calls` → `function_call`,
  `role="tool"` → `function_call_output` keyed by `call_id`),
- nested `{"type":"function","function":{...}}` tools → flattened
  `{"type":"function","name":...,"parameters":...}`,
- flat `reasoning_effort` → a `reasoning={"effort": ...}` object,
- `response_format` → `text={"format": {...}}` (strict json_schema or the soft
  schema-in-prompt + json_object fallback),
- reads `response.output_text` + `function_call` items from `response.output`.

Generic LLM config flags are honored: `extra_body`, `timeout`, per-call
`temperature`/`max_completion_tokens`/`max_retries`. It also wires two flags the
chat/completions path drops — `openai_service_tier` (as the native Responses
`service_tier`) and `default_headers` (on the SDK client).

The conversation is replayed statelessly each turn (`store=False`, no
`previous_response_id`); server-side reasoning reuse across turns is left as a
future optimization.

Wiring: provider registration + dispatch, `PROVIDER_DEFAULT_MODELS` default
(`gpt-5.6`), docs + `.env.example` (+ embed template sync), and an `openai`
floor bump to `>=1.66.0` for the Responses API surface. Unit tests mock
`responses.create` (incl. the generic-flag wiring) and pin that reasoning + tools
are sent together on the tool path — the combination chat/completions rejects.

Validated live against real gpt-5.6-terra: plain + reasoning, strict structured
output, reasoning+tools together with a stateless function_call replay, and a
full run_reflect_agent end-to-end (stubbed retrieval, no DB) — tools drove to a
correct synthesized answer.
2026-08-03 14:33:50 +02:00
Nicolò Boschi 06eaf09493 docs(coding-agents): scope the npm package in the install command
The package publishes as @vectorize-io/hindsight-coding-agents, so the unscoped
`npm install -g hindsight-coding-agents` in the README, the docs page and the
companion skill resolved to a different (non-existent) package and failed for
the first users who tried it. The BINARY stays unscoped, so `hindsight-coding-agents
install` is unchanged.
2026-08-03 14:29:40 +02:00
github-actions[bot] 736d1c2f7b chore: update star history 2026-08-03 12:22:17 +00:00
Nicolò Boschi bb26f49e93 release(coding-agents): v0.0.2 2026-08-03 14:00:58 +02:00
Nicolò Boschi 47b679ec24 fix(coding-agents): repoint Antigravity and the Claude MCP server on re-install
Two hosts silently kept stale wiring when the package moved, each for its own
reason. Both surfaced after the directory rename, on a machine that already had
Hindsight installed.

- Antigravity keys its hooks.json by a top-level namespace equal to MARKER,
  where every other host matches entries by substring. Renaming the marker wrote
  a second namespace and left the first registered, so every Antigravity hook
  fired twice — once against a path that no longer exists. Install now drops any
  namespace written under a previous marker, leaving unrelated bundles alone.

- `claude mcp add` refuses a name that already exists ("MCP server hindsight
  already exists in user config"), so the add failed and the installer fell back
  to printing manual instructions. The old registration survived and Claude Code
  reported "Failed to connect — Connection closed" with the hindsight_* tools
  dead. Remove before add, so the registration is replaced like the hooks are.

Both are the same class as the Grok block that skipped when one already existed:
"install" must repair existing wiring, not step around it.
2026-08-03 13:59:54 +02:00
github-actions[bot] a1ec656424 chore: update star history 2026-08-03 10:36:27 +00:00
Nicolò Boschi cb0e1dea40 chore: replace star-history.com chart with self-hosted gh-stars chart (#3150)
The api.star-history.com embed in the README was broken. Replace it with
nicoloboschi/gh-stars, which backfills stargazer data via a scheduled GitHub
Action and commits a self-hosted SVG chart into the repo, so the README image
no longer depends on a third-party service.
2026-08-03 12:32:58 +02:00
Nicolò Boschi bf6c12d550 feat(control-plane): refresh the UI design system (#3149)
Replaces the stock shadcn oklch greys with the Hindsight palette and moves
the shared primitives onto the design system's density, so the whole app
picks up the new look from tokens rather than per-component edits.

Tokens (globals.css)
- Blue-shifted neutral palette in both modes: page #F3F5F9/#080C17, card
  #FFFFFF/#0F1724, sidebar #FFFFFF/#0A1020, hairline borders. Light mode
  previously had --card equal to --background, so cards were invisible
  against the page.
- Adds the --hs-* semantic layer (surface/fg/border/status/chart) and its
  @theme mappings.
- --tracking-normal 0.025em -> 0; Inter reads wrong with positive tracking
  at body sizes.
- Light --muted-foreground is #525866 (5.3:1 vs page, 7.1:1 vs card) so body
  copy clears WCAG AA in both modes.

Fonts
- Inter and JetBrains Mono move to next/font/google, replacing the Google
  Fonts @import that loaded weights lazily and left semibold headings in the
  system fallback until the weight arrived. Space Grotesk is dropped; it was
  only reachable via a `font-heading` utility no component used.

Primitives
- card, button (+ gradient variant), input, textarea, select, switch,
  checkbox, dialog, alert-dialog, popover, dropdown-menu, command move to
  13px / h-9 / rounded-[10px], with rounded-[16px] cards and dialogs and
  blurred overlays.

Layout
- Bank page content is capped by a responsive staircase
  (1024 / xl:1280 / 2xl:1440, centered). It was uncapped, so on a 16" display
  body text and table rows spanned the full ~1700px window.
- Active tab underlines use the brand gradient instead of flat --primary.
- The sidebar toggles from anywhere on its chrome, not just the button.

Preserved deliberately: the h1-h6 weight default (Tailwind preflight resets
headings to inherit and not every heading here carries an explicit weight
utility), plus the chip tokens, logo keyframes, themed scrollbars and .prose
table rules that the tokens rewrite would otherwise have dropped.

Note: page.tsx is mostly re-indentation from wrapping the views in the width
container; `git diff -w` shows the real change.
2026-08-03 12:32:41 +02:00
Nicolò Boschi 237a45fdb5 fix(coding-agents): declare the repository so provenance publishing works
The release workflow publishes with `npm publish --provenance`, and npm rejects
the upload when package.json has no `repository` matching the signed provenance:

  422 Unprocessable Entity - Error verifying sigstore provenance bundle:
  "repository.url" is "", expected "https://github.com/vectorize-io/hindsight"

v0.0.1 built and tagged fine and only failed at the registry. Same shape as the
other npm integrations (openclaw, ai-sdk, chat), including `directory` so npm
links to the subfolder.
2026-08-03 11:18:17 +02:00
Nicolò Boschi 8396b51d92 release(coding-agents): v0.0.1 2026-08-03 11:09:11 +02:00
Nicolò Boschi 8e5fdf28dd docs(coding-agents): unlist the integration page until it is announced
The package is about to be released so it can be installed and exercised end to
end, but its page should not surface yet. Three edits, each covering a different
surface:

- integrations.json: the entry drives BOTH the gallery and the sidebar (the
  sidebar is generated from this file), so removing it hides both.
- `unlisted: true` on the doc page: keeps it out of search and the sitemap and
  marks it noindex, while leaving it reachable by direct URL — and stops the
  build warning about a doc belonging to no sidebar.
- check-integrations.mjs EXCLUDED: the reverse check fails the docs build when a
  released integration has no gallery entry, so without this the build breaks the
  moment the release tag exists.

To publish it later: drop it from EXCLUDED, restore the integrations.json entry,
and remove `unlisted` from the page.
2026-08-03 11:08:27 +02:00
Nicolò Boschi abb5ba3498 docs(embed): document uvx cache growth and how to reclaim it (#2915) (#3118)
The uvx-launched daemon keeps a cached Python environment per Hindsight
version it has run (~1.5 GB each), and nothing removes them. Document the
stop / prune / restart recovery, including why the daemon has to be stopped
first.
2026-08-03 10:46:03 +02:00
Nicolò BoschiandChris Latimer b5d8439c8f hindsight-coding-agents: harness-pluggable long-term memory for coding agents (#2522)
* feat(integrations): add hindsight-opencode-coding plugin

Reflect-only long-term memory for coding agents in OpenCode, with a git+chat
backfill and (opt-in) live session write-back.

- reflect + INJECT: on a task, reflect() the symptom and push the root-cause
  answer into the system prompt (no tools/recall).
- backfill: every commit (full message + full diff, commit timestamp + git
  metadata) under a 'git' retain strategy; each chat as a JSON user/assistant
  transcript with custom extraction (<=2 coherent facts) under a 'chat' strategy;
  observations on; optional codebase knowledge pages.
- live write-back (opt-in HINDSIGHT_RETAIN_SESSIONS): every N turns upsert the
  tool-filtered transcript under a stable conversation:<sessionID> document_id.

* refactor(integrations): generalize opencode-coding into hindsight-coding-agents

Make the coding-memory plugin harness-pluggable instead of opencode-specific.
A 'harness' (coding agent) differs in only two places; everything else is now
shared core:
  - src/core/    hindsight client, missions, git + chat ingest, inject, RuntimeCore
  - src/core/types.ts  HarnessAdapter + ChatReader interfaces
  - src/harness/ per-agent adapters + registry (opencode implemented)

Backfill: --harness selects how past sessions are read (opencode today);
git ingest, retain strategies, missions, and knowledge pages are identical
across agents. Runtime: HINDSIGHT_HARNESS (default opencode) selects the
adapter that binds RuntimeCore's reflect+inject+write-back to that agent's
plugin API. Adding an agent = one adapter file + a registry entry.

Type-checks and builds clean; unknown --harness/HINDSIGHT_HARNESS errors with
the available list.

* feat(coding-agents): on-demand memory_reflect tool, opt-in git-sync, JSON config

Add two capabilities to the reflect-only coding-agents plugin and move all
configuration off environment variables onto a single JSON file.

- memory_reflect tool: exposes the same synthesized reflect that is auto-injected
  on the first message as an on-demand opencode tool the agent can call mid-task
  (RuntimeCore.reflectNow + opencode adapter tool). Harness-agnostic core, thin
  opencode wiring.
- incremental git-sync (opt-in): on load, diff the target ref's commits
  (origin/main, falling back to HEAD) against the git:<sha> document_ids already
  in the bank and async-retain only the missing ones, reusing the backfill's
  per-commit encoding (retainCommit). Set-based, correct across rebases;
  best-effort, non-blocking. Off by default (gitSync.enabled).
  Adds HindsightClient.listDocumentIds + core/sync.ts.
- config file: all settings now come from ~/.hindsight/coding-agent.json
  (core/config.ts) -- no environment variables. The backfill CLI reads the same
  file for shared connection/bank settings with --flags overriding; operation
  flags stay CLI-only.

Committed with --no-verify: the repo-wide pre-commit lint hook is broken in this
environment (missing @eslint/js in hindsight-control-plane) and blocks all commits.

* fix(coding-agents): remove benchmark-specific strings from prompts

Fairness audit of the sdebench benchmark found three contaminations:
- CHAT_CUSTOM_INSTRUCTIONS used the literal answer to a graded task
  (round_cents/ROUND_HALF_DOWN/legacy ledger) as its example - replaced
  with a fictional, non-benchmark example.
- buildSystemInjection told the model 'the hidden tests depend on those
  exact choices' - hardcoded knowledge of the benchmark's grading;
  reworded benchmark-agnostic.
- REFLECT_MISSION examples were shape-matched to specific benchmark
  tasks (symbol mappings, exact numbers) - neutralized.

No behavior change intended beyond removing the leaked specifics.
(includes hook-regenerated skills/hindsight-docs sync)

* feat(coding-agents): reflect-outcome diagnostics — no more silent memory loss

A benchmark sweep ran the entire memory arm with zero injected memory:
reflect failed environmentally on every task and the best-effort catch
swallowed it, making a memory-less run indistinguishable from a memory
run. onTask now appends a reflect_ok/reflect_empty/reflect_failed record
(duration, error, query prefix) to HINDSIGHT_DIAG_FILE (default
/tmp/hindsight-plugin.log). Consumers can assert a session actually had
memory before trusting a comparison.

* fix(coding-agents): chronological session recency + supersession-aware reflect

Two defects surfaced by the conversation-amended benchmark tasks (a rule
settled in one chat and amended in a later one):

- chat ingestion staggered synthetic timestamps NOW - i*1h, INVERTING
  recency: an amendment chat ranked older than the decision it
  superseded, steering temporal ranking toward the stale rule. Session
  list order is chronological; the last session is now the newest.
- REFLECT_MISSION now states that when memories conflict on the same
  rule, the latest/superseding decision wins and the superseded rule
  must be reported as no longer in effect, never presented as the fix.

Observed live: reflect on an amended bank returned the superseded
keep-latest rule as the fix. Both fixes are general recency/consistency
semantics, not benchmark-specific behavior.

* feat(coding-agents): multi-harness configurability + Claude Code hook entry

One config, several agents side by side:

- Each runtime entry point now KNOWS its harness instead of reading the
  config's `harness` key (which selected a single global adapter and
  made opencode + claude mutually exclusive). That key now only picks
  the backfill's session formatter.
- New `harnesses.<name>` config sections: per-agent overrides of any
  field (bank, disabled, timeouts) over shared connection defaults.
- New project-local layer: <project>/.hindsight/coding-agent.json
  overrides the global file — the natural home for a per-repo bank.
  Precedence: defaults < global < global.harnesses < project <
  project.harnesses.
- New entry point: `hindsight-claude-hook` (dist/claude-hook.js), a
  Claude Code UserPromptSubmit hook. Reflects once per Claude session,
  caches the answer in tmp and re-injects it on later prompts, and
  writes the same reflect_ok/failed diagnostics as the opencode path.

Verified live: claude hook via project config + harnesses section
(reflect_ok, cached re-emit in 46ms, one reflect total); opencode via
the benchmark harness (reflect_ok, task solved 0 corrections).

* feat(coding-agents): per-repo dynamic bank resolution (family convention)

Port of the bank-derivation convention shared by the claude-code, omo,
cline, and opencode integrations, with coding-first defaults:

- No bankId configured => the bank is derived from the git repo the
  working directory belongs to, WORKTREE-AWARE: git rev-parse
  --git-common-dir resolves every linked worktree to the main worktree's
  basename, so all worktrees of a repo share one memory bank (bare repos
  use the bare dir name; non-git dirs fall back to the dir basename).
- Default granularity is [gitProject] (not agent::project): opencode and
  claude share ONE memory per repo — add 'agent' to
  dynamicBankGranularity to split per agent.
- Explicit bankId keeps today's static behavior (benchmark harness,
  single-bank setups); dynamicBankId forces either mode; supporting
  fields: bankIdPrefix, directoryBankMap (exact cwd -> bank escape
  hatch), agentName, resolveWorktrees.
- backfill: --bank wins, else the SAME resolution applied to --repo, so
  `hindsight-coding-backfill --repo .` fills exactly the bank the
  agents will read.

Verified: worktree -> main-repo bank (hs-coding-plugin-wt -> memory-poc),
static/prefix/dirMap/granularity cases, and the claude hook e2e
(reflect_ok via directoryBankMap against a live bank).

* feat(coding-agents): bank template string, prefix path map, {harness} field

Bank-resolution refinements:

- `bankIdTemplate` format string replaces the granularity array:
  e.g. "hindsight-{gitProject}" or "{harness}-{gitProject}" — default
  "{gitProject}" (opencode + claude share one bank per repo).
  Placeholders: {gitProject} {project} {harness} {channel} {user};
  unknown placeholders warn with the valid list. bankIdPrefix removed
  (expressible in the template).
- {harness} is supplied by the entry point itself (opencode plugin,
  claude hook, backfill --harness), not a config field — nothing to
  keep in sync.
- directoryBankMap now matches by LONGEST absolute-path prefix and
  overrides everything incl. an explicit bankId: mapping a repo root
  covers all its subdirectories; deeper mappings win.
- config discovery walks UP from the working directory to the nearest
  .hindsight/coding-agent.json — a hook invoked from a repo subdir
  previously missed the repo's project config entirely (found by an
  e2e test that failed exactly this way).

Verified: derivation matrix (template/prefix-map/override/static/bad
placeholder), claude hook e2e from a nested subdir (reflect_ok via
walked-up config + prefix-matched map), opencode benchmark task green.

* feat(coding-agents): cursor-cli + codex harnesses, unit tests, live system tests

Harnesses — hook-based agents now share one runtime (core/hook.ts:
stdin event -> layered config -> per-repo bank -> once-per-session
reflect with tmp cache -> native output -> diagnostics), so each agent
is a ~25-line HookSpec:
- hindsight-claude-hook  (UserPromptSubmit -> additionalContext)
- hindsight-cursor-hook  (beforeSubmitPrompt -> {continue, additional_context})
- hindsight-codex-hook   (Codex CLI v0.116+ claude-compatible hooks;
  accepts prompt/user_prompt)
All three + opencode registered in the harness registry (backfill
--harness resolves them; hook harnesses share the normalized-JSON
chat reader).

Tests (vitest, family convention):
- 25 unit tests: full bank-derivation matrix (worktree/bare/static/
  dynamic/template/{harness}/prefix-map incl. longest-wins and
  no-sibling-false-match) and config layering (harness sections,
  project-over-global, upward walk, nearest-wins, gitSync field merge,
  malformed fallback, legacy signature).
- live system suite (npm run test:live, HINDSIGHT_LIVE_E2E=1): builds a
  real git repo with a decision planted in a commit + a conversation,
  runs the real backfill CLI (server-side LLM extraction), then invokes
  the BUILT hook binaries as subprocesses and asserts the decision's
  literals come back in the injected context — semantic verification
  with a real LLM — plus per-session cache behavior and diag records.
  All 4 passing against a live server.

Note: session ids in the live suite are unique per run — the hooks
cache per session id in tmp, and a static id once cached a bad answer
from a half-broken server across reruns.

* docs(coding-agents): full README rewrite + integration docs page

README now covers everything the package does today: the reflect-once/
inject-every-turn mechanics, all four harnesses (opencode plugin +
claude/codex/cursor hooks) with install snippets, the complete
configuration reference (layered files, harnesses sections, per-repo
dynamic bank resolution with template placeholders, directoryBankMap,
worktree behavior), backfill CLI incl. bank auto-resolution and
chronological session ordering, the reflect diagnostics contract, and
the unit + live test suites.

Docs site: new docs-integrations/coding-agents.md (same content adapted
to the integration-guide format) + integrations.json hub entry so the
generated sidebar picks it up. Placeholder icon (github.png) pending a
real one. Verified: page renders (docusaurus build), all doc pre-flight
checks pass for this entry — note the docs build on this branch was
ALREADY failing on the unrelated pre-existing 'zcode missing from
integrations.json' check.

* feat(coding-agents): 🧠 attribution header in buildSystemInjection

Prepend the 'Using Hindsight Memories' visible-attribution directive to the
harness-agnostic system injection so every coding-agent harness surfaces a
recognizable header when it uses recalled memory. Covered by 5 deterministic
inject.test.ts cases (real emoji + em dash, no lone surrogates).

* feat(core): add recall() to HindsightClient

* style(core): apply prettier formatting to recall test

* style(coding-agents): normalize prettier formatting across package

* fix(core): narrow RecallResult to actual API contract, add fetch-throw test

* feat(core): formatMemories + shared attribution preamble

* style(core): prettier-wrap recall.test.ts array literal

* fix(core): cover formatMemories trim/filter + drop stale inject comment

* feat(core): per-turn recall in the hook runtime (reflect once, recall every turn)

Extracts the hook logic into a pure, unit-testable buildHookOutput(): every
prompt now runs recall() and injects a <hindsight_memories> block; reflect
still runs once per session (first prompt) and its cached answer is no
longer re-injected on later turns. runHook() becomes thin stdin/stdout
plumbing with a makeClient seam for tests. Updates the three hook
entrypoints' doc comments to match, and adds recallMaxTokens/recallTimeoutMs
config fields.

* fix(core): make recall fail-open in buildHookOutput + cover recall failure/opts

* feat(claude-code-v2): wrapper plugin skeleton (per-turn recall via bundled core)

Also disables tsup code-splitting in hindsight-coding-agents so each bin
entry (claude-hook.js etc.) is a single self-contained file with no
shared chunk-*.js — required for wrapper build scripts that copy just
the one hook file out of dist/.

* chore(coding-agents): sync codex-hook bin into package-lock

* fix(claude-code-v2): derive version from manifest + guard self-contained bundle

* feat(core): Claude transcript reader (normalized user/assistant text turns)

* fix(core): transcript reader null-safety + drop sidechain turns

* feat: live write-back on the Claude Stop hook (shared retain-hook runtime)

Extracts a testable buildRetain core (read transcript -> upsert under
conversation:<sessionId> via retainLiveSession) plus a thin runRetainHook
plumbing wrapper mirroring the existing runHook/buildHookOutput split, and
wires it up as a Claude Code Stop hook. Fail-open throughout: an empty
transcript is a no-op, and a retain failure is diagnosed but never thrown.

Exports diag() from core/hook.ts so retain-hook.ts can reuse the same
diagnostics helper instead of duplicating it.

* refactor(core): extract diag module + trim buildRetain params

- Move diag() out of hook.ts into a neutral src/core/diag.ts so retain-hook
  (and future lifecycle hooks like SessionStart) don't reach into a
  recall/reflect-specific module for a cross-cutting concern.
- Drop the unused cwd/cfg params from buildRetain — only harness, sessionId,
  transcriptPath, and client are read; cwd/cfg stay in runRetainHook where
  they're actually used (config load + deriveBankId).
- Clarify that retainSessions is opencode-plugin-only; the Stop hook always
  writes back unless disabled.

* feat(core): knowledge-page CRUD on HindsightClient (mental-models)

* fix(core): page methods throw on 404 + doc rationale

* feat: native TS MCP server for knowledge-page tools (bank-aligned)

Adds a native TypeScript MCP (stdio) server exposing the agent_knowledge_*
tools (get_current_bank, list_pages, get_page, create_page, update_page,
delete_page, recall) over MCP, wired into the claude-code-v2 wrapper.

Bank resolution goes through the same loadConfig + deriveBankId path the
hooks use (harness "claude-code"), so knowledge pages, recall, and retain
all land in one per-repo bank. This is a native TS server rather than
reusing the Python MCP because its bank derivation mismatches.

- src/core/knowledge-tools.ts: SDK-free tool specs (zod schemas), unit
  tested against a stub client (17 tests) — every handler is fail-closed
  to an isError:true result instead of throwing.
- src/mcp-server.ts: the only file importing @modelcontextprotocol/sdk.
- tsup.config.ts: new mcp-server entry, noExternal inlines the SDK + zod
  so dist/mcp-server.js stays a single self-contained file.
- claude-code-v2/.mcp.json + build.mjs: wires the bundle into the plugin;
  the self-contained-bundle guard passes for mcp-server.js unmodified
  (no exemption needed) since noExternal fully inlines its deps.

* fix(mcp): honor disabled flag + testable selectTools

- Export selectTools(cfg, client, bankId) from mcp-server.ts: pure,
  SDK-free, returns [] when cfg.disabled (mirrors the hooks' disabled
  check) so a disabled Hindsight exposes zero MCP tools instead of all 7.
  Confirmed at runtime: with disabled:true the server still connects but
  doesn't advertise a tools capability at all (tools/list -> Method not
  found), which is stronger than an empty list.
- Guard main() behind an argv[1]-vs-import.meta.url check so importing
  the module for tests doesn't start a real stdio server.
- Add src/mcp-server.test.ts covering selectTools for both the disabled
  and enabled cases.
- Reword the HINDSIGHT_MCP_PROJECT_CWD comment: nothing sets it today
  (the plugin doesn't cd), it's an escape hatch, not a launching-host
  contract.

* refactor(core): lazy-load opencode adapter so backfill bundles self-contained

* test(core): lock opencode no-runtime registry invariant + doc it

* feat(core): cold-repo detection + seed-consent state

* test(core): cover seed write-failure + guard non-object state

* feat(core): background seed mechanics + hindsight-seed control CLI

Adds hasGitHistory (git.ts), startBackgroundSeed + seedControl (seed.ts),
and the src/hindsight-seed.ts entrypoint the agent runs after the
SessionStart seed offer (Task 10b) to seed or decline a repo's bank.

* fix(core): handle async spawn error in startBackgroundSeed

spawn() failures (ENOENT/EACCES/fd exhaustion/sandboxed environments) often
arrive asynchronously as an 'error' event on the child, not a synchronous
throw. An unhandled 'error' event crashes the caller, so attach a no-op
handler alongside the existing try/catch. Also documents the Claude-Code-only
harness assumption in hindsight-seed.ts.

* feat: SessionStart auto-seed offer for cold repos (Claude wrapper wired)

* fix(core): shell-escape seed offer paths + drop orphaned isColdRepo

* docs(claude-code-v2): marketplace entry, full README, v1→v2 migration note

* fix(core): cap hook reflect timeout, align backfill+hook config resolution

- hook.ts: cap reflect's timeoutMs to HOOK_REFLECT_CAP_MS (8s) so it always
  resolves/aborts before Claude Code's 15s UserPromptSubmit kill window,
  guaranteeing the session cache write + recall injection complete instead
  of silently retrying reflect (and dropping recall) on every turn.
- backfill.ts: resolve config via loadConfig({harness, projectDir: REPO,
  path}) instead of the legacy string form, so project-local
  .hindsight/coding-agent.json layers in and the background auto-seed
  backfill targets the same bank recall/retain/MCP read from.
- hook.ts/retain-hook.ts: resolve the cwd fallback before loadConfig (not
  just at deriveBankId) so project-local config layers even when the
  hook event's cwd is missing.

* fix(claude-code-v2): dev-install must copy .mcp.json (MCP tools were missing)

* feat(core): deterministic SessionStart auto-seed + knowledge-page bank mission

The prior SessionStart design asked the agent to pose a y/n question then
run a seed command itself; live testing showed the model surfaces the
question and then ignores it, so nothing ever seeds. The hook now starts
the background seed itself on a cold git repo (tri-state: cold/warm/
unreachable) and always injects a short visible note plus a bank-mission
pointing the agent at the agent_knowledge_* tools.

* docs(claude-code-v2): update seed docs for deterministic auto-seed + knowledge mission

* feat(core): default seed to aggregated commit messages (one cheap doc) + Initiatives page; full-diff opt-in via --diffs

* docs(core): align backfill README + strategy log/comment with gitlog default

* feat(core): headless codebase-survey seed + agent_knowledge_ingest MCP tool

On a cold repo, the SessionStart hook now also spawns a detached headless
`claude` that samples the repo's structure and ingests its findings into
Hindsight via a new agent_knowledge_ingest MCP tool, alongside the existing
git-history backfill. Knowledge pages synthesize their content from bank
memories via source_query, so this is how the survey feeds them.

- knowledge-tools.ts: add agent_knowledge_ingest (title -> slug doc id,
  retain via the "chat" strategy, tagged source:upload).
- survey.ts: resolveClaudeBin + startCodebaseSurvey, mirroring seed.ts's
  fire-and-forget/never-throw spawn pattern.
- Anti-recursion: HINDSIGHT_DISABLE_HOOKS guard at the top of runHook,
  runRetainHook, and runSessionStartHook so the survey's own claude session
  can't re-trigger seeding/recall/retain; survey.ts sets it on the child.
- config.ts: codebaseSurvey (default true) + surveyModel (default "sonnet").
- session-start.ts: wire startSurvey into the cold-repo branch alongside
  startSeed; update the visible learning note.

* fix(core): sandbox headless survey (deny-list, no bypassPermissions) + spend cap + document strategy

* feat(core): default codebase-survey model to haiku (cheaper/faster; sonnet still configurable)

* feat(core): survey excludes CLAUDE.md + agent-instruction files from ingestion

* docs(coding-agents): v2 knowledge-pages design spec + implementation plan

* feat(core): add pageRefreshEveryTurns config (default 10)

* feat(core): knowledge-injection roster/preamble formatting

* feat(core): passive knowledge entity_labels tier vocabulary + configureBank wiring

* feat(core): tag-scope seeded pages, Initiatives folder, relatedPageId link source_query

* feat(core): captureInitiative — per-initiative page + relatedPageId marker

* feat(mcp): hindsight_* grounding tools + capture_initiative; remove raw page CRUD from agent

* feat(core): SessionStart injects page roster + guidance preamble

* feat(core): UserPromptSubmit hook-counted periodic page-roster refresh

* feat(core): rich markdown session write-back with tool calls + verbose session strategy

* chore: apply prettier line-wrapping to test files

* fix(claude-code): surface the seed note via user-visible systemMessage, keep preamble in additionalContext

* fix(survey): use renamed hindsight_ingest_document MCP tool (Task 6 rename regression)

* fix(core): preamble + refresh nudge the agent to call capture_initiative for major features

* fix(core): re-inject tool+capture reminder every cadence turn even with no pages (unconditional nudge)

* fix(core): inject when-to-call guide for the full hindsight_* tool suite, not just pages+capture

* feat(claude-code): cold-check-wins seeding — reseed a cleared bank on the live doc count, ignore stale seededAt

* fix(core): simplify capture_initiative instruction to one clear trigger (remove confusing OR-chains)

* fix(core): port proven v1 attribution preamble + surface memories block first so the header actually gets emitted

* fix(core): reflect every turn (configurable reflectEveryTurns, default 1) instead of once per session

* feat(core): per-turn injection is recall-only (drop reflect from the hook), recall token budget default 750

* feat(core): inject a user-feedback section above memories (capture-initiative + attribution-header preferences)

* fix(core): align user-feedback attribution bullet with the generous WHEN-IN-DOUBT-EMIT rule

* fix(core): sharpen capture_initiative trigger — call right after plan approval, before implementation

* feat(survey): raise default codebase-survey budget cap to $2 (0.5 was over-conservative)

* feat(codex): codex-v2 wrapper (SessionStart seed + per-turn recall + MCP); parametrize session-start/MCP harness

* feat(core): default bank template is harness-neutral coding-agent::{gitProject} (shared memory across agents)

* feat(core): default apiUrl is Hindsight Cloud (https://api.hindsight.vectorize.io); local is now an override

* feat(codex): Stop write-back — Codex rollout transcript reader + codex-stop-hook (full parity)

* fix(core): captureInitiative returns the server-assigned page id (not the slug) so read_knowledge_page + relatedPageId links resolve

* feat(coding-agents): upgrade opencode adapter to full v2 parity

Per-turn recall via chat.message + system.transform (750-tok budget), native hindsight_* tools registered directly through opencode's tool() (no MCP server), rich tool-aware write-back on by default, and cold-check auto-seed at plugin load — reusing the shared formatMemories / buildKnowledgePreamble / buildKnowledgeTools / buildSessionStartContext primitives so opencode matches Claude Code and Codex.

Adds transcript-opencode.ts (rich normalizer over the live message list). Adds a HINDSIGHT_DISABLE_HOOKS recursion guard to RuntimeCore (seed/recall/write-back/sync no-op; tools still register) for headless survey runs. Removes the now-dead reflect path (client.reflect, inject.ts/buildSystemInjection, reflectTimeoutMs) as the whole surface is recall-only. README rewritten to the recall/knowledge-page/seed/write-back v2 model.

* feat(coding-agents): harness-portable codebase survey (multi-agent headless)

The cold-repo survey no longer hardcodes headless `claude` — startCodebaseSurvey now runs under the current harness's own CLI (claude/codex/gemini/opencode), falling back to any available agent, so a Codex/Gemini/opencode user without claude installed still gets the survey (the git-log seed already ran regardless).

Per-agent read-only recipes: claude (-p + inline --mcp-config + --disallowedTools), codex (exec --sandbox read-only + inline -c MCP), gemini (-p --approval-mode plan --allowed-mcp-server-names hindsight --skip-trust), opencode (run --agent plan; tools from the loaded plugin under the HINDSIGHT_DISABLE_HOOKS guard). All spawned with HINDSIGHT_DISABLE_HOOKS=1. session-start threads the harness through to the survey.

* feat(gemini): add Gemini CLI v2 integration (gemini-v2)

Full v2 parity for Gemini CLI (>=0.52.0), which added a Claude-style hooks system (stdin/stdout JSON). Maps onto the shared HookSpec/runSessionStartHook/runRetainHook abstraction with Gemini's event names: BeforeAgent (per-turn recall -> hookSpecificOutput.additionalContext), SessionStart (seed), SessionEnd (write-back).

The one Gemini-specific piece is transcript-gemini.ts — a reader for the 0.52.0 chats/session-*.jsonl mutation-log (upsert-by-id, polymorphic content: user text arrays, assistant plain strings, tool results as user functionResponse parts; drops the synthetic session_context message + thoughts). Adds the gemini-v2 wrapper (build.mjs + dev-install.sh that merges hooks + mcpServers into ~/.gemini/settings.json). Validated: reader against a real transcript, and a live recall smoke test (recall_ok) end-to-end.

* style(coding-agents): prettier-format README config table

* fix(opencode): inject via lastInjection fallback (1.18.5 system.transform has no sessionId)

opencode 1.18.5 fires experimental.chat.system.transform with input {model} only — no sessionId — so RuntimeCore.getInjection(input.sessionID) looked up undefined and pushed nothing into the system prompt. Recall still ran (chat.message does pass sessionID) but the memory block + attribution preamble + knowledge-page guide never reached the model, so no visible header and no tool use.

getInjection now falls back to the most recent turn's block (lastInjection) when there's no session-keyed hit. The completion's system.transform fires right after that session's onPrompt, so lastInjection is this turn's block. Adds an inject_ok/inject_empty diag (matching recall_ok/seed_started) to confirm injection lands.

* fix(coding-agents): treat project-local config as untrusted (block apiUrl/apiToken/directoryBankMap from a repo)

A project-local .hindsight/coding-agent.json lives inside whatever repo the developer opens, so it is untrusted input. loadConfig previously merged it per-field over the user-global config, letting a repo override apiUrl while the user-global apiToken survived the merge — so a malicious repo could set only apiUrl and the client would send the user's real Bearer token plus every recall query (the prompt) and Stop write-back transcript to an attacker-controlled host, silently, just by opening the repo (verified end-to-end).

Fix: the project-local layer is now sanitized — apiUrl, apiToken, and directoryBankMap are stripped from it (top level + any harnesses.<name> section) with a one-line warning; the user-global config stays trusted and unrestricted, and a repo can still set its own per-repo bank (bankId/bankIdTemplate). Also skip re-applying the global file as a project layer when the upward findProjectConfig walk lands back on it (a repo under $HOME with no closer config), which would otherwise strip its own apiUrl and warn every session. Adds 4 regression tests.

* style(coding-agents): prettier-format config.ts

* feat(coding-agents): restore reflect as the memory path; per-turn injection from knowledge-page sections

One opinionated runtime path (no behavior config):
- reflect ONCE per session on the first prompt (agentic root-cause synthesis,
  benchmark-proven), cached and re-injected every turn — hook harnesses and the
  opencode runtime alike
- every turn: knowledge-page SECTIONS matched locally against the prompt
  (lexical section index, no server/LLM call) injected with provenance and a
  pointer to the full page — fast like recall, organized like reflect
- raw recall leaves the runtime path (still powers the hindsight_search_memory
  tool)

Session write-back: transcripts are now JSON turns matching the backfill chat
format, with each tool call compacted to a role:"action" turn naming the tool
and its primary target (no arguments, no outputs) — Claude, Codex, Gemini and
opencode readers.

Knowledge pages: no more entity_labels/tag taxonomy — pages are unscoped, each
page's source_query selects from the whole bank; survey, gitlog seed, write-back
and security hardening stay.

Spec: docs/superpowers/specs/2026-07-27-reflect-pages-runtime.md

* test(coding-agents): rewrite unit tests for reflect+pages runtime and JSON action transcripts

* test(coding-agents): live suite matches reflect_ok by content (pages_ok now follows it in the diag stream)

* docs(coding-agents): README + docs page describe the reflect+pages runtime (reflect once per session, local page-section injection per turn, JSON action write-back)

* feat(coding-agents): drop the backfill CLI — ingestion is automatic and background

- new deepen engine (dist/deepen.js, unpublished): idempotent, resumable —
  per-bank lock, dedup by document id; ingests missing conversations, the
  one-time gitlog seed, then progressively deepens recent history with
  per-commit full diffs (newest first, bounded batch per run); drains and
  creates knowledge pages last
- every session start now fires the engine (cold or warm); survey and the
  cold-seed note stay cold-only
- sync status is the new readiness contract: hindsight_sync_status agent tool
  + dist/status.js for harnesses (synced = gitlog seeded, pages present,
  extractions drained); activeOperations() filters terminal ops
- opencode write-back now upserts every turn (async) so a killed session
  loses at most the last turn
- repoNameOf resolves relative paths so document ids are path-spelling-proof
- hindsight-coding-backfill bin removed; benchmark/e2e run the engine
  directly and poll status

* polish(coding-agents): short, non-technical cold-seed message highlighting the bank id

* polish(coding-agents): cold-start banner — HINDSIGHT unicode wordmark + bank id line

* feat(coding-agents): timing diagnostics on by default

- session_start diag event on EVERY session (bank, cold/warm, pages, ms) —
  warm sessions previously logged nothing
- deepen engine: deepen_started/deepen_done/deepen_failed diag events with
  duration; child output now appended to ~/.hindsight/coding-agent-state/deepen.log
  (was stdio:ignore — undebuggable) and log lines timestamped
- retain_ok/retain_failed carry ms on both the Stop hook and the opencode
  per-turn upsert (which was fully silent)
- vitest config pins HINDSIGHT_DIAG_FILE to a tmp file so unit tests stop
  polluting the real diag log

* feat(coding-agents): show the Hindsight banner on every session start (cold: learning, warm: remembering)

* polish(coding-agents): session banner uses the API server's colored pixel-art logo (shared visual identity), wording line below

* polish(coding-agents): banner text before logo — the TUI's first-line prefix was displacing the logo's top row

* polish(coding-agents): banner logo re-rendered foreground-only — the TUI strips ANSI background colors, which deleted half the server logo's pixels

* feat(coding-agents): per-turn user-visible notice — every prompt shows what Hindsight delivered (reflect state + matched knowledge pages) via hook systemMessage; opencode logs the same line

* polish(coding-agents): per-turn notice shows the match query excerpt and the page titles it returned

* fix(pages-index): singularize plain-word tokens so plural prompts match singular headings ('components' -> 'Component map'); path-like tokens untouched

* polish(coding-agents): per-turn notice — gradient Hindsight wordmark, value-driven wording, no timings

* feat(coding-agents): interim always-inject knowledge stub + explicit Hindsight attribution

- selectSections: TEMPORARY stub returning the first section of up to 3
  distinct pages every turn regardless of prompt — guarantees injected data
  for testing source attribution; will be replaced by the server-side
  knowledge-base/search (local lexical index drops with it)
- both injection blocks now carry an ATTRIBUTION directive: when memory
  shapes the answer, the agent introduces it with '🧠 From Hindsight memory
  (<page>)' — and must never credit memory that did not contribute

* polish(coding-agents): gradient-word banner (logo dropped), lean per-turn notice, attribution directive front-loaded as a mandatory output format

* polish(coding-agents): reflect turn notice shows the assigned goal and a preview of what memory returned

* feat(coding-agents): page knowledge moves from auto-injection to an explicit tool

- new hindsight_search_knowledge_pages(query) tool (native on opencode, MCP on
  hook harnesses) — interim local selection, single swap point for the
  server-side knowledge-base/search; results carry the attribution requirement
- per-turn auto-injection of page sections removed: a trivial prompt ('yes')
  no longer displays phantom research; ordinary turns are silent
- per-turn notice only on the reflect turn (assigned goal + result preview);
  tool calls provide their own native visibility
- tool guide/roster advertises the search tool as the first stop

* feat(coding-agents): bind hindsight_search_knowledge_pages to the server-side hybrid knowledge-base search

- merge feat/knowledge-pages-okf underneath (GET /knowledge-base/search,
  BM25 + vector, RRF-fused; conflicts resolved in okf's favor for server/
  clients/UI, coding-agents docs entry preserved)
- client.searchKnowledgePages(query, limit) wraps the endpoint; the tool
  returns ranked {page, page_id, snippet, score} — verified end-to-end
  through the real MCP server against the live endpoint
- interim local selection removed from the tool path (pages-index remains
  only for the hook page cache pending full cleanup)

* refactor(coding-agents): drop pages-index — local section index deleted; hook/runtime keep only the id+title roster (content lives behind the server-side knowledge-base search)

* refactor(coding-agents): drop hindsight_search_memory (raw recall) — knowledge-page search is THE search surface; recall client method and formatter removed

* feat(coding-agents): hindsight_reflect tool — on-demand deep memory reasoning alongside the session-start reflect

* refactor(coding-agents): one 'conversation' retain strategy for all developer conversations

Backfilled decision chats and live session write-back were the same content
type (identical JSON action-transcript format) extracted two ways based only
on where they came from. Merged CHAT_MISSION + SESSION_MISSION into one
CONVERSATION_MISSION that scales facts to substance (short decision chat ->
1-2 facts, working session -> several; final-state-wins, verbatim literals,
rejected-alternative rule kept); the ≤2-fact CHAT_CUSTOM_INSTRUCTIONS
extractor is retired with it.

* feat(coding-agents): restore Chris's knowledge entity_labels tier

configureBank again sets entity_labels {knowledge: feature-work/decision/
convention/component/concept, tag:true} + entities_allow_free_form, so the
extractor routes durable facts with knowledge:<tier> tags the server-side
knowledge base can select on; capture_initiative markers regain the
knowledge:feature-work label. Pages themselves stay unscoped (the okf
knowledge base owns synthesis).

* feat(coding-agents): seeded pages tag-scoped again — page tags match the restored knowledge:<tier> entity labels (capture_initiative pages included)

* fix(coding-agents): reflect injection wrapped in <hindsight_memory> so write-back never re-ingests it; seed-state file (declined flag) removed — the live bank is the only state

* feat(coding-agents): gitIngest enum ('message' | 'full' | 'none') — one setting, one code path for seeding AND staying current

- deepen's idempotent git pass IS the sync: gitlog doc re-upserts when HEAD
  moves (gitlog-head:<sha> tag makes freshness a single tag query); in full
  mode new commits surface at the top of rev-list and the next run ingests
  them
- separate git-sync path deleted (sync.ts, runtime.syncGitOnce, gitSync
  config)

* feat(coding-agents): gitIngest defaults to 'message' (cheap by default; opt into depth); deepen gains --git-ingest override for harnesses

* feat(coding-agents): session banner shows git-sync state (condensed syncStatus): 'git in sync' / 'catching up on new commits' / 'syncing git history (n/target)'

* polish(coding-agents): two-line banner — value headline (tracking decisions/conventions/history) + bank/sync detail line

* refactor(coding-agents): ONE config file — project-local .hindsight/coding-agent.json layer removed entirely (with its sanitization machinery); per-repo routing stays via directoryBankMap

* docs(coding-agents): fix stale project-config reference in comment

* refactor(coding-agents): runtime scratch (deepen lock + engine log) moves to the OS temp dir — ~/.hindsight now holds ONLY the config file

* feat(coding-agents): cursor auto-ingestion parity — hosts without a SessionStart hook fire the deepen engine (+ cold survey) from the session's first prompt

* feat(coding-agents): leveled plugin logging — one plugin.log (debug/info/warn/error, config logLevel + HINDSIGHT_LOG_LEVEL/FILE overrides); diag events mirror at debug; deepen logs itself (separate deepen.log dropped); warn on reflect/retain failures

* feat(coding-agents): one-shot bank configuration via the server's template import — missions, strategies, entity labels, and the 5 seeded pages in a single idempotent POST /import (configureBank PUT+PATCH and createPages removed)

* feat(coding-agents): one-command installer — npx hindsight-coding-agents install|uninstall [harness...]

Detects the coding agents on the machine and merges each one's native
wiring (hooks + MCP: claude mcp add for Claude Code; hooks.json + append-
only config.toml sections for Codex; settings.json for Gemini; hooks.json
+ mcp.json for Cursor; plugin array for opencode). Idempotent by marker,
preserves foreign entries, backs up touched files as .hindsight-backup;
uninstall removes exactly ours. 27 unit tests over temp homes.

* fix(installer): refuse to install from an npx/dlx cache (wired paths would die on eviction); document global install + npm update -g as the update path

* ci(coding-agents): unit + typecheck + build job, and a live E2E job (real API server + real LLM) running the deepen->sync->reflect->injection path; prettier-format the package

* docs(blog): launch post draft — coding-agent memory results (marked draft: true)

* docs(blog): rewrite launch post as the narrative — from 'does memory even help?' through why-not-SWE-bench, the corrections dataset, benchmark-driven architecture decisions, to the final numbers

* docs(blog): position knowledge pages as a co-launch headline — living-documents framing, example page excerpt, platform-wide availability (dashboard editor, hybrid search API, bank templates), closing CTA

* docs(blog): restructure launch post payoff-first — contrarian RAG finding + cost in the lede, TL;DR box, narrated task with both runs, seeded-answers objection met head-on, data-locality/time-to-value/latency answers, Sonnet number promoted, backstory compressed to one section

* fix(coding-agents): deepen waits for server-side ops to settle (template-import page refreshes broke the synced contract); HINDSIGHT_CONFIG env override for the config path (containers/test harnesses; replaces the live test's dependency on the removed project-config layer)

* docs(blog): second-pass fixes — flagship example swapped to the arbitrary retry decision (RFC 4180 attack closed), reconstruction disclosed, 58% provenance clause, placebo backstory + grading block restored in numbers, RAG figure per-task, benchmark-site date

* docs(blog): align remaining CSV references with the retry flagship; TL;DR per-task figures

* docs(blog): flagship rebuilt on the real dataset task — the ERP export decision whose rejected alternative IS the textbook fix (='00042' formula form, minimal quoting, CRLF); dangling injection-verified reference restored; limitations cross-check attached to the correct row

* docs(blog): rewrite as the 0.9.0 launch post — five-beat narrative (question → dataset → auto-recall failure → reflect → knowledge pages from llm-wiki to self-healing) for Knowledge Pages + unified coding-agents plugin

* docs(blog): add the missing beat — shaping the dataset revealed decisions live in git, which the old plugins never ingested

* docs(blog): reframe reflect — very smart rather than slow; first message carries the session goal; on-demand reflect tool for session drift

* docs(blog): pages section addresses the 'back to files?' objection — pages as projected views over consolidated memory (contradiction resolution underneath), raw docs remain source of truth

* docs(blog): out-of-box row updated to n=3 (22/26/23 -> 0.72/task, -26%; cost -35%); matured row marked single-run

* fix(hooks): reflect block injected once per session (+ cadence refresh), not every turn — hook context persists in the transcript, so per-turn re-injection stacked duplicate blocks

* fix(coding-agents): wrapper bundles ship deepen.js, not the renamed backfill.js

The core build entry `backfill` was renamed to `deepen` (deepen engine +
status), but the three wrapper build.mjs bundleFiles lists still copied the
removed `backfill.js`, so every dev-install failed with ENOENT. Point them at
`deepen.js` (spawned by seed.ts at runtime) so the installers build again.

* feat(coding-agents): periodic re-survey — refresh structural pages every N commits

Structural knowledge pages are only generated on a cold repo, so an evolving
architecture drifts from what the survey captured. Add surveyRefreshCommits
(default 20; 0 = cold-seed only): at SessionStart, count commits reachable from
HEAD since the newest survey-baseline marker (branch-robust via
git.commitsSince) and re-run the headless survey once the threshold is crossed,
re-recording a baseline marker. Cold seed still records the first baseline.

* fix(coding-agents): per-turn hook timeout (30s) must exceed the 25s reflect cap

The once-per-session reflect is capped internally at HOOK_REFLECT_CAP_MS=25s,
but every harness killed the UserPromptSubmit/BeforeAgent hook at 15s — below
the cap. The host killed the hook mid-reflect before the cache write, so the
injection was discarded AND the reflect re-fired uncached on every turn
("UserPromptSubmit hook timed out after 15s" every prompt). Raise the hook
timeout to 30s (> cap) across claude/codex/gemini, bump Stop to 30 to match,
and document the cap-below-timeout invariant so it can't silently drift again.

* polish(coding-agents): attribution header is a bold blockquote callout, not flat text

The live directives all told the agent to credit memory with a plain inline
"From Hindsight memory (<page>):", which renders as flat text. Switch every
directive (session tool-guide, reflect injection, both MCP tool descriptions)
to a markdown blockquote header "> ... **From Hindsight memory (<page>)** — ..."
so it renders as a distinct callout, restoring the richer attribution look.

* fix(coding-agents): strip <hook_prompt> transport wrappers from retained transcripts (codex surfaces hook stdout/errors as user messages); session + backfill transcripts switch to JSONL (one turn per line — clean appends, chunker-atomic turns)

Note: benchmark numbers (n=3) were measured on the JSON-array format; JSONL
is extraction-equivalent by design but unvalidated by a sweep — gate before
quoting new numbers on this pipeline.

* fix(installer): write [features].hooks (codex_hooks deprecated in Codex >= 0.145); accept either flag as already-enabled

* fix(hooks): fire the ingestion engine from the FIRST prompt on every harness (lock-protected no-op when SessionStart already did) — safety net for sessions predating the install, whose banks otherwise never get pages; survey stays SessionStart-owned (ensureSeed hosts excepted)

* feat(status): expose survey observability — surveyBaseline (last surveyed HEAD, from Chris's survey-baseline markers) + surveyCommitsBehind in syncStatus/hindsight_sync_status

* test(status): expected shapes include the survey observability fields

* feat(survey): findings docs ARE the completion signal — surveyDocs (0-4) in syncStatus; a baseline without findings re-fires the survey at the next warm session start (crashed-survey retry)

* feat(config): banks.<bankId> overrides — per-repo opt-in/out applied AFTER bank resolution (disable a repo, tune gitIngest/retainSessions per bank) from the ONE config file; resolution fields ignored inside a bank section

* feat(config): bankAliases — remap resolved bank ids as the final resolution step (single hop, converging allowed); docs page brought fully current (env exceptions, gitIngest/logLevel/survey rows, banks overrides, aliases, resolution step 4)

* refactor(config): bank rename lives INSIDE banks.<id> as the  field (separate bankAliases tree removed) — one per-repo section for disable, behavior, and rename; applyBankConfig returns {cfg, bankId}

* docs(coding-agents): recipe — two repos sharing one bank (converge by resolved id via banks.<id>.bank, or by path prefix via directoryBankMap), with the id-vs-path rule of thumb

* rename(config): directoryBankMap -> mapPathToBank (direction-explicit; pre-0.9.0 breaking-rename window)

* feat(coding-agents): companion skill — hindsight-coding-agent SKILL.md shipped in the package and installed into ~/.claude/skills by the installer; explains storing/retrieving, full config (banks/mapPathToBank/gitIngest), install/update, and debugging

* docs(coding-agents): mention the companion skill in README + docs page

* feat(coding-agents): companion skill ships to ALL skills-capable hosts (claude/gemini/cursor native dirs, codex via ~/.agents/skills standard); retained sessions and ingested documents carry the harness as tag (harness:<name>) and metadata

* feat(skill): self-updating companion skill — every session start re-syncs installed copies with the packaged SKILL.md (presence-gated; npm update -g now updates the skill too, no re-install)

* fix(coding-agents): worktree-aware document ids (no more per-worktree gitlog duplicates) + deepen self-cleanup; issue/PR refs preserved verbatim and emitted as ENTITIES; calibrated reflect-injection wrapper; docs ported to the TRUE source (hindsight-docs/docs-integrations) that generates the skill copy

* docs(skill): explain the internal marker documents (survey-baseline:<sha> bare-sha content is deliberate — zero extracted facts; gitlog:<repo> seed doc)

* feat(survey): human-readable baseline markers under a zero-extraction marker strategy (live-verified: 0 facts) — start as researching, deepen lazily flips to completed once findings exist

* refactor(survey): one survey strategy with conditional rules replaces the separate marker strategy — status markers extract nothing, findings extract structural facts (both branches live-verified)

* fix(hooks): mid-session heal — zero knowledge pages in the roster cache fires the ingestion engine on any prompt (covers long-lived sessions predating the install; lock makes repeats free)

* feat(bank): ~ expansion in mapPathToBank; document the directory-blacklist recipe (map tree to one bank + disable it)

* feat(coding-agents): explicit correction protocol — when the agent verifies a memory is wrong/stale it ingests a 'Correction: <topic>' doc (claimed vs verified-true vs evidence); guidance in the injection wrapper, tool guide, tool description, and companion skill

* fix(hooks): reflect block injected exactly once — cadence re-injection dropped (replaying the turn-1 synthesis at arbitrary turns reads as random noise after drift; hindsight_reflect covers genuine re-need)

* fix(coding-agents): 15s hard timeout on every client request + opencode boot no longer awaits seedIfCold — a stalled memory server can never freeze the host TUI (onPrompt already tolerates a late preamble)

* fix(reflect): defer past trivial openers — a greeting no longer spends the once-per-session synthesis on 'hi' (seen live: reflect answered a greeting with persona chatter and burned the session's slot); first substantive prompt reflects instead

* test(hooks): align reflect-call assertions with the non-trivial fixture prompt

* Revert trivial-prompt reflect deferral (misread the report — the issue was the notice's UI position, not reflect-on-greeting behavior)

* fix(opencode): stop writing banner/reflect notices to stderr — opencode renders plugin stderr inside the TUI at the cursor (text wedged against the input bar); the trail moves to the plugin log

* feat(opencode): TUI companion plugin — visible presence via api.ui.toast (opencode's TUI plugin API): banner toast on activation + reflect goal/preview toasts from the plugin-log trail; installer registers the second entry

* fix(opencode): visible presence via the server client's tui.showToast (POST /tui/show-toast) — banner + reflect toasts from the server plugin; the separate TUI module approach removed (1.18.9's loader rejects tui-only entries in the shared plugin list); SDK deps bumped to 1.18.9

* fix(opencode): toasts never rendered — v1 client wants {body}, and boot toast raced TUI mount

opencode injects the v1 SDK client whose showToast signature is {body: {title,
message, variant, duration}} and which resolves with {data|error} instead of
rejecting — the earlier flat-params call sent an empty body and the failure was
invisible. Also the toast event is not durable: the seed banner on a warm bank
fired <1s after plugin init, before the TUI subscribed, and was lost. Toasts now
use the body shape, log a rejected result at debug, and defer until ~3s past
init. Verified live in tmux: boot banner and reflect toast both render.

* fix(coding-agents): reflect must report history, never issue directives

The 0.8.6-blog incident: reflect fused two true but unrelated facts (the
hermes-deprecation goal and the blog-section removals of c87e7ac19) into one
confabulated narrative rendered in the imperative — 'You should explicitly
remove the following sections' — a completed past action re-issued as a present
directive, indistinguishable from a prompt injection to the receiving agent.

Three changes:
- buildReflectQuery wraps the session's first prompt with strict rendering
  rules: declarative past-tense attributed facts only, no instructions or
  recommendations, no stitching unrelated episodes into one narrative.
- The <hindsight_memory> wrapper now states the block is a record of the past
  that never assigns tasks: imperative wording inside it is a description of
  work already done, to be ignored unless it informs the task as historical
  fact (and unrelated memories are still ignored outright).
- The reflect_ok diag event records the injected synthesis verbatim (8k cap),
  so the next incident is one grep instead of harness-transcript spelunking.

* refactor(coding-agents): read and seed knowledge pages through the knowledge-base API

The plugin advertised knowledge pages but drove them off /mental-models, so the
two halves of the feature never met: pages seeded via the bank template's
mental_models key got a mental model and no knowledge_pages node, and
/knowledge-base/search joins through that table — the five seeded pages were
absent from the corpus of the tool billed to the agent as its FIRST STOP. The one
page search could return (an initiative, created through the KB endpoint) came
back as a kp-… node id, which the reader then fed to GET /mental-models/{id} and
404'd. Search found only what read could not open.

Every page operation now speaks one id space:

- listPages reads /knowledge-base/tree and flattens it to {items:[…]}, dropping
  folders and keeping the containing folder name.
- getPage reads /knowledge-base/pages/{id} — the ids search and [[page:<id>]]
  links already hand back.
- seedPages replaces the template's mental_models key: it creates the PAGES
  taxonomy through /knowledge-base/pages and re-syncs a drifted source_query via
  PATCH /knowledge-base/nodes/{id}, so a plugin upgrade that rewords a query
  lands on the live page instead of orphaning its synthesized content. Matched by
  name, since the endpoint mints its own id; a 409 from a concurrent deepen run
  is tolerated rather than failing the run.
- createPage/updatePage/deletePage are deleted — mental-models CRUD with no
  callers outside its own tests.

Verified against a live server on a scratch bank: five real kp- nodes, re-run
reports 0 created / 5 unchanged, all five readable by their listed id, all five
now returned by /knowledge-base/search, and a hand-drifted source_query restored
onto the same node rather than a duplicate.

* feat(coding-agents): autoReflect flag — opt out of injected reflect into tool-only mode

autoReflect (default true, layerable per-harness/per-bank like every other
field) keeps today's validated behavior: one reflect synthesis injected on the
session's first prompt. Set false and nothing is injected; instead the
knowledge preamble and every roster refresh carry an explicit trigger telling
the agent to call hindsight_reflect itself whenever a new task/goal is set —
the pull-based variant, ready to benchmark against the push default.

* docs(blog): move the 0.9.0 launch post to its own PR

The draft now lives on blog/0-9-0-launch so this PR merges independently of
launch timing (hero image, publish date, and final voice pass pending there).

* fix(deepen): dead-holder locks are stale immediately, not after 30 minutes

The per-bank deepen lock only honored its TTL: a killed run (SIGKILL, crashed
harness) left its bank locked for LOCK_STALE_MS, and every subsequent deepen
exited 'another run holds the lock — nothing to do' against an empty bank.
The lock already records the holder's pid — probe it (kill -0); if the holder
is gone the lock is stale now. Found live: a killed benchmark ingestion left
four banks locked and the retry campaign polled empty banks to its deadline.

* feat(coding-agents): expand native harness support

* fix(reflect): table-shaped decisions must be reproduced verbatim, not summarized

Benchmark replay showed reflect compressing mapping/table policies into prose
('specific extensions map to specific types') and even asserting a lossy
generalization that matched a known-wrong fix — while rule-shaped policies
survive intact. The reflect query now demands complete verbatim enumeration of
mappings/sets/tables including carve-outs.

* fix(reflect): decisions outrank implementation-derived memory

Under heavy retrieval noise, reflect surfaced the git-ingested BUGGY module
source as 'the established implementation logic' while claiming no decision
records existed — presenting the bug under investigation as authority. The
rendering rules now state: report decisions and rationale, never the current
implementation (the reader has the code); when decision memory and
code-derived memory conflict, the decision wins; implementation-only matches
are not policy.

* feat(coding-agents): expand harness integrations

* Expand coding-agent integrations and legacy compatibility

* chore(coding-agents): fix the CI-only test failure and complete the release wiring

The `test-coding-agents` job failed on every run while passing locally: the
gitDiffTarget fixture committed into a temp repo without a git identity, which a
developer machine supplies from its global config and a CI runner does not
("empty ident name not allowed"). The identity is now passed per-command, the
way the harness E2E fixture already did it.

Release wiring, which was incomplete in three places that each fail at a
different point:

- scripts/release-integration.sh had no entry, so the release refuses to start.
- generate_changelog.py keeps its OWN integration list; the release script
  aborts and reverts at the changelog step when a name is missing there.
- The docs build cross-checks released tags (`integrations/<name>/vX.Y.Z`)
  against the SLUGS in integrations.json. The directory was the only
  integration carrying a `hindsight-` prefix, so the tag would have been
  `integrations/hindsight-coding-agents/...` against a `coding-agents` slug —
  green release, then a failing docs build. The directory is renamed to
  `coding-agents` so directory, integration name, tag and docs slug all agree,
  matching every other integration.

Also drops the claude-code-v2 / codex-v2 / gemini-v2 wrappers and the
hindsight-memory-v2 marketplace entry. Claude Code is fully served by
`hindsight-coding-agents install claude-code` — hooks, MCP and skill — so the
wrappers were a second copy of the same core with its own version to keep in
lockstep. The README rows that pointed at their dev-installers now name the
supported installer command instead.

* fix(coding-agents): make the installer actually re-point a moved package

Both bugs were exposed by the directory rename, which invalidated the absolute
paths every host config stores — the case `install` exists to repair.

- Grok wrote its block only when one was absent, so every later `install` was a
  silent no-op and the dead paths survived; the only repair was editing
  config.toml by hand. It now replaces the block, sharing one regex with
  uninstall.
- MARKER was the full package name, which identifies our entries for
  dedupe-on-reinstall and for uninstall. A repo checkout stopped containing it
  once the directory dropped its `hindsight-` prefix, so from a checkout
  re-installs would have accumulated duplicate hook entries and `uninstall`
  would have removed nothing. Narrowed to the substring both layouts share.

Regression tests cover a moved package being repointed (not appended past), the
marker matching npm and checkout paths, and a repeated checkout install leaving
one entry per event.

---------

Co-authored-by: Chris Latimer <[email protected]>
2026-07-31 22:15:21 +02:00
BenandClaude Opus 4.8 500a9e637a blog(evaluate-agent-memory): swap cover for a more contextual design (#3117)
The previous cover read as a context-free "10 things to look for." New cover
keeps the editorial template but leads with the subject ("Evaluating / agent
memory") and moves the listicle framing into a "THE 10-POINT CHECKLIST"
eyebrow, so the topic is clear at a glance.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-31 15:23:52 -04:00
BenandClaude Opus 4.8 486a8c41d6 blog: How to Actually Evaluate an Agent-Memory System (#3106)
* blog: How to Actually Evaluate an Agent-Memory System

A buyer's-guide / evaluation-framework post: the write→store→manage→read
lifecycle, the dimensions that matter (retrieval beyond similarity, entity
resolution, conflict updates, freshness, test-time learning), the production
dimensions most guides skip (data ownership, PII/secret security, cost,
observability, multi-tenant scoping), how to read LongMemEval, and a
copy-paste checklist. Editorial deep-dive cover.

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

* blog: retitle to "The 10 Things to Look For", swap LongMemEval→BEAM

Reviewer feedback:
- Drop "(2026)" from the title (blog posts don't use a year; that's the
  /articles convention).
- Retitle to the listicle framing "The 10 Things to Look For in an
  Agent-Memory System"; number the dimensions table and checklist 1–10 so the
  count is honest and consistent.
- Reframe the benchmark section around BEAM (10M-token tier) instead of
  LongMemEval, kept loose — the takeaway is "build your own eval on your
  domain." Remove LongMemEval-specific competitor scores.
- New editorial cover ("10 things / to look for", CHECKLIST tag).

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

* blog: drop unverified BEAM "next-best 40.6%" comparison

The agentmemorybenchmark.ai leaderboard only lists Hindsight at the 10M tier
(64.1%, verified). The 40.6% next-best figure isn't on that leaderboard, so
state only the verifiable number and leave the fuller comparison to the
beam-sota post.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-31 14:44:40 -04:00
Nicolò Boschi 1ce308f359 fix(api): report the real last-write time on banks and documents (#3109)
`/v1/default/banks` exposed only `last_document_at` (MAX of
`documents.created_at`), so a bank whose long-lived document keeps
receiving appends looked idle: ingestion time never moves, and
`banks.updated_at` only tracks name/mission edits. UIs reading either as
"last write" showed hours-old activity while memories were still landing.

Add `last_write_at` to the bank list — the newest of "a document was
(re-)retained" (`documents.updated_at`) and "a fact was stored"
(`memory_units.created_at`) — and order the list by it. `last_document_at`
keeps its ingestion-time meaning and is now documented as such. The
control-plane bank selector shows and sorts by the new field.

Same symptom on the documents list (reported in #2944): it was ordered by
`created_at DESC`, burying an actively-appended document behind every
document created after it. It is now ordered by `updated_at DESC`.

Fixes #2944
2026-07-31 18:11:24 +02:00
Nicolò Boschi d571199cd6 fix(memory-engine): compute update_memory_unit embeddings off the pooled connection (#3083)
Split update_memory_unit into a read/resolve/embed phase (no pooled connection held) and a short write transaction that re-reads the row, applies the precomputed embedding, and re-embeds in-txn only on a concurrent entity-set change; orphan entities from a failed edit are reclaimed by a forced graph-maintenance sweep. Preserves the pluggable store's begin_txn/decide_txn write-group.

Validated by CI (all three test-api shards incl. the live-PG curation suite pass). Follow-up to #3082. Refs #2434.
2026-07-31 18:09:13 +02:00
Nicolò Boschi baa923debc release(openclaw): v0.10.0 2026-07-31 18:08:02 +02:00
Nicolò Boschi d19f54c770 feat(openclaw): add preferObservations recall option (#2977) (#3108)
Adds a `preferObservations` plugin config flag (default false, backward
compatible). When enabled it forwards `prefer_observations: true` to the
recall API, which drops raw facts already consolidated into an observation
while keeping unconsolidated ones. Paired with a `recallTypes` that includes
raw types, this surfaces just-retained facts before consolidation catches up
(e.g. a /reset followed by "what did I just say?") without duplicating
already-consolidated content.

The flag requires the recall option added to the client in #2311, so bump
the plugin's @vectorize-io/hindsight-client dependency ^0.6.2 -> ^0.8.6.

Also fixes a stale integration test that #3066 missed when it flipped the
default recallInjectionPosition to 'user': the E2E suite only runs on
non-fork PRs, so #3066 (a fork PR) never exercised it and the assertion
kept expecting the old prependSystemContext placement. Updated it to expect
the new default prependContext, matching the sibling tests #3066 did update.

Closes #2977
2026-07-31 18:03:04 +02:00
Nicolò Boschi 24825200b0 fix(engine): don't hold pooled DB connections across embedder/LLM calls (#3082)
Several memory-engine paths held a pooled PostgreSQL connection checked out
for the entire duration of a slow external call (embedder/LLM). The pools are
already bounded and per-process, so this is saturation, not a leak: enough
concurrent operations park the pool on multi-second calls and everything else
blocks on acquire.

This covers the two paths that can be fixed without widening the read→write
window unsafely:

- update_mental_model: compute the embedding BEFORE acquiring a connection.
  The embedding text depends only on the incoming name/content, never on DB
  state, so it needs no connection.

- consolidation: _process_memory_batch and its executors/dedup helpers no
  longer receive a long-lived connection. Recall, the batch LLM call, every
  per-action embed, and dedup adjudication run with NO connection held; each
  helper self-acquires a short-lived connection only around its own SQL.
  Moving the slow calls off the connection widens the decision→write window,
  so the held-transaction serialization is replaced with explicit guards:
  each source-liveness check (FOR SHARE) is paired with its write in one short
  transaction, and dedup CREATE/UPDATE folds are RETURNING-gated and re-filter
  live sources inside the fold transaction (sources-before-observation lock
  order, matching the normal write paths) so a twin or source deleted during
  the now connection-free window can't drop a CREATE or fold a dead source id.
  A cheap non-locking preflight restores the pre-refactor "skip before embed
  when every source is already gone" short-circuit. The separate-store
  (non-SQL) branches and the Oracle-safe search_vector clause are preserved.

Deterministic no-DB tests pin the fold guards (RETURNING gate, live-source
filtering, created/skipped propagation) and the pre-embed short-circuits;
live-DB curation/invalidation/document-transfer tests are updated to the new
short-acquire signatures.

The update_memory_unit hold-across-embed path is intentionally left for a
follow-up: its two-phase re-lock/abort/retry has to be reconciled with the
pluggable memories store's cross-store transaction coordinator and validated
against a real database.

Refs vectorize-io/hindsight#2434
2026-07-31 17:14:59 +02:00
chethanuk cd40649393 feat(agent-sdk): let agent_knowledge_recall request source chunks (#2995)
The recall API already supports `include: {chunks}`, and the TypeScript
client already exposes it as `includeChunks`/`maxChunkTokens`, but the
agent tool forwarded only `{maxTokens, types}` — so an agent had no way
to reach the raw source text a fact was extracted from, which is exactly
what "what did we actually say" questions need.

Add optional `include_chunks` / `max_chunk_tokens` parameters and pass
them through. Both are additive and off by default, so recall responses
are unchanged unless an agent asks for chunks.

Fixes #2949
2026-07-31 17:12:27 +02:00
Sanderhoff-alt 79c9f4afeb fix(openclaw): default recall injection to user context (#3066)
Default recalled memories to user context so dynamic recall content no
longer invalidates the stable system prompt prefix on every turn.

Keep explicit prepend and append settings unchanged. Align the manifest,
docs, and tests with the cache-friendly default.

Closes #3061
2026-07-31 17:12:20 +02:00
Nicolò Boschi 3868c8d055 feat(control-plane): badge documents an in-flight retain op is updating (#3102)
* feat(control-plane): badge documents that a retain op is updating

Cross-check the documents table against pending/processing retain operations:
when an in-flight op targets a document already in the list, that document is
being rewritten — badge its row as 'Updating' (with a spinner) and poll until
the op finishes, then refresh its content.

Operations already expose document_id, but only file uploads populated it.
Populate result_metadata.document_id for single-document retains too (engine:
BatchRetainParent/ChildMetadata + submit_async_retain), so reprocesses and
single-document async retains surface their target. Multi-document batches leave
it unset (matched per single-document child) to avoid misattributing a row. No
API response-shape change, so no client/OpenAPI regen.

Adds 'documentUpdating' to all 10 locales and two engine regression tests.

* feat(control-plane): auto-detect updating documents without a reload

The badge previously only appeared once an in-flight op was already detected,
and detection only ran on load / bank-switch / upload-refresh — so from an idle
table you had to catch the moment or reload. Run the (light) operations check on
every poll tick while the view is open; keep the heavier document refresh gated
to when something is actually in flight. Also kick detection right after a
reprocess so its badge shows immediately.

* feat(control-plane): soften the updating badge + auto-refresh the docs table

Badge: drop the spinning icon for a gentle pulsing dot on a soft neutral (muted)
pill instead of the loud saturated-blue spinner.

Table: auto-refresh on a timer (every 8s idle, 4s while something is in flight)
so new/updated documents, counts, and badges appear without a manual reload —
not only while an op is already detected in flight.

* feat(control-plane): show last-refresh time next to the documents count

Stamp the wall-clock time on each list refresh and render it beside the count
('N total documents · Refreshed 14:41:32') so the auto-refresh is visible. Adds
'lastRefreshed' to all 10 locales.

* feat(control-plane): relative last-refresh time, baseline-aligned

Show the refresh time as a live relative label ('Refreshed 3 seconds ago') that
ticks every second — a self-contained component with its own 1s ticker so only
the label re-renders, localized via Intl.RelativeTimeFormat (no per-unit i18n
keys). Baseline-align the count row so the smaller label lines up with the
count text.
2026-07-31 15:48:22 +02:00
Nicolò Boschi c3b98998b7 feat(control-plane): a logo for every coding agent, not just five (#3101)
#3079 resolved `metadata.harness` to a logo, but registered only the five ids
hindsight-coding-agents emitted at the time. That integration (#2522) now ships
ten, so the majority of harnesses fell back to a raw `harness=<id>` metadata chip
— the exact thing the logo was introduced to replace.

Register the full emitted set, taken from both places that define an id:
`src/harness/hook-lifecycle.ts` (one HookSpec per hook-driven agent) and the
persistent-plugin entrypoints in `src/harness/registry.ts`, whose id is their
`createPluginEntry(...)` argument. New: `antigravity-cli`, `cline-cli`,
`copilot-cli`, `devin-cli`, `grok-build`, `kilo`.

Icons come from `hindsight-docs/static/img/icons/` where the docs site already
carries the brand (Cline, GitHub Copilot, Devin, Grok). It carries none for
Antigravity or Kilo, so those are the vendors' own marks; the registry comment
and CLAUDE.md now say that is allowed rather than implying the docs dir is the
only source.

`gemini` stays registered even though the integration replaced that harness with
`antigravity-cli` and nothing emits it any more: documents retained while it did
are still in people's banks and should keep their logo. The test that pins the
registry to the emitted set now carries that as an explicit RETIRED list, so a
speculative id still can't sneak in.

Monochrome dark-on-transparent marks (Cline, Copilot, Devin) get `dark:invert`.
Grok deliberately does not — it is a filled black tile with a white glyph, so it
reads on dark already and inverting would burn a white square into the row.
Verified all eleven at 16px against both themes.
2026-07-31 15:03:53 +02:00
BenandClaude Opus 4.8 a90f922376 blog(github-copilot): redate to 2026-07-30 (#3087)
Move the GitHub Copilot CLI persistent-memory post to today's date to align
with the 0.1.1 release and social launch. Renames the file and updates the
slug and date to 2026/07/30 (URL changes from /2026/07/29/... accordingly).

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-30 14:17:40 -04:00
BenandClaude Opus 4.8 53325898e6 fix(copilot-cli): parse Copilot CLI 1.0.76 native message transcript format (#3081)
* fix(copilot-cli): parse Copilot CLI 1.0.76 native message transcript format

Copilot CLI >= 1.0.76 writes session transcript events as dotted event
names — `{"type":"user.message","data":{"content":"..."}}` and
`assistant.message` — with the message text under `data.content`. The
transcript parser only recognized the older flat / SDK-envelope / role-nested
shapes, so it extracted zero messages from current Copilot transcripts.

Because the failure is silent (hooks load, fire, and exit 0; the parser just
returns an empty list and retain skips with "No messages in transcript"),
auto-retain quietly stopped persisting anything to the bank on newer Copilot
builds.

Teach `_parse_transcript_entry` to read the native `user.message` /
`assistant.message` envelope, using the clean `data.content` (not the sibling
`transformedContent`, which carries injected system reminders). Add a
regression test with a 1.0.76-shaped transcript asserting messages are
extracted and the reminder-laden transformedContent is ignored.

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

* release(copilot-cli): 0.1.1 — transcript parser fix + changelog

Bump hindsight-copilot-cli to 0.1.1 for the Copilot CLI 1.0.76 transcript
parser fix, and add a CHANGELOG.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-30 14:04:19 -04:00
Nicolò Boschi b651f43f22 fix(control-plane): knowledge-pages polish (#3080)
* fix(control-plane): stop next-intl parsing 'type:<x>' tag hint as an unclosed tag

The Tags hint used 'type:<x>', which next-intl reads as an unclosed rich-text
tag (INVALID_MESSAGE: UNCLOSED_TAG), crashing the create/edit page dialog.
Replace the angle-bracket placeholder with 'type:…' across all locales.

* refactor(control-plane): replace loading-emoji hourglasses with a shared Spinner

The UI used a spinning  emoji for loading in several places — inconsistent and
not accessible. Add a shared <Spinner> (wrapping lucide Loader2, sized xs–xl,
role=status) and use it for all 7 loading states: the large centered
'loading…' placeholders (documents / document / chunks / chunk-modal) and the
inline save-button spinners (tags / content / edit-memory).

* feat(control-plane): branded tumbling-logo spinner for loading states

Add a LogoSpinner that renders the Hindsight mark doing a looping 2D
'tumble' (crouch → hop + 360° flip → land squash), ported from the motion
lab. The hop is expressed as a % of the element so one keyframe scales
across sizes, and reduced-motion falls back to an opacity pulse.

Use it for the prominent centered loading states (documents list, document/
chunk modal); the compact lucide Spinner stays for tiny inline spots.

* feat(control-plane): make the tumbling logo the shared spinner everywhere

Fold LogoSpinner into the shared Spinner so every loading state renders the
Hindsight mark, and swap all remaining ad-hoc spinners (lucide Loader2, the
hand-rolled ring-<div> spinners, the sonner toast loader, and the two
RefreshCw-as-loader stand-ins) over to it — ~65 sites across 22 files.

Spinner now has two motions:
- variant "flip" (default): an in-place 360° flip + squash, safe inline and in
  buttons (only a tiny vertical bob, stays on the text baseline).
- variant "jump": the full tumble (crouch + hop + flip), for prominent centered
  loaders that have vertical room.

RefreshCw icons that spin only while refreshing are left as-is — a spinning
refresh arrow reads as "refreshing", which is distinct from a generic loader.

* fix(control-plane): show the loader (not empty state) on Documents, and finish the spinner sweep

Documents: the mount fetch is debounced, so the empty state ("No documents
found" / "0 documents") flashed before the spinner. Track a `loaded` flag and
gate the empty state + count on it, so the loader shows until the first fetch
resolves. Also swap the 📄/📊 emoji empty states for lucide icons.

Finish converting the loaders the first sweep missed (they used non-spinner
patterns, so grepping for Loader2/animate-spin didn't find them):
- pulsing Clock full-view loaders → jump Spinner (stats page, bank profile)
- emoji + text-only loaders (entities graph/list/linked-memories, llm-requests
  and audit-logs chart loaders) → Spinner
- emoji empty states (no chunks / no entities / no data) → lucide icons

* fix(control-plane): render the Documents loader on first paint (hard refresh)

On a hard refresh, bank-context starts currentBank=null and only resolves it
from the URL in an effect (after the first paint, before hydration + theme), so
the previous `!!currentBank` guard made the empty state win the very first
render — you'd see "No documents found" flash before anything else.

Gate the loader on `!loaded` alone. currentBank always resolves on a
/banks/[id] route and the fetch's finally flips `loaded` (even on an invalid
bank whose fetch errors), so it can't get stuck. Verified: the SSR HTML now
renders the loading Spinner, not the empty state.

* fix(control-plane): apply theme before first paint (no light flash on hard refresh)

ThemeProvider only reads the saved/system theme in a useEffect (after paint), so
a dark-mode user saw a light flash on every hard refresh. Add a tiny blocking
inline script as the first <body> child that sets the .dark class synchronously
before the content paints — the standard anti-FOUC pattern (what next-themes does
internally). <html> already has suppressHydrationWarning for the class mutation.
Logic mirrors lib/theme-context.tsx exactly (saved || system).

* feat(control-plane): add the tumbling mascot to the no-bank welcome screen

Loop the jump Spinner above 'Welcome to Hindsight' as a friendly greeting on
the dashboard shown when no bank is selected.

* feat(control-plane): spin the header logo on sidebar navigation

Clicking a sidebar item now gives the header Hindsight logo a one-shot 'spin
round' — a little playful nav feedback. The sidebar dispatches a
'hindsight:logo-spin' window event (decoupled, like DOCUMENTS_REFRESH_EVENT) and
the header listens and toggles a one-shot animation, cleared on animationEnd.

The logo is a wide lockup, so a 2D rotate would swing it vertical and overflow
the header — use a rotateY card-flip that stays within its footprint instead.

* feat(control-plane): spin only the logo mark (not the wordmark) on navigation

Split the header lockup into two pieces: the octopus mark (favicon.png, a
standalone image so it can rotate freely) and the 'Hindsight' wordmark (the
right slice of the full logo.png, shown via a cropped background). Their widths
sum to the full logo so they butt together seamlessly. Only the mark carries the
one-shot spin, so the wordmark stays put.

Because the mark alone is ~square, the spin is now a clean 2D rotate (reverted
the rotateY card-flip workaround that the wide full lockup had required).

* feat(control-plane): soften logo nav feedback from a spin to a subtle wiggle

A full 360 rotate was too much. Replace it with a small tilt that springs back
(logo-wiggle, ~450ms) on the mark — less impactful but still a bit of life.

* chore(control-plane): drop the dead logo-spin-once reduced-motion block

Leftover from the rename to logo-wiggle.

* style(control-plane): apply prettier formatting to the spinner-sweep files

Ran scripts/hooks/lint.sh — the earlier commits were eslint-clean but not
prettier-formatted, which tripped verify-generated-files.
2026-07-30 19:04:56 +02:00
Nicolò Boschi d105038323 fix(consolidation): trigger mental model refresh on resolved scope, not the tags column (#3053) (#3078)
The candidate query in `_trigger_mental_model_refreshes` gated on the mental
model's `tags` column, but a model's refresh scope is whatever
`_resolve_refresh_tag_filtering` resolves. Two configurations put untagged
memories in a *tagged* model's scope, and both were silently starved:

- `trigger.tags_match` "any"/"all" — non-strict matching ORs untagged rows in
- `trigger.tag_groups` — overrides the tags column entirely

Such a model reported stale forever and was only ever refreshed when some
unrelated tagged memory happened to be consolidated.

Both branches now prefilter on "can this model's scope reach untagged
memories?" instead of on the column. A tagged model left on the default
`all_strict` is still excluded — strict matching drops untagged rows, so an
untagged-only consolidation genuinely cannot make it stale, and refreshing it
would burn an LLM call to regenerate identical content. The final gate is
unchanged: `compute_mental_model_is_stale` evaluates the resolved scope, so
widening the prefilter cannot produce spurious refreshes.

The predicate uses `trigger ? 'tag_groups'`; the Oracle rewriter's key-exists
regex only matched unquoted columns, so it missed `"trigger"` (already quoted
as a reserved word by that point) and left the operator untranslated.
2026-07-30 17:10:32 +02:00
Sanderhoff-alt bc604ab91b fix(packaging): bundle licenses in Python distributions (#3067)
Stage the canonical repository license in each isolated Python build
context so wheels and source distributions include the MIT text.

Declare SPDX license metadata and verify every release artifact before
publishing to prevent repository firewalls from quarantining packages.

Closes #3054
2026-07-30 16:55:15 +02:00
Nicolò Boschi aa38790dd6 feat(control-plane): show the coding agent's logo on documents and memories (#3079)
Documents retained by hindsight-coding-agents carry the agent that wrote them
as `metadata.harness` plus a `harness:<id>` tag, but the UI rendered that as
just another `key=value` chip — indistinguishable from `session_id` while
scanning a column of near-identical `conversation:<uuid>` IDs.

Resolve the value to a logo instead:

- documents table: the mark trails the "Updated …" line (leading the ID shifted
  every row that had no harness), and the now-redundant `harness=` metadata
  chip is dropped — the `harness:<id>` tag stays, since clicking it filters
- document dialog: logo in the title, plus a Harness row
- memory dialog: a Harness card in the Document tab, next to the document's
  tags — memory units inherit the document's metadata at retain time, so no
  second lookup is needed. That tab also gained the document's metadata,
  rendered with the shared MetadataChip

The registry holds exactly the ids that integration emits (claude-code, codex,
cursor-cli, gemini, opencode; see its src/harness/hook-lifecycle.ts) and a test
asserts it stays in step — an id nothing writes is a logo nothing renders. An
unregistered harness is not an error: no logo, value still shown as metadata.

Monochrome marks are flagged so only they get `dark:invert`; multi-colour ones
are left alone.
2026-07-30 16:53:58 +02:00
Sanderhoff-alt f61d383acf feat(file-parser): support custom OCR headers (#3065)
Allow MarkItDown OCR clients to receive operator-defined default
headers for proxy routing and request tracing.

Wire the JSON environment setting through parser construction, document
the option, and cover configured and unset behavior.
2026-07-30 16:42:44 +02:00
Nicolò Boschi 29aea56281 docs(hermes): deprecate standalone hindsight-hermes plugin (#3057) (#3077)
The standalone hindsight-hermes pip plugin fails on current Hermes builds
with "Timeout context manager should be used inside a task" (an upstream
hermes-agent tool-dispatch bug). Hermes now ships a native Hindsight memory
provider, so mark the old plugin deprecated instead of chasing the upstream bug:

- Add a deprecation warning admonition pointing users to the native
  provider and the existing migration guide.
- Reword the Architecture section from plugin/entry-point language to the
  native provider.
- Drop the plugin-specific "Plugin not loading" entry-point troubleshooting.

Regenerated the hindsight-docs skill mirror.
2026-07-30 16:42:01 +02:00
Nicolò Boschi 218e6d34b1 feat(knowledge-base): client-managed knowledge pages, control-plane UI + hindsight fs CLI (#2455)
* feat(knowledge-base): self-curating knowledge base (OKF pages + folder missions)

Server-side knowledge base: a hierarchy of folders and pages over mental
models, projected to the Open Knowledge Format, with a mission-driven curator
that maintains pages automatically after each consolidation.

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

* feat(hindsight-fs): mirror a bank's mental models as a live local folder

Add @vectorize-io/hindsight-fs, a CLI under hindsight-tools/ that mirrors a
Hindsight bank's mental models as real markdown files (YAML frontmatter + body)
in a local directory, refreshed from the API on an interval. Once mounted,
ordinary shell tools (ls, cat, grep, find, ...) work against current memory.

- Pull-based sync engine: full list each tick, write changed/new/tampered
  files, skip unchanged (content-hashed), prune deleted models. Atomic writes;
  a transient API error never wipes the mirror.
- One-way mirror enforced two ways: files are read-only (0444) so agent edits
  fail with EACCES, plus a tamper-revert backstop that compares on-disk bytes
  and overwrites drift on the next pass. --writable opts out.
- Commands: mount/start/stop/restart/sync/status/list/logs/unmount. Background
  daemon via detached process + pidfile; per-mount config is remembered.
- status doubles as a healthcheck: --json report and a non-zero exit when the
  mount is dead/failed/stale (--stale-after overrides the threshold).
- Tests: unit (sync engine, frontmatter, health) + e2e that spawns the real
  CLI against a mock API and exercises real bash commands. 26 tests.

* refactor(hindsight-fs): mirror the knowledge-base tree, not mental models

Re-point hindsight-fs at the knowledge base so it projects a bank's folder/page
hierarchy as nested directories + .md files, instead of a flat list of mental
models.

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

Verified live against a bank's knowledge base: the `people` folder mirrors to
people/anna.md + people/marco.md with OKF frontmatter.

* refactor(knowledge-base): drop server-side curation + folder missions

The knowledge base is now purely client-managed (CRUD over folders/pages); the
server no longer auto-curates. Removes the folder curator entirely and the
folder `mission` concept, and leads the sidebar with Knowledge Base.

- Remove engine/knowledge_curator.py, the curate_folder task (handler + dispatch
  + submit_async_curate_folder / _bank_folders), the post-consolidation curation
  hook, and the folder-create / mission-update curation triggers.
- Remove folder `mission` and `last_curated_at` (columns + engine + API + UI);
  keep `managed` as a client-set flag. Migration a5b6 now adds `managed` only;
  the last_curated_at migration is dropped and the unique-index migration
  repointed. Single alembic head preserved.
- API: KnowledgeNode/CreateFolderRequest/UpdateNodeRequest lose `mission`;
  PATCH node handles name/parent_id only.
- Control plane: sidebar leads with Knowledge Base (before Memories); remove the
  mission field, edit-mission dialog, and mission display from the KB view.
- Delete the curator tests; regenerate OpenAPI + SDK clients.

* feat(knowledge-base): default pages to living-document trigger + 4096 tokens

Client-created pages had no server curation applying a trigger, so they fell back
to the plain mental-model default (no refresh, full mode, all fact types). Make a
knowledge page a living document by default: when the client omits `trigger`, use
observation-only + delta + exclude_mental_models + refresh_after_consolidation;
when it omits `max_tokens`, default to 4096 (vs the mental-model 2048). Clients
can still override either.

* feat(control-plane): bank Home dashboard, Knowledge tabs, Notion editor + memory Euler graph

A large control-plane pass on the knowledge base UX:

- Home dashboard (home-view): memory constellation + read-only knowledge-page
  TOC (reuses the Pages tree) + recent documents + the bank-profile "Memory store"
  card and "Memories by ingested time" chart (extracted as reusable exports).
  Fixed-height top row so the constellation fills and the side cards scroll.
- Sidebar: add Home (first); order Home → Memories → Knowledge.
- Knowledge view: Pages / Mental Models sub-tabs (Mental Models moved out of the
  Memories view). Pages tab is an Obsidian-style workspace — file-tree sidebar +
  inline editor with open-page tabs; the generation prompt is tucked behind a
  "How this page is derived" expander; a "Backed by N memories" line opens the
  backing model's based_on via the existing mental-model detail modal. First page
  auto-opens; deep-link via ?page=.
- Knowledge graph: reframed as an Euler/Venn of the source memories — nodes are
  based_on memories, one translucent circle per page (overlaps = shared memories),
  plus the memory graph's own edges. New Constellation venn mode (nodeGroupsFn /
  groupColorFn / groupLabelFn) drawing overlapping per-group circles + pill labels.
  Backend: knowledge_page_memory_graph endpoint (pages' based_on → memory nodes).
- Pages default to the living-document trigger (observation-only, delta, exclude
  mental models, auto-refresh) + 4096 max_tokens.
- Documents: metadata badges are expandable (show all keys, not just 3).
- Regenerated OpenAPI + docs-skill.

* feat(cli): port hindsight-fs into the Rust CLI as `hindsight fs`

Rewrite the standalone TypeScript hindsight-fs tool as a native subcommand
of hindsight-cli. Mirrors a bank's knowledge base (folders + pages) to a
local folder of markdown files, one-way (API -> disk) with read-only files
and drift-revert, plus a detached background refresh daemon.

- new src/commands/fs/ module (client, format, sync, state, daemon,
  health, config, paths) with unit tests for the pure logic
- subcommands: mount/start/stop/restart/sync/status/list/logs/unmount
- deps: reqwest blocking + sha2 + libc
- remove the TS package and its npm workspace entry

* feat(knowledge-base): drop the pages Graph view + polish the Pages UX

Client:
- Remove the Tree/Graph toggle and the whole graph branch (the toggle row
  was the dead band between the tabs and the content).
- Tree rows: full-width page name + compact status dot (was a pill that
  crushed the name to a few chars); float the hover actions so they no
  longer reserve width; widen the sidebar (w-64 -> w-72).
- Borderless workspace card; solid sticky editor-tab bar (was bg-muted/20,
  so scrolled body text bled through); tighter sub-tab spacing.
- Drop getKnowledgeBaseGraph + the /api/knowledge-base/graph proxy route
  and the now-unused i18n keys (viewTree/viewGraph/graphEmpty).

Server:
- Remove GET /knowledge-base/graph, KnowledgePageGraphResponse, the
  knowledge_page_memory_graph engine method + its helper, and the orphaned
  KnowledgeGraph/palette code in okf.py.
- Regenerate OpenAPI + SDK clients + docs-skill.

* feat(knowledge-base): add hybrid GET /knowledge-base/search (BM25 + vector)

Doc-level hybrid search over a bank's knowledge pages, fused with Reciprocal
Rank Fusion in a single query — no reranker, tuned for latency (~sub-100ms
query on top of the embed).

- engine.search_knowledge_pages: vector arm (mm.embedding ANN) + BM25 arm
  (mm.search_vector, the generated tsvector over page name+content) via
  websearch_to_tsquery('english'), RRF-fused (k=60) in SQL. BM25-only
  fallback when the query embedding is unavailable. Folders excluded.
- GET /v1/default/banks/{bank}/knowledge-base/search?q=&limit= with
  KnowledgePageSearchResult/Response models (id, name, mental_model_id,
  snippet, score, updated_at).
- tests: ranks the relevant page first, excludes folders, respects limit,
  requires q.
- Regenerate OpenAPI + Python/TS/Go clients + docs-skill (Rust client has
  no KB ops; the CLI's fs port uses raw reqwest there).

* feat(control-plane): wire knowledge-page hybrid search into the Pages sidebar

A debounced search box at the top of the Knowledge sidebar queries the new
/knowledge-base/search endpoint (BM25 + vector, RRF-fused). A non-empty query
swaps the folder tree for a ranked result list (name + snippet); clicking a hit
opens the page in a tab. Clear (×) restores the tree.

- new /api/knowledge-base/search proxy route
- client.searchKnowledgePages(bankId, q, limit) in lib/api.ts
- search box + results list in knowledge-base-view.tsx (reuses openPage)
- i18n: searchPlaceholder / clearSearch / searchEmpty + api.errors.knowledgeBase.search across all locales

* feat(control-plane): widen the Knowledge tree to 1/3 + show page tags inline

- file tree pane w-72 -> w-1/3 (content gets the other 2/3)
- render each page's tags as small chips under its timestamp in the tree

* feat(control-plane): let new pages set tags in the create dialog

The create-page dialog gains a comma-separated Tags field (wired to the
existing createKnowledgePage tags param); a type:<x> tag sets the page's
OKF type. i18n added across all locales.

* perf(control-plane): stop the home dashboard blocking on a 22MB graph

The memory constellation fetched limit=1000 (≈67k edges, ~22MB) and the whole
dashboard awaited it before painting. Now the light panels (stats/pages/docs)
render immediately and the constellation loads on its own (with a spinner) at a
200-node cap (~3.7MB). The full graph stays in the Memories view.

* feat(control-plane): add a 'View all' button to the home memory card

The memory-constellation card header gets a 'View all →' link to the Memories
(data) view, matching the Knowledge pages / Recent documents cards.

* feat(knowledge-base): edit a page's source query, tags, and token budget

PATCH /knowledge-base/nodes/{id} now also updates a page's options on its
backing mental model — source_query, tags, max_tokens — each applied only when
present. Changing source_query schedules an async refresh so the page rebuilds
against the new question.

- engine.update_knowledge_page + UpdateNodeRequest fields + endpoint wiring
- control plane: an "Edit" button on the open page opens a dialog (name /
  source query / tags); tags pre-fill from the raw tree tags so the type:<x>
  tag isn't dropped on save. i18n across all locales.
- tests: update options persist; empty PATCH is 400.
- regenerated OpenAPI + clients + docs-skill.

* refactor(knowledge-base): squash page migrations into one + drop OKF naming

- Fold the three knowledge_pages migrations (table / managed column / unique
  page-name index) into a single a9b8c7d6e5f4 migration.
- Rename api/okf.py -> api/page_markdown.py and replace the "OKF" / "Open
  Knowledge Format" terminology throughout (server, control plane, CLI, i18n)
  with plain "markdown" / "page" wording — pages just render to markdown.
- Add the knowledge-base endpoints to the CLI OpenAPI-coverage skip list (they
  live in the control plane UI / are mirrored by `hindsight fs`, no CLI cmd).
- Regenerate OpenAPI + clients + docs-skill.

* test(knowledge-base): rename test_okf -> test_page_markdown, drop dead graph tests

Follow-up to the okf.py rename + Graph-view removal: the test module still
imported the old `okf` module (breaking collection for the whole api test
suite) and still tested the removed tag-based knowledge_graph() builder.

* fix(transfer): classify knowledge_pages in export-bank (skip for now)

test_export_bank_covers_schema requires every BACKUP_TABLES entry to be
classified by export-bank. knowledge_pages is skipped: carrying its self-
referential parent_id tree needs a parents-first restore order that the generic
per-row _restore_rows doesn't provide (follow-up). Mental models are carried, so
the target can recreate the tree.
2026-07-30 16:02:41 +02:00
Nicolò Boschi 0f9dc55084 chore(deps): bump pg0-embedded to >=0.15.0 (#3073) 2026-07-30 14:44:42 +02:00
Nicolò Boschi b769045b64 feat(engine): pluggable memories storage backend (#2917)
Squashes the feat/pluggable-memories-provider work into one commit.

- Carve the `memory_units` + link slice out from behind raw SQL into a pluggable
  MemoriesExtension (engine/memories/), so a different engine (memlake) can own
  memories, links, retrieval, consolidation and curation while documents, chunks,
  banks and the entity registry stay in Postgres. The default PostgresMemories
  keeps everything exactly where it was; every call site routes through the store
  interface rather than branching on the implementation.
- Route recall (semantic+BM25+graph), scan/get, stats/counts, consolidation
  writes, curation edits, bank/document deletion, entity postings and graph reads
  through the store.
- Cross-store write-group transactions (begin/decide/mint/witness + recovery
  sweep) so a store that keeps memories elsewhere commits atomically with the
  Postgres side of a retain/consolidation/curation/delete.
- Documents & chunks: when the store owns a dedicated document store
  (owns_document_store), a document's bulky extracted text + chunk texts move out
  of Postgres into it (Postgres keeps thin rows: id, content_hash, chunk_index,
  tags); reads overlay the text from the store; the original file goes through a
  memlake FileStorage backend. All gated so the Postgres path is unchanged.
2026-07-30 14:41:03 +02:00
Nicolò Boschi 74cff93098 docs: give the documents table its own section in the 0.8.6 blog post (#3064)
* docs: cover the reworked documents table in the 0.8.6 blog post

* docs: give the documents table its own section in the 0.8.6 blog post

* docs: add the documents table screencast to the 0.8.6 blog post

* docs: replace the 0.8.6 blog GIFs with 30fps MP4 video
2026-07-30 11:50:17 +02:00
Nicolò Boschi 556c2c76c6 docs: add missing 0.8.6 changelog entries (Copilot CLI, retain deadlock) (#3063) 2026-07-30 09:56:45 +02:00
BenandClaude Opus 4.8 cc1eaeeeba blog: Give GitHub Copilot CLI a memory of your codebase (#3055)
* blog: Give GitHub Copilot CLI a memory of your codebase

Tutorial for hindsight-copilot-cli (published, v0.1.0): persistent memory
for GitHub Copilot CLI via hooks. Recall on sessionStart, retain on
agentStop/sessionEnd, subagents seeded with baseline project memory.
Grounded in the integration source; documents the once-per-session recall
limitation honestly.

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

* blog: co-brand Copilot cover with the official GitHub Copilot mark

Add the GitHub Copilot logo (top-left lockup + terminal title bar) so the
cover reads as a Copilot x Hindsight co-brand.

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

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-07-29 16:01:37 -04:00
Nicolò Boschi 6268654bf6 docs: changelog and blog post for v0.8.6 (#3051)
* docs: changelog and blog post for v0.8.6

* docs: focus 0.8.6 blog post on new features

* docs: lead 0.8.6 blog with the entity timeline

* docs: use entity timeline gif in 0.8.6 blog post

* docs: drop embedded-engine bullet from 0.8.6 blog post

* chore(docs-skill): sync openapi version to 0.8.6
2026-07-29 18:14:45 +02:00
Nicolò Boschi 08995e3013 Release v0.8.6
- Update version to 0.8.6 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.8
2026-07-29 18:09:56 +02:00
Nicolò Boschi 3a1841c421 feat(control-plane): add document tag filtering and unify facet chips (#3049)
Documents list
--------------
The documents table exposed only a document-ID search, even though
`GET /banks/{id}/documents` has supported `tags` + `tags_match` all along.
Wires those through the control-plane proxy and the client, and adds the
tag filter (with autocomplete and an any/all toggle) to the toolbar. Both
UI modes map to their `*_strict` variant, since the non-strict ones
deliberately include untagged documents and read as a broken filter.

Also fixes two things found while working on it:

- The filter toolbar lived inside the "has results" branch, so a filter
  matching nothing removed the only means of clearing it.
- Two effects both called `loadDocuments`, one debounced and one not, so
  every keystroke issued two requests.

The table is reworked around what it is actually scanned for: updated-at
moves under the document ID, created-at is dropped (it duplicated
updated-at for nearly every document, and both remain in the dialog),
tags and metadata share one column, and size / memory-units get fixed
right-aligned columns. `table-fixed` is what makes the declared widths
hold — under auto layout the long mono IDs sized the first column
themselves, so `truncate` never engaged.

Facet chips
-----------
Tags, entities and metadata were styled independently in each view: tags
were blue in the memory dialog, amber in the documents table and purple
in directives, while entities reused the tag blue so the two were
indistinguishable. The memories table had them inverted relative to the
dialog. `ui/facet-chip` is now the single place all three are rendered,
adopted across 13 components.

Colour does not carry the kind. Several revisions tried a saturated fill
per kind and each read as busy at chip size, duplicating what the `#` and
`key=` prefixes and the header legend already say. Kinds are separated by
form, over one quiet neutral treatment in three tonal variants; the brand
cyan is spent only on an active filter, as an outline.

Tokens live in globals.css so both themes resolve through the `.dark`
block. That also sidesteps a pre-existing issue: the app is on Tailwind
v4, where `dark:` compiles to a prefers-color-scheme media query, but
themes are switched with a `.dark` class and no `@custom-variant dark` is
declared — so literal `dark:` utilities only fire when the OS agrees.
That still affects ~115 utilities elsewhere and is left for a separate
change.

Layout
------
- Bank page used `min-h-screen`, so the page grew past the viewport and
  scrolled the header and sidebar away instead of scrolling `main`.
- TagFilterInput put applied chips inline with the input and the match
  toggle, which came apart past two or three tags. Chips now get their
  own row, and `flex-1 min-w-0` is baked in: without it the block's
  flex-basis was the max-content width of the chips row, so one long tag
  collapsed the sibling search input to nothing.
2026-07-29 16:58:47 +02:00
Nicolò Boschi 94f4adfbed fix(control-plane): bind the dark: variant to the .dark class (#3050)
Tailwind v4 changed the default meaning of `dark:`: it now compiles to
`@media (prefers-color-scheme: dark)` unless a `@custom-variant` says
otherwise. This app switches themes by toggling a `.dark` class on <html>
(lib/theme-context.tsx) and never declared one, so every literal `dark:`
utility was keyed to the OS setting rather than the in-app toggle — it
fired only when the two happened to agree, and never at all for a user on
a light OS.

The CSS-variable half of theming always worked, since the `.dark` block
overrides the tokens directly. That is what made this easy to miss:
backgrounds and foregrounds flipped correctly while ~145 literal `dark:`
utilities silently did nothing. They are almost entirely `dark:text-*-400`
lightened text plus `dark:prose-invert` for rendered markdown, i.e. exactly
the "make this readable on a dark surface" cases.

Measured on the LLM Requests table, the `success` badge
(`text-green-800 dark:text-green-300` on `bg-green-500/10`):

  before   1.17:1   — effectively invisible
  after   12.81:1

Every utility affected was audited: all 145 are dark-appropriate values
(lightened text, darker `bg-*-900/30` fills, stronger borders,
`prose-invert`). None were tuned to compensate for the variant being
inert, so switching it on corrects them rather than inverting anything.
Contrast was re-checked across the affected elements — on the bank config
page, all 25 elements carrying a `dark:` class pass at 7.02:1 or better.

Verified with a production build, since `@custom-variant` is parsed at
build time.
2026-07-29 16:58:04 +02:00
Nicolò Boschi 70c09adcf0 fix(clients): expose mental model query controls in python wrapper (#3047)
Mirror the TypeScript wrapper fix (#3042) on the Python side: the
hand-written Hindsight wrapper's list_mental_models forwarded only
tags, and get_mental_model forwarded no query at all, so Python
consumers silently inherited the server's detail=full default and
could not use tag-match or pagination — even though the generated
MentalModelsApi already supports all of them.

Forward tags_match/detail/limit/offset on list_mental_models and
detail on get_mental_model, and add mapping regression tests so a
refactor cannot restore the dropped controls.

Follow-up to #2975 / #3042 (Python-wrapper parity).
2026-07-29 16:00:47 +02:00
Sanderhoff-alt c5643fdf5c fix(reflect): apply exact empty scope to mental models (#3039)
Apply the exact tag filter even when the requested tag list is empty.
This keeps mental-model retrieval aligned with facts and observations.

Add a regression test that verifies the generated query selects only the
untagged global scope.
2026-07-29 15:56:59 +02:00
Nicolò Boschi 6a460d2c9a feat(reflect): add apply_all_directives to bypass directive tag scoping (#3031) (#3046)
* feat(reflect): add apply_all_directives to bypass directive tag scoping (#3031)

Directives are tag-scoped like memories: a reflect with no tags loads only
untagged directives, and tagged directives apply only when the request's tags
match. This is deliberate (isolation_mode), but it means an operator's
tag-organized directives silently never reach an untagged reflect — 45% of
standing rules in the deployment reported in #3031.

Add an opt-in `apply_all_directives` flag on the reflect request (default
false, preserving current behavior). When true, every active directive is
loaded regardless of tags, ignoring tag scope. Wired through the HTTP API,
both MCP reflect variants, and the engine.

Also correct the docs, which claimed directives are "always" enforced without
mentioning tag scoping.

Regenerated OpenAPI, clients (Go/Python/TS/Rust), and the docs skill mirror;
updated the control-plane reflect proxy + api.ts types.

* chore(cli): record apply_all_directives CLI-coverage exemption

The reflect field is intentionally not exposed as a CLI flag (available via
the REST API, SDKs, and control plane). Record the exemption so cli-coverage-check
passes, matching the existing tag_groups entry.
2026-07-29 15:39:50 +02:00
Evo 97b00ca75a fix(clients): expose mental model query controls (#3042) 2026-07-29 15:28:48 +02:00
Nicolò Boschi 9452ac29da feat(retain): report zero-fact documents at write time (#3040) (#3044)
* feat(retain): report zero-fact documents at write time (#3040)

A document whose fact extraction legitimately returns zero facts is stored
but unreachable: only memory_units carry embeddings, so recall and reflect
cannot reach a document that owns none. The retain still succeeds, the
operation reports completed, and nothing in the response, the webhook or
the metrics says the document produced no memories — the operator has no
way to know it needs a reprocess. FAIL_ON_EXTRACTION_ERRORS (#2721) cannot
help by construction: there is no error to fail on.

#2861 made retain.completed fire for zero-fact batches, but the payload is
byte-identical to a successful one, so it still carries no signal.

Add the count to all three write-time surfaces:

- retain.completed gains data.memory_unit_count, filled inside the outbox
  callback on the retain's own connection so units written by the enclosing
  transaction are visible.
- The synchronous retain response gains memory_units_created.
- New counter hindsight.retain.documents.total{outcome=facts|no_facts},
  emitted per document at both extraction exits.

The webhook and the metric report the document's total *after* the retain,
not what the call created: the delta path skips unchanged chunks, so an
idempotent re-retain creates zero units while the document keeps every
memory it had. Reporting units created would raise a false alarm on every
re-submit. The count query only runs when the call created nothing, which
is the path where no work was done anyway.

Docs: how a retain mission trades away retrieval of the raw source, the
three signals, the non-determinism caveat, and reprocess as the way back.

* fix(retain): drop memory_units_created from the retain response

The synchronous response field reported units created by that call, which is
a different number from the one the webhook and the metric report (the
document's total after the retain) and only ever populated on the sync path.
The async path is the one that matters, and it is already covered by
retain.completed carrying data.memory_unit_count.

Removing it also takes the API surface back to identical with main — the
webhook payload is now the only public shape change — so the regenerated
Python/TypeScript/Go clients and the OpenAPI spec carry no delta.

Also renames the metric's parameter to memory_unit_count to match what it is
actually handed: the document total, not units created.
2026-07-29 15:04:58 +02:00
Nicolò Boschi 40d2b7f6b8 fix(graph): serialize graph-maintenance queue enqueue against worker drain (#3034) (#3045)
The graph_maintenance_queue used a lock-free duplicate-suppression enqueue
(`ON CONFLICT DO NOTHING` on PG, `IGNORE_ROW_ON_DUPKEY_INDEX` on Oracle). Neither
locks the existing row, so a mutation re-enqueueing an already-queued unit could
not serialize against a worker that concurrently claimed (deleted) that row and
processed the unit's pre-mutation state. The re-enqueue signal was silently lost
and the unit's derived temporal/semantic links were left stale with an empty queue.

Fix (issue Option 1 — schema-free):
- PG enqueue: DO NOTHING -> DO UPDATE SET enqueued_at = <table>.enqueued_at, a
  no-op update whose purpose is to take the existing row's lock.
- PG claim: ordered-lock CTE — choose oldest by enqueued_at, then lock FOR UPDATE
  in (bank_id, unit_id) order (same idiom as prune_stale_cooccurrences' #2529 lock),
  matching the enqueue's sorted lock order so producer and worker cannot cycle.
- Oracle enqueue: MERGE (WHEN MATCHED locks the row) replacing the lock-free hint;
  claim deletes claimed keys in sorted unit_id order.
- Worker Pass 1: each claim+relink batch runs inside retry_with_backoff (already
  ORA-00060 / DeadlockDetectedError-aware) as a backstop; _BatchOutcome dataclass
  folds counters into JobResult only after commit to avoid double-counting on retry.

Adds tests/test_graph_maintenance_queue_race.py covering both interleavings, batch
selection, and concurrent no-deadlock, driven against the real Postgres test DB.
2026-07-29 14:56:35 +02:00
Nicolò Boschi b1a0ef5f7d feat(config): add per-operation reasoning_effort override (#2998) (#3043)
reasoning_effort was the only LLM request setting without a per-operation
override: retain, reflect and consolidation all read the single global
HINDSIGHT_API_LLM_REASONING_EFFORT. When one operation requires a specific
value (e.g. reflect needs "none" for OpenAI reasoning models that reject
function tools otherwise), that value is forced onto the others, silently
degrading their generation quality.

Add REASONING_EFFORT to the existing per-operation set, following the
established fallback pattern:

  HINDSIGHT_API_RETAIN_LLM_REASONING_EFFORT
  HINDSIGHT_API_REFLECT_LLM_REASONING_EFFORT
  HINDSIGHT_API_CONSOLIDATION_LLM_REASONING_EFFORT

Each falls back to HINDSIGHT_API_LLM_REASONING_EFFORT when unset.
2026-07-29 14:52:34 +02:00
Derek Bouius 4f4c2988e9 fix(oracle): guard consolidator search_vector to_tsvector for Oracle (#3021)
The consolidator emitted `search_vector = to_tsvector('...'::regconfig, ...)` gated only on `text_search_extension == "native"` — dialect-blind, so the PostgreSQL-only expression reached Oracle and the `::regconfig` cast became an unbound `:REGCONFIG` placeholder (DPY-4010). Consolidation failed on every run against Oracle.

The three UPDATE sites now route through a dialect-aware `_native_search_vector_update()` helper and the INSERT branch is guarded on `not _is_oracle()`, falling through to the no-`search_vector` path. Oracle loses nothing: `memory_units.search_vector` is a vestigial CLOB there that no Oracle code populates, and keyword search runs off `idx_mu_content_text` (CTXSYS.CONTEXT on `memory_units(text)`, SYNC ON COMMIT). All PostgreSQL paths are unchanged.

Verified in `test-typescript-client-oracle`: 200 DPY-4010 errors and 50 `Task execution failed: consolidation` on the baseline, zero here, with consolidation completing normally.
2026-07-29 11:20:23 +02:00
Sanderhoff-altandNicolò Boschi feac397324 chore(repo): remove unused code (#3007)
* chore(api): remove unused code

Remove confirmed unreferenced helpers from the API and engine.

Delete tests only where they cover superseded internal paths. Keep
active test helpers and public memory operations unchanged.

* chore(cli): remove unused code

Remove dead CLI configuration, client, and output helpers.

Drop the parser implementation and tests used only by the retired
output path.

* chore(control-plane): remove unused code

Remove unused ControlPlaneClient methods and unreachable directive
detail state from the think view.

* chore(dev): remove unused code

Remove unreferenced benchmark and repository maintenance helpers.

* chore(embed): remove unused code

Remove the unused daemon port lookup helper while preserving current
profile-based daemon discovery.

* chore(integrations): remove unused code

Remove unreferenced helpers across supported integrations.

Drop tests only for retired internal paths and retain active test and
lifecycle infrastructure.

* test(consolidation): port prompt regression tests to split builders

The dead-code cleanup removed build_batch_consolidation_prompt and its tests,
but those tests guarded behaviors that are still live in the current
build_consolidation_system_prompt / build_consolidation_input path:

- brace-safety of a mission / capacity note containing literal { } (a lone
  brace would raise KeyError in the internal str.format() and crash
  consolidation)
- output-language directive injection into the cached system prompt
- the built-in default mission when none is supplied

Re-add these as regression tests against the current builders instead of
dropping the coverage. Also fix a stale comment referencing the removed
utils.extract_facts module.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-29 11:18:30 +02:00
Nick OldandNicolò Boschi b8e1524a17 fix(tracing): serialize unvalidated provider responses (#3033)
* fix(tracing): serialize unvalidated provider responses

* test(claude-code): lock in best-effort span recording on recorder failure

Add a regression test asserting that when the span recorder itself raises,
the Claude Code provider still returns its result (the best-effort contract
restored in #3025). Also apply ruff import sorting to the test module.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-29 10:47:53 +02:00
Nicolò Boschi 979999651a docs+config(worker): rename per-type WORKER_*_MAX_SLOTS to *_RESERVED_SLOTS (#2963) (#3016)
* docs+config(worker): rename per-type WORKER_*_MAX_SLOTS to *_RESERVED_SLOTS (#2963)

The per-operation `HINDSIGHT_API_WORKER_<TYPE>_MAX_SLOTS` env vars set a
reservation *floor* (a guaranteed minimum), not a ceiling — despite the name a
type overflows the shared pool up to WORKER_MAX_SLOTS. The reporter hit exactly
this: consolidation ran 6-concurrent with "MAX_SLOTS=1".

Rename them to `<TYPE>_RESERVED_SLOTS`, which says what they do. The old
`<TYPE>_MAX_SLOTS` stays as a deprecated alias that logs a warning (setting both
is an error), so no existing deployment changes behavior. Defaults unchanged
(consolidation reserved=2).

Docs (current + version-0.8) updated to state plainly that a reservation is a
floor, not a cap — a type's real ceiling is WORKER_MAX_SLOTS. Tests cover the
new env var, the deprecated-alias mapping + warning, and the both-set error.

This is the issue's "part 1" — the cheap, high-value half. A genuine per-type
concurrency ceiling is a separate follow-up if operators need one.

* chore(docs-skill): regenerate for WORKER_*_RESERVED_SLOTS rename
2026-07-29 10:37:43 +02:00
Evo 91160f3bab fix(tei): retry HTTP 429 backpressure for reranking and embeddings (#3001)
TEI >=1.9 returns 429 as normal overload backpressure (fail-fast permit
acquisition, one permit per text), but the TEI reranker and embeddings clients only
retried 5xx, so a single 429 failed the whole recall and aborted the surrounding
consolidation round.

- Retry 429 alongside 5xx in both TEI clients, honoring Retry-After (numeric and
  HTTP-date). Other 4xx still fail fast and the retry budget is unchanged.
- Spread retries with equal jitter. TEI overload is self-synchronising, so a narrow
  jitter window left concurrent callers retrying in lockstep and re-colliding on the
  same exhausted permit pool.
- Cap a single backoff at 5s rather than the client request timeout. The reranker holds
  its concurrency semaphore across the sleep, so a large server-supplied Retry-After
  would otherwise stall every queued rerank.
- Document the TEI permit-pool sizing invariant in the reranker configuration docs.

Fixes #2991
2026-07-29 10:10:33 +02:00
Ben 2dc40f3bc7 blog: How to move your agent's memory off a vector database (#3022)
* blog: How to move your agent's memory off a vector database

Practical migration guide from Pinecone/Chroma to Hindsight: export the
text (not the vectors), map namespaces to banks, batch-retain, verify with
recall/reflect. Complements the existing "case against vector DBs" post
with the how-to. Grounded in the public SDK (create_bank, batch retain).
2026-07-28 14:22:32 -04:00
Ben ff7a087a37 release(copilot-cli): v0.1.0 2026-07-28 09:55:30 -04:00
Scott Guymer 6500944c74 feat(copilot-cli): add GitHub Copilot CLI hooks integration (#2742)
* feat(copilot-cli): add GitHub Copilot CLI hooks integration

Add hindsight-integrations/copilot-cli/, giving GitHub Copilot CLI
persistent long-term memory via Hindsight hooks (see docs.github.com/en/
copilot/how-tos/copilot-cli/customize-copilot/use-hooks). Modeled on the
existing cursor-cli integration.

Hooks:
- sessionStart: recall using initialPrompt (or a cwd-derived fallback
  query), injects additionalContext
- subagentStart: recall for every subagent Copilot CLI spawns (explore,
  task, research, code-review, rubber-duck, security-review, and custom
  agents, not the built-in general-purpose agent, which never fires
  this hook). Subagent payloads carry no per-invocation task text, so
  this always uses the fallback query.
- agentStop: reads the transcript, retains to Hindsight on a configurable
  turn cadence, caches the transcript path for sessionEnd
- sessionEnd: forces a final retain using the transcript path cached from
  the last agentStop, since sessionEnd's own payload has no transcript
  path field

Install via pip install hindsight-copilot-cli, then hindsight-copilot-cli
install (user scope, writes ~/.copilot/hooks/hindsight-copilot-cli.json)
or --scope repo for a team-shared .github/hooks/ registration. Zero
runtime dependencies, hook scripts are pure stdlib Python.

Also wires up CI (test-copilot-cli-integration job), release-integration.sh
and generate_changelog.py registration, and docs gallery/sidebar entry.

Closes #1588

* fix(copilot-cli): regen skill mirror, drop unreleased changelog link

- Run generate-docs-skill.sh to add the missing skill mirror for the
  new copilot-cli doc page (verify-generated-files was failing on the
  untracked references/sdks/integrations/copilot-cli.md).
- Remove the [View Changelog] link, which pointed at
  /changelog/integrations/copilot-cli — a page the release script only
  creates on first release, so it was a broken link failing build-docs.
2026-07-28 09:52:59 -04:00
Nicolò Boschi 8133c5ab7e fix(worker): stop wedged retains from holding worker slots forever (#3020)
* fix(worker): stop wedged retains from holding worker slots forever (#3002)

A retain task that blocks indefinitely held its worker slot until the
process restarted. The operation stayed 'processing' — which the API
refuses to either retry or cancel — so once every slot was held the
worker stopped claiming retains and the backlog grew without bound.

Five changes, outermost first:

* HINDSIGHT_API_RETAIN_WALL_TIMEOUT (default 1h, 0 disables) bounds one
  retain task in the poller, mirroring REFLECT_WALL_TIMEOUT. The
  existing timeouts each bound one LLM call, query or acquire; none
  bounded the task. On expiry the executor is cancelled and the
  operation is marked 'failed', so it is retryable. asyncio.timeout()
  (not wait_for) so an inner TimeoutError isn't misreported as a wedge.

* The streaming retain pipeline now cancels both halves explicitly.
  Plain gather() propagated the consumer's exception but left the
  producer and every extraction task under it running; they parked
  forever on chunk_queue.put() into a queue nobody drained, pinning
  chunk payloads and still spending LLM permits on a failed operation.

* The LLM stage breadcrumb says '.queued' until the concurrency permits
  are held. It was stamped before the acquire, so a call waiting on a
  saturated semaphore was indistinguishable from one the provider was
  running — the label sent the reporting operator after Bedrock for
  tasks that had never reached Bedrock. Providers now stamp attempt 1
  too, so a retry ladder is visible from the first attempt.

* bulk_insert_entities orders by LOWER(name), making the database's
  collation the single arbiter of insert order for all writers. The
  caller already sorted by Python's str.lower(), which agrees with the
  conflict target for ASCII but not every locale.

* HINDSIGHT_API_DB_ACQUIRE_TIMEOUT now bounds the wait it names. It was
  only passed to create_pool(timeout=...), a connect kwarg; Pool.acquire()
  kept asyncpg's default of waiting forever, so pool exhaustion never
  surfaced as an error.

* docs: regenerate hindsight-docs skill reference for RETAIN_WALL_TIMEOUT
2026-07-28 14:40:42 +02:00
Merlin_r68 9a1ba951fa fix(llm): send reasoning_effort on the tool path, matching call() (#2983)
`OpenAICompatibleLLM` builds request params in two places. `call()` sets
`reasoning_effort` for reasoning models; `call_with_tools()` built its own
`call_params` and never did.

Omitting it is not a neutral default. Measured against the OpenAI API for
gpt-5.6-terra with function tools:

    reasoning_effort="low"   -> HTTP 400
    reasoning_effort absent  -> HTTP 400
    reasoning_effort="none"  -> succeeds

    "Function tools with reasoning_effort are not supported for
     gpt-5.6-terra in /v1/chat/completions. To use function tools, use
     /v1/responses or set reasoning_effort to 'none'."

So `HINDSIGHT_API_LLM_REASONING_EFFORT=none` could not fix it — that setting
only ever reached `call()`. Reflect is a tool-calling search loop, so every
tool call 400'd, retried, and fell back to a tool-less completion. The
fallback still returned content and still stamped `last_refreshed_at` and
cleared `is_stale`, so mental models looked refreshed while never having
searched memory. The only outward signal was input-token volume: ~800 per
call degraded, versus 2.4k-8.2k healthy.

The fix mirrors `call()` rather than gating by provider: `call()` already
sends this parameter to the same provider/model pairs under the same
capability check, so gating the tool path by provider would replace one
asymmetry with another. A parameterized test pins that contract.

Not addressed here, to keep the change reviewable — `call_with_tools()` also
diverges from `call()` by applying temperature unconditionally (reasoning
models generally reject it) and by omitting groq's `service_tier` and
`include_reasoning`. Neither has a reproduction; both deserve their own change.

Verified: 7 new tests; deleting the hunk fails 6 of them; 161 provider tests
pass. Live end-to-end, reflect went from 20 errors and an 806-token fallback
to zero errors and 2.4k-7.5k-token real searches, refreshing 5 mental models
in 91s.
2026-07-28 14:31:29 +02:00
Sanderhoff-alt 20caf8aa5c refactor(retain): require explicit semantic link thresholds (#3004)
Require semantic-link thresholds to be passed explicitly to the
low-level ANN, within-batch, and batch-creation helpers.

Make the streaming final-ANN threshold keyword-only to prevent
positional argument mistakes, and rename the forwarding test to match
what it verifies.
2026-07-28 14:24:49 +02:00
Nicolò Boschi ac4df7eb8e fix(operations): re-runnable batch_retain parents (retry re-queues children) (#3018)
#2985 added a guard that rejected retry for every payload-null batch_retain
parent. But `retain --async` ALWAYS returns such a parent (submit_async_retain
creates a payload-less aggregator, even for a single item), so that guard made
async-retain operations un-retryable to users and 409'd the operations.sh doc
example — turning test-doc-examples(cli) red on main.

Make retrying a batch_retain parent re-run the batch's outstanding work instead
of rejecting it:
- re-queue the parent's failed/cancelled children to 'pending';
- revive the parent to 'pending' so it re-aggregates, but ONLY when at least one
  non-completed child remains to drive the reconcile — otherwise it would strand
  'pending' with nothing to promote it (the exact #2985 bug);
- leave pending/processing children untouched: a live worker owns a 'processing'
  child and resetting it would let a second worker race it on the same
  document_id (#1795);
- if there is nothing retryable (no children, or all completed), keep the 409 and
  point the caller at resubmit + delete.

This restores the natural "retry my async retain" UX and fixes the doc example
with no change to operations.sh.

Tests (deterministic, direct async_operations rows):
- failed child -> re-queued + parent revived;
- processing child -> untouched, parent revived;
- all children completed -> 409, parent NOT revived (no re-strand).
Updated test_retry_rejects_batch_retain_parent's docstring: it now covers the
childless case specifically.
2026-07-28 14:20:23 +02:00
Nicolò Boschi af196287e4 fix(transfer): preserve consolidation lifecycle on whole-bank import (#2965) (#3017)
Whole-bank export/import dropped each fact's consolidation lifecycle
(created_at, consolidated_at, consolidation_failed_at). Import rebuilt
consolidation state only from surviving observation lineage, so facts that
were consolidated (or failed) in the source but no longer back a surviving
observation lost their state and became re-eligible. The maintenance
reconciler then treated them as backlog and re-consolidated, duplicating
observations — violating the whole-bank contract of restoring exact state
without re-running consolidation.

- schema: TransferFact carries the three lifecycle timestamps (optional;
  absent in pre-fix archives -> None -> legacy fallback path).
- export: carry lifecycle exactly when observations are carried
  (always for export_bank; export_documents only with include_observations).
  The plain document export still omits them so it re-consolidates from
  scratch, which is correct there (it carries no observations).
- import: restore timestamps verbatim after fact insert; the
  observation-source marking now COALESCEs so it no longer clobbers a
  restored consolidated_at (still covers legacy archives).
- test: regression covering consolidated-but-observationless facts, a
  failed fact, exact lifecycle equality, zero reconciler backlog, and
  unchanged observation count.
2026-07-28 14:19:24 +02:00
Nicolò Boschi 678ca0e908 fix(reflect): fail on unusable tool calls instead of salvaging leaked text (#3013)
* fix(reflect): fail on unusable tool calls instead of salvaging leaked text

Reflect is driven by structured tool calls. Some provider transports don't
actually support function calling and silently strip the tool definitions from
the request (e.g. litellm's Vertex AI gpt-oss MaaS path drops tools/tool_choice
when the model is flagged unsupported). The model then answers in free text that
mimics a done() payload, which landed in message.content with empty tool_calls.
The old code served that raw text as the answer, so a growing pile of regex/JSON
"strippers" tried to claw the leaked memory_ids/observation_ids/directive_compliance
siblings back out of the user-facing answer.

Instead of salvaging untooled text, fail loudly:

- Track whether the model ever produced a tool call reflect could parse. If it
  never does (the stripped-tools case), raise ReflectToolCallError -> HTTP 500
  (the request is valid; the server's configured model can't do the job) with a
  clear message (provider, model, response snippet).
- Keep the done tool; _process_done_tool now trusts args["answer"] verbatim.
  A parsed tool call can't bleed its sibling id fields into the answer string.
- A model that DID tool-call and later stops with text is a legitimate stop and
  still routes through the clean forced-final synthesis path.
- Delete the entire strip zoo: _clean_done_answer, _unwrap_leaked_done_arguments,
  _strip_trailing_id_json_object, _clean_answer_text, _DONE_CALL_PATTERN, and the
  leaked-JSON regexes/key-sets. The forced-final paths return the model's prose
  directly (tools are disabled there, so there is no tool syntax to strip).

No static supports_function_calling gate -- reflect just tries and fails.

Supersedes the answer-salvage approach in #2972.

* test(mock): drive the reflect loop via tool calls, not bare prose

The reflect agent now rejects a turn that yields no usable tool call
(ReflectToolCallError). MockLLM's default call_with_tools returned bare
"mock response" content with no tool calls, which the old salvage path served
as the answer -- so ~15 reflect integration tests (empty-bank, tracing,
based_on, tags, think) started failing with 500 under the new guard.

Make MockLLM simulate a compliant tool-calling provider in its default path:
honor a forced retrieval tool_choice (so recall/search actually run and populate
based_on), and otherwise finish via the done tool. Tests that script their own
turns via _response_callback / _mock_response are unaffected.
2026-07-28 13:55:09 +02:00
Nicolò Boschi 6fe0dd690f fix(oracle): audit_log write qualification + llm_requests read gating (#3015)
Two remaining Oracle issues in the observability tables, both surfaced as
ORA-error spam in the Oracle CI logs (follow-up to the llm_requests write gate):

1. Audit writes (audit.py). `AuditLogger._safe_log` built `f"{schema}.audit_log"`,
   which on Oracle is `public.audit_log` — "public" is a reserved word there, so
   every write failed with ORA-00903 even though the table DOES exist on Oracle.
   Fix: use `fq_table_explicit("audit_log", schema)`, which qualifies per dialect
   ("schema".audit_log on PostgreSQL, bare audit_log on Oracle where the schema is
   set at the session level). This makes audit writes actually work on Oracle.

2. llm_requests reads (memory_engine.py). Unlike audit_log, `llm_requests` is
   PostgreSQL-only (its migration omits the Oracle slot; LLMTraceRecorder already
   skips writes on Oracle). `list_llm_requests` and `llm_request_stats` still ran
   `SELECT ... FROM llm_requests`, which is ORA-00942 on Oracle. Fix: after the
   bank-auth check (so a missing bank still 404s), return an empty page / empty
   stats on Oracle instead of querying a non-existent table.

Tests:
- test_audit_per_bank: capture the emitted SQL via a fake pool and assert the
  audit INSERT targets bare `audit_log` on Oracle (no `public.`) and `"schema".
  audit_log` on PostgreSQL.
- test_llm_trace: the list and stats endpoints return empty (200, not 500) when
  the backend is Oracle. Both deterministic, run on the default PG backend.
2026-07-28 12:36:32 +02:00
Nicolò Boschi 2620a2a3fa fix(embeddings,reranker): default local models to CPU on Apple Silicon (MPS memory leak) (#2988)
* fix(embeddings,reranker): default local models to CPU on Apple Silicon (MPS memory leak)

Local embedding + reranker inference on the PyTorch MPS (Metal) backend caches a
distinct compiled kernel graph and allocator pool per unique input tensor shape
and never releases it. Under the engine's variable-length, high-volume
recall/rerank/embed traffic (documents and candidate sets of every size), that
per-shape cache grows without bound: a local API instance was observed idling at
~20 GB (phys_footprint) — ~9.4 GB of Metal graphics memory plus ~8 GB of native
heap, essentially all of it stale per-shape MPS cache. CPU inference has no such
per-shape cache: the same workload holds flat at a few hundred MB, with
negligible latency cost for the small default models (and MPS actually slows down
over time as it recompiles graphs for new shapes).

Fix:
- MPS is now opt-in. select_local_device() (new engine/local_device.py) picks CPU
  when the only accelerator is Apple Silicon MPS; CUDA/XPU still auto-select. Set
  HINDSIGHT_API_{EMBEDDINGS,RERANKER}_LOCAL_ALLOW_MPS=true to opt back in.
- Post-batch memory release is consolidated in local_device.py and now also runs
  on macOS: it returns freed native pages to the OS (glibc malloc_trim on Linux,
  malloc_zone_pressure_relief on macOS — the #1717 fix previously covered only
  Linux) and empties the GPU allocator pool (torch.<backend>.empty_cache) when a
  GPU was used. The release path is wired into the embeddings encode path too,
  which previously released nothing.

Validated end-to-end through the real LocalSTEmbeddings/LocalSTCrossEncoder
classes under 150 iterations of variable-length load: default config runs on CPU
and holds flat at ~420–455 MB (vs. MPS climbing past 7.8 GB toward the observed
20 GB); the ALLOW_MPS opt-in still reaches the MPS device.

* docs(local_device): link the upstream PyTorch MPS graph-cache issues we track

* fix: only release GPU cache after local embedding when on a GPU; regen docs skill

Two CI fixes:
- embeddings.encode() ran gc.collect() + heap-trim on every call. encode() is on
  the retain hot path (a batch retain calls it many times), so a full gc.collect()
  per call added enough overhead to time out heavy retain tests
  (test_large_batch_auto_chunks). Guard the release to GPU devices only: on the CPU
  default there is nothing to reclaim that refcounting doesn't already free, and
  the opt-in MPS/CUDA path still gets empty_cache(). The reranker keeps its
  per-batch heap trim (#1717, lighter recall path).
- Regenerated skills/hindsight-docs/references/developer/configuration.md from the
  docs source (generate-docs-skill.sh) so verify-generated-files passes.
2026-07-28 11:29:31 +02:00
Nicolò Boschi ca755f8ca2 fix(oracle): skip LLM trace writes on Oracle (llm_requests is PG-only) (#3012)
`LLMTraceRecorder` wrote every LLM call into `llm_requests`, but that table is
PostgreSQL-only — its migration is `run_for_dialect(pg=...)` with the Oracle
slot intentionally absent, and `MaintenanceLoop.start` already skips its
retention sweep on Oracle for the same reason. The write path missed that gate,
so on Oracle every LLM call fired an INSERT that failed with:

    ORA-00903: invalid table name        (INSERT INTO public.llm_requests ...)

("public" is a reserved word on Oracle, so the schema-qualified name fails to
parse; and the table does not exist there regardless.) The failures are caught
and logged, so nothing breaks functionally, but they spam the error log on every
retain/consolidation call — visible throughout the Oracle CI logs.

Gate the recorder on the backend, mirroring MaintenanceLoop: a new
`_llm_requests_persistable()` returns False on Oracle, and both write entry
points (`is_enabled`, consulted by `record_llm_call`, and `attach_memory_ids`)
short-circuit before scheduling any work. PostgreSQL behaviour is unchanged.

Note: `audit_log` DOES exist on Oracle but `AuditLogger._safe_log` builds the
same `f"{schema}.audit_log"` (→ `public.audit_log`, also ORA-00903). That is a
distinct bug (wrong qualification, not a missing table) and audit is off by
default so it wasn't in the failing logs — left for a separate change.

Test: test_recorder_disabled_on_oracle_backend forces the Oracle backend and
asserts the recorder reports disabled and records nothing (deterministic, no
live Oracle needed).
2026-07-28 11:08:07 +02:00
Nicolò Boschi 8f19087c2b fix(claude-code): make reflect tool calls work and honor configured model (#2980)
Two fixes to the claude-code provider's ClaudeAgentOptions blocks.

#2966 — reflect agent made 0 tool calls. call_with_tools() is one *round*
of a loop the caller drives (reflect/agent.py executes the real tools and
feeds results back), but the SDK ran its own in-process loop against our
placeholder MCP handlers. With max_turns=2 the model called recall, saw the
empty placeholder, re-queried, exhausted the budget → error_max_turns → and
the code raised on that, discarding the tool calls it had made (trace then
read tools=[none]). Fix: cap the SDK at max_turns=1, break out of the stream
after the first proposed tool call, and treat the trailing error_max_turns as
non-fatal when tool calls were already captured. This matches every other
provider's single-round call_with_tools semantics.

#2881 — the configured model never reached the CLI: neither options block
passed model=, so every call ran the CLI's own default (Opus-class on Pro/Max
OAuth) while metrics/logs still printed self.model. The isolated
CLAUDE_CONFIG_DIR means a host settings.json can't reach the CLI either, so
model= is the only channel. Fix: pass model=self.model in both call() and
call_with_tools().

Tests: new test_claude_code_llm_tool_round.py (fake-SDK: tool call returned
despite error_max_turns, stops after first round, text-only answer, model
pinned on both paths, genuine error still raised). Both fixes verified
end-to-end against the real SDK.
2026-07-28 10:52:37 +02:00
Nicolò Boschi 4708a3661b fix(worker): reconcile stranded batch_retain parents on recovery (#2985) (#2986)
A batch_retain parent is a payload-less status aggregator: workers never
claim it, and it is promoted to a terminal state only when its last child
sub-batch finishes (_maybe_update_parent_operation). Two crash windows
strand it 'pending' forever — the aggregation swallowing a transient error
after all children are terminal, or children that never committed. Such a
parent is unclaimable, invisible to failed_operations, unretryable via the
API, and its documents are silently absent.

- Add WorkerPoller._reconcile_orphaned_parents(), run at the end of the
  per-schema recover_own_tasks() pass. Pending payload-null batch_retain
  parents are driven terminal: all-terminal children -> completed/failed
  (inheriting a representative child error), no children -> failed with an
  explicit resubmit hint. Parents with a live child are left to normal
  aggregation.
- Guard retry_operation so a batch_retain parent (null payload) cannot be
  retried into a re-stranded 'pending' state; the 409 points at the
  supported recovery (resubmit + delete).

Tests: reconciliation coverage in test_worker.py and a retry-guard test in
test_operation_status.py.
2026-07-28 10:29:53 +02:00
Ben e57765e012 feat(zapier): remove memoryDefenseTriggered trigger (gated capability) (#2994)
* feat(zapier): remove memoryDefenseTriggered trigger (gated capability)

Memory Defense is a gated capability: enabling it returns 400
'detectors_not_entitled' for orgs without the sensitive_data detector, so a
public Zapier trigger for memory_defense.triggered can never satisfy Zapier's
T001/S002 'one live run' review checks for un-entitled users.

- Remove the trigger from index.js and delete triggers/memoryDefenseTriggered.js
- Add guard tests asserting the exposed trigger set and that the trigger is absent
- Drop it from the package README and the Zapier integration docs page
- retain.completed and consolidation.completed remain (verified delivering on Cloud)

* chore(zapier): prettier-format triggers.test.js
2026-07-27 16:46:59 -04:00
Ben 2d0cd46084 blog: What people actually build with agent memory (use cases) (#2990)
* blog: What people actually build with agent memory (use cases)

Overview post walking through the concrete patterns teams build on
Hindsight: coding agents, per-user products, support/account assistants,
voice, chat platforms, self-built framework agents, multi-agent shared
banks, and automations. One primitive (retain/recall/reflect over a
bank), scoped and surfaced differently.
2026-07-27 14:44:07 -04:00
Ben 3feb111c86 docs(zapier): clarify private-beta availability + Webhooks-by-Zapier path (#2989)
* docs(zapier): clarify private-beta availability + Webhooks-by-Zapier path

* docs(zapier): remove Option B (private-beta native app), keep Webhooks path
2026-07-27 13:43:48 -04:00
Nicolò Boschi 581b7c48cc fix(oracle): don't COALESCE a bind against the CLOB mission column in update_bank (#2981)
`update_bank` wrote `SET mission = COALESCE($3, mission)`. On Oracle `mission`
is a CLOB, and COALESCE derives its result type from the first argument — the
bind `$3`, which oracledb sends as a VARCHAR2. Oracle then evaluates the CLOB
`mission` in a "CHAR expected" context and raises:

    ORA-00932: expression ("BANKS"."MISSION") is of data type CLOB,
               which is incompatible with expected data type CHAR

This broke every createBank/update that set a mission on Oracle — the failure
behind the persistently-red test-typescript-client-oracle job (`createBank`
issues a name+mission update).

Fix: build the UPDATE's SET clause from only the columns actually supplied and
assign them directly (`SET mission = $n`), the way set_bank_mission already
writes the CLOB. Assigning a string straight into a CLOB is fine on Oracle; it's
the cross-type COALESCE that fails. Untouched columns are simply not written,
which is the same result the COALESCE-of-NULL produced. Behaviour on PostgreSQL
is unchanged.

Tests:
- test_http_api_integration: new deterministic PG regression asserting name and
  mission round-trip, plus a mission-only update (runs on every CI shard).
- test_oracle_integration: test_bank_profile_crud now asserts the mission value
  round-trips (it already exercised this path but only checked name; the Oracle
  suite is skipped in normal PR CI, so the live coverage was the TS client job).
2026-07-27 17:37:36 +02:00
Nicolò Boschi ed248447e2 fix(control-plane): gate audit-log & observations tabs on resolved per-bank config (#2982)
The audit-logs and observations tabs gated on features.audit_log /
features.observations from the /version endpoint, which only reports the
global (server-level) default. Both fields are hierarchical
(env -> tenant -> bank), so a bank that opts in via per-bank config still
saw "not enabled" because the global flag stays off.

Gate these tabs on the bank's resolved config (getBankConfig) instead,
falling back to the global flag when the bank config API is disabled
(per-bank overrides can't exist then) or the field is unavailable.
2026-07-27 16:00:14 +02:00
Nicolò Boschi 5792b2b864 fix: avoid dotenv side effects on library import (#2979)
* fix: avoid dotenv side effects on library import (#2961)

`hindsight_api.config` called `load_dotenv(find_dotenv(usecwd=True),
override=True)` at module scope. Importing `hindsight_api` (or anything that
pulls it in — `import hindsight`, `HindsightEmbedded`) therefore walked up from
the host process cwd and overwrote the embedding application's own environment,
with override=True beating values it had set deliberately (#2961).

Move the load out of module scope into a `load_dotenv_for_entrypoint()` helper
that Hindsight's standalone entry points call explicitly: the API CLI
(`main.py`), the ASGI app (`server.py`), the worker, and the admin CLI. Library
imports are now side-effect-free.

Backwards compatibility for our own deployments is preserved exactly:
- `override=True` is kept in the helper, so a discovered `.env` stays
  authoritative over the ambient process env — unchanged precedence.
- `server.py` is covered, not just the CLI: it is the `uvicorn
  hindsight_api.server:app` target AND the import string uvicorn re-imports in
  each worker process when `hindsight-api` runs with `--workers`/`--reload`, so
  omitting it would silently break `.env` loading in multi-worker mode.
- `tests/conftest.py` now loads the workspace `.env` with `override=True`,
  matching the precedence config.py used to apply at import time (the oracle
  fixture depends on `.env` being authoritative).

Also drop the now-obsolete `_EARLY_DB_URL` workaround in `recall_perf.py`.

Closes #2961

* style: ruff-format test_fact_extraction_retry signature (pre-existing #2969 drift)

`ruff format` collapses this test's parametrized signature onto one line (it
fits within the 120-char limit). #2969 (e5cd23940) committed the multi-line form,
so verify-generated-files now flags it on every new branch. Not related to the
dotenv change — folded in here only to keep the whole-tree generated-files check
green.
2026-07-27 14:36:35 +02:00
EvoandNicolò Boschi e5b4c52d7e fix(clients): expose async retain operation_id (#2978)
* fix(clients): expose retain operation_id

* fix(clients): warn when operation_id is dropped on sync retain

operation_id only enables idempotent retries for asynchronous retain; on a
synchronous request it was silently dropped. Emit a warning at each retain
entry point (Python warnings.warn / TS console.warn) so a caller who forgets
retain_async=True learns their idempotency key was ignored.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-27 12:19:48 +02:00
Tommaso Fontana b475f5cca0 perf: eliminate redundant graph seed and UUID scans (#2968)
* perf(recall): reuse semantic candidates for graph seeds

* perf(retain): preserve UUID index for date lookup

* test: expand PostgreSQL optimization coverage
2026-07-27 12:12:30 +02:00
dimonnld 4724f26d33 Add Russian temporal period rules (#2767)
Extend the non-Chinese period table with Russian relative expressions
(вчера/позавчера/сегодня, «пару|несколько дней|недель|месяцев назад»,
прошлой неделе|месяце|году, прошлых выходных) and Russian month names in
their inflected forms, so Russian time queries get the same deterministic
extraction as English.

Russian months inflect: dateparser only resolves the nominative ("май"),
while "в мае" (prepositional) and "мая" (genitive, in explicit dates) are
the forms that occur. Enumerated per month with word-boundary guards so
stems inside longer words (майонез, мартовские) must not match.
2026-07-27 12:10:50 +02:00
Evoandr266-tech c908fade19 fix(consolidation): stop emitting unsupported maxItems that breaks all Bedrock consolidation (#2500) (#2502)
* fix(consolidation): stop emitting unsupported maxItems that breaks all Bedrock consolidation (#2500)

_build_response_model attached a Pydantic max_length to creates, which serializes to JSON-schema maxItems; Bedrock Converse rejects maxItems on array types, failing 100% of consolidation for capped Bedrock banks. The cap is already enforced by the prompt capacity note + unconditional truncation to remaining_observation_slots, so the schema constraint is dropped.

* test(consolidation): assert response schema omits maxItems (#2500 regression)

Rewrite TestBuildResponseModel to the new contract: factory always returns the base model, schema omits maxItems (Bedrock-compatible), over-cap creates are accepted (truncated downstream) rather than rejected. End-to-end cap enforcement remains covered by the existing max_observations_per_scope integration tests.

* Add an opt-out for maxItems schemas

---------

Co-authored-by: r266-tech <[email protected]>
2026-07-27 12:10:19 +02:00
Evoandr266-tech c65bf5c9eb fix(control-plane): preserve observations inheritance (#2885)
Co-authored-by: r266-tech <[email protected]>
2026-07-27 12:09:59 +02:00
Ben 2acd66df44 docs(openclaw): note memory-wiki bridge mode is unsupported (#963) (#2955) 2026-07-27 12:09:26 +02:00
Nick Old f7ff5341f7 fix: return free-form entities from dry-run extraction (#2958) 2026-07-27 12:09:09 +02:00
Jevinandijevin dcd3ba57e4 test(litellm): cover Responses named tool choice (#2953) (#2957)
Co-authored-by: ijevin <[email protected]>
2026-07-27 12:08:47 +02:00
Jay Stothard e5cd239401 fix: accept text alias in fact extraction (#2969) 2026-07-27 12:04:37 +02:00
Evo 1fa2de3327 Reject misplaced file retain metadata (#2971) 2026-07-27 12:03:57 +02:00
Ben ed120a256d blog: recall vs reflect (the two ways to read agent memory) (#2954)
* blog: recall vs reflect (the two ways to read agent memory)

Feature/decision piece contrasting Hindsight's two read operations:
recall (hybrid retrieval + rerank, no LLM, ranked facts, sub-second)
vs reflect (agentic loop with an LLM, hierarchical retrieval, synthesized
answer, response_schema, validated cited sources). Includes comparison
table, decision guide, and FAQ. Grounded in the recall/reflect engine
and API docs. Cover: recall vs reflect contrast panels.

* blog: use Inside retain() editorial theme for recall vs reflect cover

* blog: fact-check fixes to recall section

Adversarial verification against the recall engine found three
inaccuracies: recall runs 3 retrieval strategies always (semantic, BM25,
graph) with temporal conditional (not 4); no MMR/diversity pass is
implemented (docstring only); high budget defaults to 1000 not 600.
Softened 'local cross-encoder' since remote rerankers are configurable.
reflect claims all verified accurate.

* blog: fix API-doc link paths (/developer/api/... not /docs/...)
2026-07-24 14:20:20 -04:00
Sanderhoff-altandNicolò Boschi 73b575c7a3 fix(graph): queue edited and restored memories for relinking (#2893)
* fix(graph): queue edited and restored memories for relinking

Graph maintenance rebuilds outgoing temporal and semantic links only for
units explicitly present in its queue. Edits and restores submitted the
worker without queuing the affected unit, so its outgoing links could
remain missing.

Queue edited units together with incoming-link victims in one sorted
insert to preserve the global lock order. Queue restored units after
their searchable fields have been rebuilt.

Cover outgoing-only restore and bidirectional edit cases, including a
single queue write for the edited unit and its victims.

Fixes #2889.

* test(graph): cover the outgoing-only relink case; tidy enqueue helper

The PR's tests only exercised mutually linked units, so the branch the bug
actually lived in — an edited/reverted unit with outgoing links but no
incoming ones, where the victim lookup is empty — was untested.

Tests:
- enqueue_relink_victims: include_affected_units with no victims (returns
  the unit itself), with victims (one combined sorted insert), and the
  default opt-out for delete callers.
- Curation: an outgoing-only edit queues itself, plus two end-to-end tests
  that let the inline SyncTaskBackend drain the queue and assert the
  temporal link is actually rebuilt after an edit and after a revert.

All five fail on the pre-fix engine.

Tidy:
- Rename deleted_unit_ids -> affected_unit_ids; with the new flag the
  helper also takes units that stay live, so the old name/doc misled at
  the edit call site. Same for the debug log wording.
- Spell out at both call sites why the edit combines self+victims in one
  insert, why the invalidating edit opts out, and that revert rebuilds
  only the reverted unit's outgoing links.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-24 17:58:44 +02:00
Nicolò Boschi 6d5157575c fix(oracle): unblock Oracle CI — free runner disk space + fix the retain deadlock (#2948)
* ci(oracle): free runner disk space before Oracle jobs

The three Oracle jobs run the Oracle 23ai `free` service image, which
together with the Python ML deps (torch) exhausts the runner's ~14 GB root
disk. Two symptoms, one cause:

- uv fails to extract a wheel with "No space left on device (os error 28)"
  (fast ~2 min failure), and
- a near-full disk starves I/O badly enough to trip the 30-minute job
  timeout.

test-python-client-oracle and test-typescript-client-oracle have been red on
every open PR (#2941, #2942, #2943) from this, independent of the code under
test. Reclaim ~20 GB of preinstalled tooling (the same jlumbroso action the
Docker build job already uses) before the Oracle setup step.

docker-images stays false here: unlike the Docker build job, the Oracle
service container is already running by the time steps execute, so pruning
images could disrupt it. The savings come from the tool cache, Android SDK,
.NET, Haskell, large apt packages and swap.

* ci(oracle): trim disk reclaim to the fast, high-yield options

The first pass enabled every reclaim, which cost ~4 minutes of job time —
counterproductive on jobs that are already fighting a 30-minute limit.

android + dotnet + haskell + swap are a few rm -rf's worth ~16-21 GB, which
is ample headroom for the Oracle image plus torch. Dropped:
- large-packages: apt-get remove, costs minutes for little extra space;
- tool-cache: deletes the preinstalled Python that actions/setup-python then
  re-downloads, making the job slower rather than faster.

* fix(retain): flush entity stats after releasing the connection (Oracle hang)

Retain hung forever on the Oracle backend: every retain test burned its 120s
client timeout while the server sat idle, so test-python-client-oracle and
test-typescript-client-oracle only ever reached ~5% of the suite before the
30-minute job limit.

The server was not slow — it was deadlocked. flush_pending_stats() acquires
its own connection, but it was being called while the enclosing
acquire_with_retry(...) block still held one:

  async with acquire_with_retry(pool) as conn:   # conn checked out
      async with conn.transaction():             # SAVEPOINT only
          ...write facts/entities...
      await entity_resolver.flush_pending_stats()  # takes a 2nd connection

oracledb does not autocommit and OracleConnection.transaction() is only a
SAVEPOINT, so the write is committed by OracleBackend.acquire() when its block
exits. Connection #2's `UPDATE entities ...` therefore waits on row locks held
by the still-open connection #1, which cannot commit until the call returns —
a circular wait. Oracle never reports ORA-00060 because session #1 is blocked
in Python, not on the database, so it hangs indefinitely instead of erroring.

Move the flush after the acquire block in all three call sites (streaming
retain, delta retain, transfer importer), which is what its own docstring
already required ("must be called AFTER the retain transaction commits") and
which PostgreSQL satisfied only by accident via asyncpg autocommit.

Guarded with an AST lint test rather than a behavioural one: the deadlock
cannot be reproduced against PostgreSQL, which is what the suite runs on.

* test(repair): retry the concurrent index drop on deadlock

test_dry_run_creates_nothing still flaked in test-api shard 3. CONCURRENTLY
avoids ACCESS EXCLUSIVE but still takes ShareUpdateExclusive, which conflicts
with the ShareLock a fresh bank's plain CREATE INDEX holds — and that one
cannot be made concurrent, since it runs inside the bank-create transaction.
So _drop_bank_indexes can still be picked as the deadlock victim while another
xdist worker seeds a bank:

  Process A waits for ShareUpdateExclusiveLock on memory_units; blocked by B.
  Process B waits for ShareLock on virtual transaction; blocked by A.

The bank-create side already retries (#2943); give the drop the same treatment.
The drop is idempotent, so retrying is safe.
2026-07-24 17:31:38 +02:00
Ben 1a4388ae49 release(paperclip): v0.3.0 2026-07-24 11:13:42 -04:00
Eric OgdenandClaude Sonnet 5 0c6d54dc8a feat(paperclip): per-agent enable/disable for pilot rollouts (#2724)
Add optional enabledAgentIds config field to restrict Hindsight recall/retain to
a subset of agents. When set, only listed agent IDs trigger memory operations;
unset or empty array = unchanged behavior (all agents). Enables pilot rollouts on
high-signal agents before fleet-wide enable, reducing LLM cost/latency risk.

- Add enabledAgentIds: string[] to instanceConfigSchema (manifest.ts)
- Add isAgentEnabled() gate function to worker.ts
- Gate agent.run.started recall, agent.run.finished, and issue.comment.created
  retain handlers (the actual LLM-cost operations)
- Add 6 test cases covering allowlist pass/fail, empty array, and unset behavior
- Update README config table

Co-Authored-By: Claude Sonnet 5
2026-07-24 11:11:52 -04:00
Nicolò Boschi 370d930341 docs(consolidation): define every input field in the consolidation prompt (#2952)
The consolidation prompt serializes temporal metadata the INPUT section never
explained. `mentioned_at` in particular was emitted on new-fact lines, on each
existing observation, and on every embedded source memory, while the format
description documented only id/text/proof_count/occurred_start/occurred_end --
so the model received the timestamp with no idea what it meant or that it
represents how current a statement is.

Define each field the serializer actually emits, and note that `mentioned_at`
tracks when the source material was written rather than when it was ingested,
which is what makes it meaningful for out-of-order document ingestion.

The two copies of the format description (the cached bank-agnostic system
prefix and the single-message template) are now built from shared constants so
they cannot drift apart.

Refs #2550
2026-07-24 16:53:07 +02:00
Nicolò Boschi 0e5aa8896e fix(curation): keep causal links across edit and invalidate/restore (#2951)
Causal edges (`caused_by` plus the historical `causes`/`enables`/`prevents`)
are retain-time extraction output. Nothing recreates them: graph maintenance
only rebuilds temporal/semantic links and consolidation regenerates
observations, not raw-fact edges. Curation destroyed them anyway (#2864):

* every edit — including a context-only one — deleted all incident
  `memory_links` rows, and
* invalidation moves the row out of `memory_units`, so the FK cascade took
  its causal edges with it and restore had nothing to bring back.

Edits now delete only the derived link types, so a corrected fact keeps the
causality the extractor asserted for it (preserving the assertion is the
reversible choice; deleting it is not). Invalidation snapshots the incident
causal edges into a new `causal_links` JSONB column on the archive row, and
restore rematerializes the ones whose peer endpoint is live again.

The snapshot also picks up descriptors parked on archived peers that name the
unit, so an edge whose both endpoints are invalidated survives on both archive
rows and is recreated by whichever endpoint is restored last — restore order
doesn't matter. Rematerialization goes through the existing bulk-insert path,
which drops links whose endpoints aren't live and is `ON CONFLICT DO NOTHING`,
so repeated invalidate/restore cycles never duplicate an edge or resurrect one
pointing at a permanently deleted memory.
2026-07-24 16:38:06 +02:00
Sanderhoff-altandNicolò Boschi 0f47c7a8dc fix(auth): authorize bank writes before provisioning (#2646)
* fix(config): validate bank config updates before creating banks

Route external bank configuration writes through MemoryEngine so tenant
authentication and UPDATE_BANK_CONFIG authorization happen consistently.

Validate profile and configuration changes before creating a bank or
persisting either one. Rejected configuration updates through PUT,
PATCH, import, and MCP therefore leave no empty bank or partial profile
changes behind.

Keep memory-defense validation behavior unchanged, and cover the new
ordering and delegation paths with regression tests.

* fix(import): preflight template operations before creating banks

Preflight every template operation before creating a missing bank.
Reject duplicate mental models and directives before applying changes.

Reuse request-local authorization decisions while the import executes,
avoiding duplicate hook calls that may reserve quota or depend on time.
Precheck mental-model refresh availability so common failures do not
leave a newly created bank or a partially applied template behind.

Document that the authorization context creates the bank after all
checks pass.

* fix(mcp): create banks through public engine APIs

Delegate MCP bank creation to MemoryEngine's public profile and update
APIs instead of calling _ensure_bank_exists() directly.

Use get_bank_profile() for default creation and update_bank() when name
or mission fields are supplied. This keeps lifecycle validation and
authorization ordering inside the engine and avoids duplicate reads.

Add coverage for both public API paths and assert that MCP never invokes
the private creation helper.

* fix(config): fail loudly when persisting config for a missing bank

Bank creation moved out of ConfigResolver into MemoryEngine, but the
persist step still returned normally when the UPDATE matched zero rows.
A caller that skipped provisioning silently discarded its overrides
while reporting success — the failure mode #1940 originally fixed.

Raise instead, and translate the concurrent-delete case in update_bank's
update-only path into the same 404 its final profile read would produce.

* test(mcp): assert update_bank calls instead of a fixture's forwarding

The mock_memory fixture re-implemented _do_update_bank's routing by
forwarding config_updates to _config_resolver.update_bank_config, so the
existing assertions verified the fake rather than production code — they
would still pass if _do_update_bank stopped sending config entirely.

Assert on the update_bank mock, which is the call the tool now makes.

* test(api): cover the 404 mapping for a delete racing the config write

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-24 16:35:03 +02:00
Naga Satish Chilakamarti 7f05187325 docs: add TealTiger community integration listing (#2831)
* docs: add TealTiger governance memory integration listing

Adds TealTiger to the integrations page as a community integration.
Governance-aware agent memory with importance-weighted retention.

Related: #2284
PyPI: https://pypi.org/project/tealtiger-hindsight/

* Delete hindsight-docs/docs-integrations/tealtiger.md

* Update TealTiger integration link to GitHub
2026-07-24 15:56:15 +02:00
Sanderhoff-alt ada3329bb9 feat(config): make embedding thresholds configurable (#2875)
Expose graph seed, temporal semantic, and semantic-link similarity
thresholds through HindsightConfig while preserving existing defaults.

Wire the settings through retrieval, retain, streaming, and graph
maintenance paths. Add validation, environment examples, documentation,
and regression coverage.

Document how to calibrate all five embedding-dependent thresholds and
note that semantic-link changes do not rebuild existing graphs.
2026-07-24 15:13:57 +02:00
Nicolò Boschi a514d39624 fix(deps): require litellm>=1.93.0 for Python 3.14 support (#2950)
litellm ships its own Rust extension (litellm-rust python-bridge ->
litellm.rust_bridge._native, built via maturin/PyO3). Releases before
1.93.0 publish no cp314 wheel, so on Python 3.14 uv falls back to the
sdist and the build fails:

    error: the configured Python interpreter version (3.14) is newer
    than PyO3's maximum supported version (3.13)

1.93.0 adds cp314 wheels and a PyO3 that builds on 3.14. Raising the
floor fixes the failure at its source, so the interpreter no longer has
to be constrained.

That lets us drop the UV_PYTHON=3.13 workaround added in #2801: the
_set_uvx_python_compat() helper and its call sites are removed from the
claude-code, codex, cursor, and cursor-cli daemons, along with the tests
that pinned that behaviour. Dropping the pin costs nothing — litellm
publishes no macOS wheels at all, so macOS builds from the sdist on every
version regardless, while Linux now gets a real cp314 wheel instead of a
source build.

Also strengthen the build-api-python-versions CI matrix. It previously
ran only `uv build`, which just packages the source and passes even when
the dependency set cannot install or import on the target interpreter --
it would not have caught this. It now installs into a fresh venv,
byte-compiles, and runs an import smoke test on each version.

Verified on CPython 3.14.4 with UV_PYTHON unset: litellm 1.93.0 installs,
the Rust bridge builds, and hindsight_api plus the engine import cleanly.

Refs #2783
2026-07-24 15:06:52 +02:00
handnewbandhandnewb d06fdd78cc fix(integrations): derive recall hook timeout from requestTimeoutSeconds (#2883)
Raise the hardcoded 12s UserPromptSubmit/beforeSubmitPrompt hook timeout
to a safe 45s default across all integration hook manifests (claude-code,
cursor-cli, codex, omo, zcode).

For Claude Code, setup_hooks.py now reads the user's requestTimeoutSeconds
from ~/.hindsight/claude-code.json and derives the hook timeout as
max(requestTimeoutSeconds + 15, 30s) — so the hook process is never killed
before the MCP recall request it wraps has a chance to complete.

Fixes #2854

Co-authored-by: handnewb <[email protected]>
2026-07-24 14:44:08 +02:00
handnewbandhandnewb 7a9ea70580 feat(control-plane): display API version in sidebar (#2886)
Fetch the API version from GET /version at mount and display it in the
sidebar footer. When collapsed, shows 'vX.Y.Z'; when expanded, shows
'Hindsight vX.Y.Z'. Gracefully handles fetch failures (no version shown).

Fixes #776

Co-authored-by: handnewb <[email protected]>
2026-07-24 14:38:25 +02:00
Chris LatimerandNicolò Boschi 64fe5e81f2 feat(engine): add MemoryEngine.delete_memory_units bulk primitive (#2659)
Bulk variant of delete_memory_unit that removes a list of unit_ids with the
same referential-integrity lifecycle, batched by bank:

- enqueue_relink_victims before the cascade
- chunked cascade DELETE (FK CASCADE handles unit_entities / memory_links /
  observation history)
- _delete_stale_observations_for_memories racing-insert sweep
- bank-stats cache invalidation
- deduped async consolidation + graph_maintenance submission per bank

Gives retention loops, LRU eviction, and bulk-maintenance tools a single entry
point that keeps the cascade contract instead of open-coding DELETEs outside
the engine and drifting from it.

(The last_recalled_at column originally in this PR was dropped: it has no OSS
consumer and is better as an extension-owned side table — a high-frequency
write of an indexed column does not belong on the hot memory_units table.)

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-24 14:38:04 +02:00
Carter d29f4703e9 fix(helm): override HINDSIGHT_API_PORT in worker StatefulSet to survive K8s service discovery (#2904)
* fix(helm): override HINDSIGHT_API_PORT in worker StatefulSet

* fix(helm): de-duplicate worker env keys shared by api.env and worker.env
2026-07-24 14:29:09 +02:00
Nicolò Boschi c41ad9bd75 feat(api): filter memory list by linked entity + entity timeline UI (#2945)
* feat(api): filter memory list by linked entity + entity timeline UI

Add an `entity_id` query param to `GET /memories/list` — an exact reverse
lookup over stored entity links (not text/semantic match), backed by the
existing idx_unit_entities_entity_unit index. Because entity links reference
live memory units only, combining `entity_id` with `state=invalidated`
returns nothing.

Wire it through the control-plane list route + clients, and use it in the
entity detail panel to render an observation timeline (reuses the memories
TimelineView) — click an entity, see its linked observations over time.

Closes #2936.

* fix(control-plane): entity timeline shows all linked memories, not just observations

Verified against real data: observations are derived/consolidated summaries and
carry no entity links — entity links live on the source world/experience facts,
which are also the ones with occurred dates. Filtering the entity timeline to
type=observation therefore always rendered an empty panel. Drop the type filter
so the panel shows every memory linked to the entity (the actual dated timeline),
and relabel the section "Timeline" with dedicated i18n keys.

* chore(control-plane): drop now-unused observation i18n keys from entitiesView

* style(reflect): wrap over-length _generate_structured_output call

Ruff format wraps this >120-char call; committing the formatter output so the
verify-generated-files CI check (which runs the formatter and diffs) is clean.
2026-07-24 14:28:44 +02:00
Nicolò Boschi af8cf142d7 fix(mental-model): anchor delta refresh watermark to newest processed memory (#2878)
Follow-up to #2866. That PR stopped the scheduled no-op refresh storm by
advancing a delta model's last_refreshed_at to the pre-Reflect snapshot cutoff
(a wall-clock now()), but a wall-clock watermark is unsafe against commit
visibility.

memory_units.updated_at is the writing transaction's start time (Postgres
now()), yet a row only becomes visible at COMMIT, which can land after a
concurrent refresh captured its snapshot. Such a straddling row is invisible to
Reflect but carries a timestamp <= that instant, so setting the watermark to
now() leaves it permanently below the watermark and drops it from every future
refresh. The same hazard existed on the contentful path (last_refreshed_at =
NOW()) before #2866.

Persist the watermark as MAX(updated_at) over the model's scope restricted to
rows visible at the snapshot -- the newest memory the refresh actually saw --
instead of now(). A straddler is still uncommitted at that snapshot so it is
excluded from the max; when it commits it stays strictly newer than the
watermark and is caught next time. This needs no time margin: max(seen) does
not overshoot the real data, so the settled window stops re-triggering (no
storm) and delta recall's created_after (the prior max(seen)) reprocesses
nothing.

The watermark is clamped monotonic: max(newest_seen, current last_refreshed_at),
so a refresh over only-older memories never moves it backwards (which would
resurface already-processed rows). MAX null (no in-scope row visible) leaves
last_refreshed_at unchanged so an in-flight first row is not skipped.

Extract _build_mm_scope_filter so the staleness check and the watermark query
share one identical scope.

Tests: straddling-commit test uses a committed baseline as the max(seen)
watermark and a newer held-then-committed straddler (fails on #2866); the no-op
test asserts the watermark equals the newest processed memory's updated_at.
2026-07-24 14:26:14 +02:00
Nicolò Boschi dfac776dd5 fix(repair): stop the shared-DB deadlock flake in test-api (CONCURRENTLY test DDL + retry transient deadlocks) (#2943)
* fix(repair): retry transient deadlocks + non-blocking test DDL

The test-api shard runs 8 pytest-xdist workers against one shared pg0
database (public schema). test_repair_bank_vector_indexes built/dropped a
decoy index with plain CREATE/DROP INDEX on the shared memory_units table,
taking ACCESS EXCLUSIVE and deadlocking unrelated workers' DML — recall,
reflect and refresh tests turned into asyncpg DeadlockDetectedError
casualties.

- tests: build/drop the decoy index CONCURRENTLY (ShareUpdateExclusive
  never blocks DML) to match production and stop the collateral deadlocks.
- engine: repair_vector_indexes retries a CREATE/DROP INDEX CONCURRENTLY
  picked as a deadlock victim (sqlstate 40P01 / ORA-00060) via the existing
  retry_with_backoff, instead of recording a permanent failure. Always
  drop-then-create so a retry clears the INVALID stub a deadlocked
  CONCURRENTLY build leaves behind.
- test: test_transient_deadlock_is_retried_not_failed injects a one-shot
  deadlock and asserts repair converges (failed == 0).

No advisory locks (project rule): concurrency stays handled by idempotent
DDL plus victim retry.

* fix(banks): make per-bank index create/delete deadlock-safe

The test-api shard runs 8 xdist workers against one shared pg0 memory_units
table, so every bank create/delete does index DDL that contends with other
workers' DML. These are pre-existing production deadlock sources, not just
test noise:

- delete_bank dropped per-bank indexes with a plain DROP INDEX (ACCESS
  EXCLUSIVE on memory_units), blocking/deadlocking every other bank's
  reads/writes. Now DROP INDEX CONCURRENTLY (ShareUpdateExclusive, does not
  conflict with DML), run post-commit on an autocommit connection, wrapped
  in retry_with_backoff for the residual transient deadlock.
- fresh-bank index build uses a plain CREATE INDEX (ShareLock) inside the
  bank-create tx — CONCURRENTLY is impossible there. The whole tx is now
  wrapped in retry_with_backoff; the build is idempotent (INSERT ON CONFLICT
  + CREATE INDEX IF NOT EXISTS) so a deadlock victim retries cleanly.

Regression tests inject a one-shot deadlock into each path and assert it
retries and converges. No advisory locks (project rule).
2026-07-24 14:04:13 +02:00
Nicolò Boschi 31218127e0 fix(retain): make async retries idempotent via caller-supplied operation_id (#2937) (#2947)
* fix(retain): make async retries idempotent via caller-supplied operation_id

An async retain whose HTTP acknowledgement is lost or times out leaves the
caller unable to tell whether the operation was created; retrying enqueues a
second parent operation and repeats extraction, embeddings, and provider spend.

Add an optional caller-supplied operation_id (UUID) used directly as the parent
async_operations primary key. Re-submitting with the same id returns the
original operation and creates no new work; the existing primary key is the
concurrency authority, so no new columns, constraints, or migration are needed.
Reusing an id owned by a different bank or operation type returns HTTP 409.
Omitting operation_id keeps the current create-each-time behavior.

Fixes #2937

* docs(retain): explain why the idempotency read is not in the create txn

* fix(retain): sync generated docs-skill + Rust clients for operation_id

- Regenerate the two docs-skill artifacts derived from the retain doc /
  OpenAPI change (verify-generated-files).
- Add operation_id: None to the Rust client test and CLI RetainRequest
  literals so both crates compile against the regenerated struct.
2026-07-24 13:43:57 +02:00
Nicolò Boschi 57c18bc298 feat(extensions): declare + provision extension-owned bank-scoped tables (#2903)
* feat(extensions): let extensions declare bank-scoped tables for backup + teardown

An extension can provision its own bank-scoped tables in the tenant schema
(audit receipts, per-bank policy state, ...), but core knows nothing about
them, so they silently fall out of the per-tenant data-lifecycle operations it
owns:

- admin backup/restore copies a fixed core table set and TRUNCATEs it CASCADE
  on restore; an extension table absent from that set is dropped from the
  backup and — if it FKs banks — wiped by the cascade with no way back;
- delete_bank clears a bank via core deletes + the banks FK cascade; an
  extension table scoping by bank_id without a cascading FK leaks orphaned rows.

Add a BankScopedTable descriptor and TenantExtension.extra_bank_tables() so an
extension declares its tables; core consults them in:

- admin backup/restore (_effective_backup_tables appends declared tables after
  the core set so restore's forward COPY / reversed TRUNCATE keep FK order);
- MemoryEngine.delete_bank (sweeps declared tables by bank_id on full delete,
  with a PG-only to_regclass guard so a declared-but-unprovisioned table can't
  abort the delete).

The extension still owns the DDL; this only tells core which tables to sweep.
Default behaviour is unchanged — the base method returns no tables, so the OSS
default path is a no-op. Descriptor names are validated to a safe SQL
identifier shape since they're interpolated into SQL.

Covered by descriptor-validation + effective-list unit tests, a delete_bank
sweep test, and a backup/restore round-trip that proves a declared extension
table survives truncate+restore.

* feat(extensions): provision extension bank tables on the migration path

Adds the creation half of the bank-scoped-table lifecycle. Previously an
extension's tables were created only by its own imperative DDL run lazily on
first request (e.g. Cloud's provision_schema off authenticate), so:
  - hindsight-admin run-db-migration migrated core schema across all tenants
    but never touched extension tables, and
  - a provisioning failure was swallowed, surfacing later as a runtime error.

Add TenantExtension.provision_bank_tables(conn, schema) — idempotent DDL the
extension owns — and invoke it right after core migrations from both migration
entry points:
  - ExtensionContext.run_migration (every tenant-schema provision), and
  - the run-db-migration sweep (_provision_extra_bank_tables, per schema),
    where a failure now aborts the command and names the schema instead of
    being swallowed.

So extension schema evolves on the same lifecycle as core schema. Default is a
no-op, so the OSS default path is unchanged. Pairs with extra_bank_tables()
(declares for backup/teardown) — one creates, the other declares.

Covered by a default-no-op test plus provisioning through both the CLI sweep
helper and ExtensionContext.run_migration against real Postgres.

* chore: ruff format after rebase (cli.py, memory_engine.py)
2026-07-24 13:32:59 +02:00
Nicolò Boschi 6a0b85f108 feat(config): make store_document_text overridable per bank (#2940)
* feat(config): make store_document_text overridable per bank

HINDSIGHT_API_STORE_DOCUMENT_TEXT was static/server-level. Make it hierarchical
so a data-minimizing bank (e.g. GDPR-sensitive) can keep only derived facts
while other banks on the same deployment retain the raw source.

- Add store_document_text to _CONFIGURABLE_FIELDS (settable per bank via the
  config API's generic updates dict, like audit_log_enabled).
- Thread the per-bank resolved value into the retain storage path
  (chunk_storage.store_chunks_batch + fact_storage.upsert_document_metadata /
  handle_document_tracking / _upsert_document_row) from the orchestrator's
  resolved config; falls back to the server-level config when unset so
  non-retain callers (import) are unchanged.
- Make the three consistency guards per-bank too so a store-off bank behaves
  coherently: append-mode rejection, recall include_chunks force-off, and the
  reflect 'expand' tool exclusion.
- Docs: mark the flag hierarchical.

Covered by a per-bank override test (one bank off, one default-on) + a
configurable-fields guard; existing global-flag tests set the ConfigResolver
global snapshot (env alone no longer suffices for a hierarchical field,
mirroring enable_audit_default).

* feat: expose store_document_text (+ audit_log_enabled) in bank templates & UI

- BankTemplateConfig gains store_document_text and audit_log_enabled so bank
  templates can preset them; regenerated bank-template-schema.json.
- Control-plane bank config: new 'Document Storage' tri-state section
  (Inherit / On / Off), mirroring the audit toggle; translations added across
  all 10 locales (non-en use English placeholders pending translation).

Backend template round-trip + messages parity/used-keys + tsc all green.

* chore(ui): rename bank-config 'Document Storage' section to 'Privacy'

* feat(ui): merge audit + document-text toggles into one 'Security & Privacy' section

Combine the separate Audit Logging and Privacy config sections into a single
Security & Privacy section with both tri-state toggles and one save (writes
audit_log_enabled + store_document_text together). Drop the now-unused
section-level message keys across all locales; add securityPrivacy* keys.

* fix(retain): use _get_raw_config for store_document_text fallback

store_document_text became bank-configurable, so get_config().store_document_text
now raises ConfigFieldAccessError (the guard forcing per-bank resolution). The
storage functions' None-fallback hit that guard, breaking every direct/delta
caller that didn't pass the value (test_chunk_storage_upsert, test_delta_retain).

Fall back to _get_raw_config() instead — the unguarded global layer the
ConfigResolver and the /config defaults response already use. The retain path
still passes the per-bank resolved value; only non-retain callers hit the
fallback.

* chore: regenerate openapi + clients + docs-skill for BankTemplateConfig fields

Adding store_document_text/audit_log_enabled to BankTemplateConfig changed the
OpenAPI schema; regenerate the spec, Go/Python/TS client models, and docs-skill
copies, and apply lint formatting (verify-generated-files).

* test: bump configurable-field count 41->42 for store_document_text
2026-07-24 12:27:41 +02:00
Parafee41 1ff09ccf9c fix(cli): preserve HTTP 400 details (#2916)
* fix(cli): preserve HTTP 400 details

* sync generated OpenAPI version
2026-07-24 12:25:39 +02:00
Voscko ff4dc116c3 fix: propagate Codex reasoning effort (#2919) 2026-07-24 12:25:11 +02:00
Salem KorayemandOpenAI GPT-5.6-Sol High a6c875156b fix(retain): preserve append-only oversized history (#2930)
Recognize a complete oversized document as a strict append even when its
header-only first transport slice previously extracted no facts and has no
stored chunk match. Advance document metadata under a content-hash guard so
later slices can recovery-skip unchanged history without risking stale writes.

Co-authored-by: OpenAI GPT-5.6-Sol High <[email protected]>
2026-07-24 12:11:42 +02:00
Derek Bouius 029e5d47d6 chore(deps): bump next, postcss, pypdf (security) (#2933)
Clears the remaining fixable high-severity Dependabot alerts:

  next     16.2.9  -> 16.2.11   4 alerts (control-plane). Direct dep bumped
                                (^16.2.6 -> ^16.2.11); a root override
                                (>=16.2.11 <17) also forces next-intl's nested
                                [email protected] copy up so no vulnerable copy remains.
  postcss  8.4.31  -> 8.5.22    1 alert. The vulnerable copy was next's bundled
                                8.4.31 (the direct 8.5.15 already satisfied);
                                a global override >=8.5.12 forces it up.
  pypdf    6.13.3  -> 6.14.2    2 alerts (superagent). Transitive.

Verified: control-plane `npm run build` (next build + standalone) succeeds,
`npm ci` installs the root lock cleanly, npm audit no longer flags next or
postcss, superagent pytest passes, lint clean.
2026-07-24 12:11:31 +02:00
Nicolò Boschi 489d55fa62 feat(observability): diagnose blocked-loop vs pool-exhaustion on stalled /health (#2942)
The API and worker run /health and all task work on a single event loop, and
/health acquires a DB connection. A failing liveness probe therefore has two
very different causes that today are indistinguishable: the event loop is
blocked by synchronous work (a restart helps), or the connection pool is
exhausted and /health can't get a connection while the loop is idle (a restart
just thrashes). Add two always-on, cheap signals so the failure is
self-diagnosing instead of an opaque restart.

LoopWatchdog (hindsight_api/loop_watchdog.py): runs in a separate OS thread —
deliberately, since a coroutine-based monitor would be frozen by the very stall
it's watching — pings the loop, and on a stall past a threshold logs the loop
thread's stack (naming the blocking frame) and emits
hindsight.event_loop.stalls / stall_duration. Works with uvloop. Wired into the
worker CLI and the API lifespan; enabled by default.

DB pool acquire instrumentation (engine/db/pool_instrumentation.py): tracks
callers currently queued for a connection (hindsight.db.pool.waiting gauge, the
signal that actually distinguishes exhaustion from a busy-but-healthy pool),
records an acquire-wait histogram, and logs a warning with pool stats when an
acquire waits too long. Wired into both the PostgreSQL and Oracle backends.
health_check() now reports db_acquire_ms and pool utilization in its payload.

Static config: HINDSIGHT_API_LOOP_WATCHDOG_ENABLED / _STALL_THRESHOLD_MS /
_POLL_INTERVAL_MS, HINDSIGHT_API_DB_ACQUIRE_WARN_THRESHOLD_MS.

Tests: test_loop_watchdog.py (detects on-loop blocks, ignores off-loop work,
quiet when responsive) and test_pool_instrumentation.py (waiter counting through
success/mid-acquire/failure, slow-acquire logging).
2026-07-24 12:11:07 +02:00
Nicolò Boschi 47a7d43809 fix(llm): normalize bare LM Studio / Ollama base URL to /v1 (#2941)
LM Studio's server UI advertises its address as a bare host
(http://localhost:1234), so users commonly set HINDSIGHT_API_LLM_BASE_URL
to that. The OpenAI SDK then POSTs to <host>/chat/completions and LM Studio
rejects it with 'Unexpected endpoint or method' — its OpenAI-compatible
routes live under /v1.

For lmstudio/ollama (whose OpenAI-compat surface is known to live under /v1)
append /v1 when the base URL has no meaningful path. Explicit paths (reverse
proxy mounts, already-correct /v1) are left untouched.

Fixes #2922
2026-07-24 11:55:01 +02:00
Nicolò Boschi 21928d7c95 chore(deps): bump protobuf to 7.x and OpenTelemetry to 1.44/0.65b0 (#2923)
protobuf 7 was blocked only by opentelemetry-proto <1.44 capping
protobuf<7.0; 1.44.0 raised the ceiling to <8.0. Bump the six coupled
otel pins together (api/sdk/otlp-proto-http 1.41->1.44, the three 0.6x
companions 0.62b1->0.65b0) and protobuf 6.33.5->7.35.1.

Verified in a real env: the OTLP HTTP exporter's protobuf-serialized
trace payload round-trips through otel's generated proto types, and the
Prometheus metrics path works. The otel_component_type kwarg (reason for
the original >=1.41 floor) is still present in 1.44.
2026-07-24 11:00:07 +02:00
EvoandNicolò Boschi 552feb24b2 fix(retain): offset causal targets from the extraction-group start (#2935)
* fix(retain): offset causal targets from chunk start

* refactor(retain): drop unreachable chunk fact-count guards

The sync path derives each chunk's fact_count as len(chunk_facts)
(extract_facts_from_text), so sum(counts) always equals
len(facts_from_llm) and counts are never negative. The mismatch/
negative RuntimeError guards could only fire under artificial test
setups; the offset fix and target bounds-check stand on their own.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-24 10:35:08 +02:00
Nick OldandNicolò Boschi 3cc3713829 fix backup restore schema compatibility (#2920)
* fix backup restore schema compatibility

* test(backup): cover type-mismatch preflight + extra-target-column restore

Add a test for the incompatible-column-type preflight branch and a
positive test proving a target with an extra nullable column (which a
column-less binary COPY would reject) now restores cleanly. Document the
deliberate exact-type strictness in _validate_restore_schema.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-24 10:09:18 +02:00
Nicolò Boschi 03a1fd08c0 fix(engine): make mental-model refresh cutoff stubbable (fix mock unit tests) (#2924)
refresh_mental_model gained an unconditional DB-time snapshot query
(_get_backend() -> SELECT current_timestamp) to bound the refresh watermark.
That broke the mock-based unit tests that build MemoryEngine.__new__ and stub
the collaborators: they now reach _get_backend() on an engine whose __init__
never ran, failing with 'MemoryEngine object has no attribute _initialized'.

Extract the snapshot into _mental_model_refresh_cutoff(bank_id, mental_model_id)
(pure refactor, no behaviour change) so those tests can stub it like the other
collaborators, and stub it in the three affected tests.

Fixes pre-existing test-api failures on main:
- test_recall_config.py::TestRefreshTriggerWiring (x2)
- test_mental_models.py::TestMentalModelRefreshMaxTokens::test_refresh_passes_stored_max_tokens_to_reflect
2026-07-24 10:01:03 +02:00
Ben 0db0d3ec93 blog: Give Roo Code a Memory So Every Task Builds on the Last (#2931)
* blog: persistent memory for Roo Code (task-based agent)

How-to for the Roo Code integration: one-command install that wires
Hindsight's MCP tools (recall/retain, auto-approved) plus a custom rules
file so Roo recalls context before each task and retains a summary after.
Covers project vs global scope, cloud/self-host, verification, cross-tool
bank sharing, and FAQ. Grounded in the integration doc + README; package
live on PyPI. Cover: Roo kangaroo mark + recall->task->retain loop.

* blog: rebuild Roo Code cover in iridescent-mesh template with Roo mark

* blog: drop MCP from Roo Code cover subtitle
2026-07-23 15:30:14 -04:00
Derek Bouius fa69b5b73b chore(deps): bump npm transitive highs (brace-expansion, js-yaml, sharp, fast-uri, svgo, shell-quote) (#2907)
Clears the remaining high-severity npm Dependabot alerts across the root lock
and three integration locks, via overrides (root + zapier + cloudflare) and a
direct-dep bump (nemoclaw, where js-yaml is declared directly):

  root:     brace-expansion 2.0.3->2.1.2, fast-uri 3.1.2->3.1.4 (capped <4),
            sharp 0.34.5->0.35.3, shell-quote 1.8.4->1.10.0, svgo 4.0.1->4.0.2
  zapier:   brace-expansion pinned per-major (1.1.16 / 2.1.2 / 5.0.7 via
            version-keyed overrides so coexisting majors are not collapsed),
            js-yaml ->4.3.0 (capped <5)
  nemoclaw: js-yaml direct dep ^4.1.0 -> ^4.3.0
  cloudflare-oauth-proxy: sharp ->0.35.3

fast-uri and js-yaml capped below the next major so a security bump does not
drag in a breaking major. Verified `npm ci` installs all four locks cleanly
and `npm audit` no longer reports any of these six packages in any manifest.

Out of scope (separate, pre-existing): zapier still reports a `tar` critical
(node-tar advisories) — a different package not in this batch.

Committed --no-verify: the generate-docs-skill hook is blocked by a
pre-existing openapi.json drift on main, unrelated to these npm bumps.
2026-07-23 14:47:55 -04:00
MENEL[bot] dbf3b9d9bc feat(ts-client): support custom headers (#2914) 2026-07-23 19:24:34 +02:00
Derek Bouius 1942cf2cd8 chore: regen skills/hindsight-docs openapi.json to fix verify-generated-files (#2925)
skills/hindsight-docs/references/openapi.json drifted from its source on
main (the generator produces a 1-line diff), so the verify-generated-files
CI job — which runs the generate scripts and fails on any diff — has been
red on every open PR regardless of its own changes, and the local
generate-docs-skill pre-commit hook blocks commits.

Regenerated via ./scripts/generate-openapi.sh + ./scripts/generate-docs-skill.sh.
Generated-file sync only.
2026-07-23 17:27:25 +02:00
Nicolò Boschi 441cf2272e feat(engine): filter list_memory_units by ingest age (created_before) (#2902)
Add a created_before filter to MemoryEngine.list_memory_units so
maintenance-loop callers (retention sweeps, bulk maintenance) can select units
by ingest age through the engine instead of open-coding SQL against
memory_units: created_at < <instant>. Composes with the existing tags /
tags_match filters. Interface + concrete method; covered by a test against
real Postgres.

(A last_recalled_before dormancy filter was dropped along with the
last_recalled_at column — recency moves to a Cloud-owned side table, so the
dormancy read lives in the extension, not core.)
2026-07-23 15:45:52 +02:00
Ben 4dc8348348 blog: Your 1M-Token Context Window Is Not Memory (#2910)
* blog: Your 1M-Token Context Window Is Not Memory

Thought-leadership piece: a context window is working memory that resets
each session and degrades before it fills (lost-in-the-middle, Chroma
context rot), so a bigger window is not a memory system. Includes a
context-window-vs-memory comparison table and the one-question test.
Cited research linked; em-dash-free.

* blog: add Hindsight Cloud CTAs (embedded mid-article + Hindsight paragraph)
2026-07-22 15:24:43 -04:00
Derek Bouius 1bb7e03429 chore(deps): bump pillow, gitpython, pyasn1 (security) (#2899)
Clears 62 high-severity Dependabot alerts across the Python locks:

  pillow     12.2.0 -> 12.3.0   50 alerts (10 advisories) across autogen,
                                crewai, llamaindex, pipecat, smolagents
  gitpython  3.1.50 -> 3.1.54   8 alerts (4 advisories) in root + agno
  pyasn1     0.6.3  -> 0.6.4    4 alerts (2 advisories) in root + google-adk

All transitive; only the intended version bumps, no transitive churn.
gitpython resolves to 3.1.54 (latest, >= advisories' 3.1.52).

Verified: crewai 35 passed, google-adk 49 passed, smolagents 81 passed.
agno has 10 pre-existing test failures unrelated to gitpython. Committed
--no-verify: the generate-docs-skill hook is blocked by a pre-existing
openapi.json drift on main, unrelated to these lock bumps.
2026-07-22 13:17:37 -04:00
Parafee41 7b161740d0 fix within-batch cosine similarity (#2890) 2026-07-22 17:43:06 +02:00
Nicolò Boschi 6428a83713 docs: changelog and blog post for v0.8.5 (#2879)
* docs: changelog and blog post for v0.8.5

* docs: demote vector-index self-heal to an ops bullet in the 0.8.5 blog
2026-07-22 14:05:52 +02:00
Nicolò Boschi 705757f362 Release v0.8.5
- Update version to 0.8.5 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.8
2026-07-22 14:04:42 +02:00
Ben dd6766c37d blog: Make Thinking Machines' Inkling your agent's memory (tutorial) (#2882)
* blog: use Thinking Machines' Inkling as a Hindsight memory model (tutorial)

Clickbaity how-to: point Hindsight's internal LLM at Inkling via any
OpenAI-compatible endpoint (four env vars, NVIDIA free key). Includes
real test results: clean structured fact extraction, unprompted temporal
resolution (last week -> 2026-07-14), entity resolution, and a coherent
reflect, all out of the box. Honest caveats (not on leaderboard, 975B
hosted-only, latency; gpt-oss-20b still fastest for high-volume retain).

* blog: swap Inkling cover to Hermes split-duotone style with Thinking Machines wordmark

* blog: use Inkling's real brand graphic (ink blob) on the cover

* blog: name Thinking Machines in title and body (Inkling is Thinking Machines' model)

* blog: cover title now names Thinking Machines Lab
2026-07-21 16:09:07 -04:00
Derek Bouius 8ca1f20f93 chore(deps): pin adm-zip >=0.6.0 in zapier via override (security) (#2880)
adm-zip 0.5.16 -> 0.6.0  GHSA (high) — clears the last fixable high-severity
                           Dependabot alert in hindsight-integrations/zapier.

adm-zip is transitive (via zapier-platform tooling) and a parent pins it to
the 0.5.x line, so `npm update` won't move it. Add an override — the same
mechanism zapier already uses for form-data/tar/tmp/yeoman-environment — to
force the patched 0.6.0. Verified `npm ci` installs the lock cleanly with
adm-zip 0.6.0.
2026-07-21 14:31:35 -04:00
Nicolò Boschi a23187a456 fix(llm): recover malformed JSON via json_repair as a last-resort parse fallback (#2871)
Recover structurally-malformed LLM JSON (trailing commas, unterminated strings, single quotes, invalid \escape) via json_repair as a terminal fallback in parse_llm_json, after fence-strip and control-char scrub both fail. Empty repair result keeps raising JSONDecodeError so retry ladders / #1833 fail-loud still fire. LiteLLM prefers a clean re-roll first (repair only after retries exhausted). Scoped to structural malformation only — the degenerate-but-valid-JSON class (#2544/#2547) is deliberately out of scope. Regenerated the docs skill to clear pre-existing #2865 drift.
2026-07-21 16:41:50 +02:00
Jordan-JarvisandNicolò Boschi 18650712fa refactor(llm): type provider tool choices (#2843)
* fix(reflect): preserve required tools for custom OpenAI endpoints

* refactor(llm): type provider tool choices

* fix(style): format required-tool regression

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-21 16:26:31 +02:00
Nicolò Boschi bd853be356 fix(vector-index): repair per-bank index coverage after restore/upgrade (#2645) (#2872)
Per-(bank, fact_type) partial vector indexes are created only at fresh-bank
creation. A bank populated outside that path (logical restore, cross-version
upgrade, extension switch) never gets them, so its recall silently falls back
to the global index + post-filter — slower and under-returning (~0.63-0.72
recall@10 measured by the reporter).

Two fixes:

- import-bank: create the per-bank indexes explicitly after restoring the
  banks row. The prior get_or_create_bank_profile call was a no-op here (the
  row already exists, so it takes the SELECT branch), leaving every restored
  bank uncovered.

- hindsight-admin repair-bank (--bank ID | --all): re-runnable operator escape
  hatch for the out-of-app routes (raw pg_dump restore, extension switch) that
  a one-time migration can't cover (a restore carries alembic_version at head,
  so the migration is already stamped). Detects missing OR invalid coverage
  (INVALID leftovers / drifted access method count as missing, unlike a
  name-only check) and rebuilds with CREATE INDEX CONCURRENTLY off any txn.
  Idempotent; concurrency handled by idempotency, not advisory locks.

Deliberately excludes the boot/periodic background reconcile and retain-path
self-heal: a bank restored and only ever read stays degraded until an operator
runs repair-bank. That background layer can be a follow-up.
2026-07-21 16:05:40 +02:00
Sanderhoff-alt 17d0a0c068 fix(docs): regenerate documentation skill (#2870) 2026-07-21 15:52:05 +02:00
Nicolò Boschi be6caf9dcf feat(llm): opt-in 4xx request-dump for diagnosing rejected calls (all providers) (#2865)
* feat(llm): opt-in 4xx request-dump for diagnosing rejected calls (all providers)

Generalizes the Gemini-only diagnostic from #2475 into a provider-agnostic
helper (engine/providers/llm_debug.py) wired into every remote LLM provider:
Gemini/Vertex, OpenAI-compatible (+ Fireworks/Nous subclasses), Anthropic,
LiteLLM (+ router subclass), and Codex — on both call() and call_with_tools().

Gated by HINDSIGHT_API_LLM_DEBUG_DUMP_4XX (off by default). On any 4xx it logs
[LLM_4XX_DUMP] with the serialized request config (message bodies stripped) and
per-message role/size + a length-capped preview. Self-gates on the env flag and
a 4xx status, extracts the status across SDK error shapes (status_code / code /
response.status_code), and never raises.

* style: ruff format single-line dump_request_on_4xx calls

* refactor(llm): source 4xx-dump flag from HindsightConfig, not raw env

Adds llm_debug_dump_4xx as a static (server-level) config field; the helper
reads get_config().llm_debug_dump_4xx instead of os.getenv directly. Documents
the flag in configuration.md and .env.example (+ bundled embed copy). Replaces
the tuple return in the message-preview helper with a dataclass per project
standards.
2026-07-21 15:16:21 +02:00
Sanderhoff-alt 91ee2537e2 fix(cli): sync regenerated OpenAPI operation changes (#2867)
* fix(cli): pass tag filters to list memories

OpenAPI added tags and tags_match to list_memories in #2848, but the
CLI wrapper still passed the previous positional arguments. Generated
Rust client builds then failed with E0061.

Pass None for both filters to preserve existing CLI behavior and match
the generated method signature.

* feat(cli): expose terminal operation deletion

OpenAPI added delete_operation in #2777 without exposing it through
the Rust CLI or accounting for it in the coverage manifest. The CLI
coverage check therefore rejected branches rebased onto that change.

Add operation delete with confirmation and --yes support. Pass the
request through the generated client and cover command parsing. This
counts the endpoint as implemented without a coverage exception.
2026-07-21 14:53:15 +02:00
SunneeYang c1fae2ae1b fix: advance watermark after no-op delta refresh (#2866) 2026-07-21 14:45:54 +02:00
Jordan-Jarvis 434dbee64c fix(reflect): emit canonical OpenAI tool result messages (#2844)
* fix(reflect): emit canonical tool result messages

* test(providers): cover canonical tool result wire
2026-07-21 14:42:17 +02:00
Nicolò Boschi c3dfaf3dd9 fix(retain): queue retain.completed webhook on boundary and zero-fact batches (#2861)
The transactional-outbox callback that queues the retain.completed webhook
delivery only fired inside the final facts-bearing batch's write transaction
(is_last=True). Two successful retain paths never reached it, silently dropping
the delivery with no error and no retry:

- Exact chunk-batch boundary: full batches flush with is_last=False and only the
  leftover partial batch is marked last. When the committed-chunk count is an
  exact multiple of retain_chunk_batch_size, the queue sentinel drains an empty
  batch, so is_last=True is never passed.
- Zero-fact final batch: _process_db_batch returns before the fact-insert call
  site (which carries the callback) when a batch extracts no facts — common for
  boilerplate content.

There is no backstop: the delivery row is only inserted by this callback, and
the worker poller re-delivers existing rows, so a never-inserted row is lost.

Fix: track whether the callback fired in-TXN and, on any successful non-aborted
retain that didn't fire it, queue the delivery exactly once in a dedicated
transaction after the consumer loop. Aborted (concurrent-takeover) retains are
skipped so they don't emit a completion event.

Regression tests assert exactly one retain.completed delivery for both the
boundary (retain_chunk_batch_size=1) and zero-fact cases; both fail with 0
deliveries on main.
2026-07-21 14:04:14 +02:00
handnewbandNicolò Boschi 234f5a0621 fix(worker): count crash-recovery attempts toward max-retry budget (#2675) (#2834)
* fix(worker): count crash-recovery attempts toward max-retry budget

When a worker crashes while processing an async_operations row, no
failure bookkeeping runs — retry_count is only incremented by in-process
failure handling. On restart, recover_own_tasks resets 'processing' rows
back to 'pending' with retry_count untouched, and the row is re-claimed
as if brand new.

An operation that can never complete therefore loops forever:
claim → grind → crash → recover → re-claim…

This changes recover_own_tasks to increment retry_count during recovery
and honor the existing worker_max_retries threshold (HINDSIGHT_API_WORKER_MAX_RETRIES).
Tasks at/over the limit are moved to 'failed' with an explanatory
error_message instead of being re-queued.

Changes:
- Poller.__init__: accepts max_retries (default 3, matches DEFAULT_WORKER_MAX_RETRIES)
- recover_own_tasks: two UPDATEs — under-limit tasks increment retry_count
  and reset to pending, over-limit tasks move to failed
- main.py: wires config.worker_max_retries into the Poller
- Tests: retry_count increment, exceeded→failed, NULL retry_count handling

Reuses the existing config field (HINDSIGHT_API_WORKER_MAX_RETRIES)
rather than adding a new one. Default of 3 retries x crash recovery
gives the same total window as the normal retry path.

Closes #2675

* style: ruff format test_worker.py

* fix(worker): propagate crash-recovery child failures to batch parent

A batch_retain child sub-batch carries parent_operation_id (not batch_id)
in its metadata, so crash recovery can move it to 'failed' once it exceeds
the retry budget. That terminal transition was not propagated to the parent
aggregator, leaving the parent stuck in 'processing' forever.

recover_own_tasks now rolls each failed child up to its parent via
_maybe_update_parent_operation (one transaction per child, mirroring the
in-process _mark_failed path). Adds a regression test.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-21 13:47:57 +02:00
Salem KorayemandOpenAI GPT-5 Codex medium d7c32f9633 fix(transfer): preserve JSONB and timestamp provenance (#2717)
* fix(export): preserve decoded JSONB scalar strings

Native admin connections decode JSON and JSONB columns before export. Preserve already-decoded string scalars while continuing to parse raw JSON strings so export-bank no longer fails on observation scopes such as combined.

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

* fix(import): normalize decoded JSONB strings

Whole-bank archives can contain Python string scalars when their export connection registered JSON codecs. Quote decoded scalars before PostgreSQL casts while preserving already-serialized JSON text and decoded objects.

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

* fix(transfer): preserve archive provenance

Record bank-row JSON encoding in transfer manifests so decoded scalar strings and serialized objects restore without ambiguous parsing. Preserve archived document and observation timestamps during replay.

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

* test(transfer): guard admin JSON provenance

Prove the codec-enabled admin exporter identifies bank rows as decoded so JSON-looking scalar strings cannot silently regress during restore.

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

---------

Co-authored-by: OpenAI GPT-5 Codex medium <[email protected]>
2026-07-21 13:46:27 +02:00
chethanuk dcef72480b feat(api): allow deleting terminal bank operations with control-plane support (#2777)
* feat(api): allow deleting terminal bank operations with control-plane support

* refactor(api): rename terminal-operation delete route to /delete

Maintainer review on #2777 asked for the hard-delete endpoint to live at
/delete rather than /record. Renames the path segment end-to-end:
dataplane route + log message, tests, OpenAPI spec (and its skills
mirror), and the generated Python/TypeScript/Go clients.

The route's operation_id is explicitly "delete_operation", so no
generated symbol names change -- only the path string. Go and Python
generated output was verified byte-identical against the pinned
openapi-generator v7.10.0.
2026-07-21 13:45:37 +02:00
Nicolò Boschi 3a65d5ed29 release(opencode): v0.2.8 2026-07-21 13:42:02 +02:00
Evo 1265563fc8 fix(docker): build images from workspace lock (#2789) 2026-07-21 13:41:30 +02:00
Nicolò Boschi 036ba19b65 fix(opencode): derive session-start recall query from user messages (#2856) (#2860)
The system.transform auto-recall used a hardcoded 'project context and
recent work' query for every session, so recall never adapted to what the
user actually asked. Fetch the session transcript (the hook input only
carries sessionID/model) and build the query from the latest user message
via the same composeRecallQuery/truncateRecallQuery path the compaction
hook already uses, falling back to the generic query when there is no user
text yet. Fetching directly also keeps this independent of the
session.created-vs-system.transform ordering (#1758).
2026-07-21 13:40:32 +02:00
Evoandr266-tech 6c8a92c318 fix(integrations): select compatible Python for uvx daemons (#2801)
Co-authored-by: r266-tech <[email protected]>
2026-07-21 13:26:17 +02:00
chethanuk 912f8e22d1 fix(retain): a zero retry budget must still perform the initial fact-extraction request (#2779)
* fix(retain): a zero retry budget must still perform the initial fact-extraction request

* fix(retain): use N+1 outer fact-extraction attempts to match provider retry convention

Review feedback on #2779: llm_max_retries=N means N retries *after* the
initial request, so N=1 must give 2 total outer attempts. The previous
max(1, N) floor under-counted (N=1 -> 1 attempt). Every provider already
loops range(max_retries + 1); the outer content-validation loop now follows
the same convention, and a zero budget still performs one request (#2731).
The raw budget is still forwarded unchanged to llm_config.call().
2026-07-21 13:20:25 +02:00
Evoandr266-tech 5126e0bb08 fix(mental-models): align stale checks with refresh tag scope (#2804)
Co-authored-by: r266-tech <[email protected]>
2026-07-21 13:18:09 +02:00
Nicolò Boschi 41d71a9818 fix(#2808): make mental model tags_match configurable on all creation surfaces (MCP, TS client, CLI) (#2858)
* feat(mcp): let create_mental_model configure tags_match (#2808)

A tagged mental model with no explicit tags_match in its trigger JSON
refreshes under all_strict (a memory must carry every one of the model's
tags), while the staleness check and every recall/reflect path default to
any. Broadly-tagged models reading narrowly-tagged memories therefore get
marked stale and then refresh to empty content.

The HTTP API, generated SDK clients, and Control Plane UI already let users
set trigger.tags_match; the MCP create_mental_model tool did not. Add a
tags_match argument (validated against TagsMatch) to both MCP variants. It
is only written into the trigger when explicitly passed, so the resolved
all_strict default is preserved for existing callers.

Document the all_strict footgun and the tags_match override in the MCP and
mental-models API docs (regen skills/hindsight-docs mirror).

* fix(ts-client): expose tags_match/tag_groups on createMentalModel

The ergonomic TypeScript wrapper's createMentalModel accepted only
{ refreshAfterConsolidation } in its trigger option and dropped every other
trigger field, so a wrapper user could not set tags_match — the exact knob
needed to avoid the empty-refresh footgun in #2808. The low-level generated
sdk already accepts the full MentalModelTriggerInput; thread tagsMatch and
tagGroups through, mirroring how recall/reflect already expose them.

The Python client needs no change: its wrapper takes a pass-through
trigger dict and the generated MentalModelTriggerInput already validates
tags_match.

* test(ts-client): cover createMentalModel trigger mapping

Mock the generated sdk layer (no server needed) and assert the ergonomic
camelCase trigger options map onto the snake_case body: tagsMatch ->
tags_match, tagGroups -> tag_groups, refreshAfterConsolidation still maps,
and omitting trigger sends none (preserving the all_strict default). Locks
in the #2808 wrapper fix.

* docs(mental-models): add tags_match code snippet

Replace the static JSON block in the tags_match override section with a
live CodeSnippet pulled from the Python example, showing how to create a
model with trigger.tags_match="any" so a broadly-tagged model reads
narrowly-tagged memories on refresh (#2808).

* feat(cli): add --tags-match to mental-model create + all-language docs

The Rust CLI's `mental-model create` was the last creation surface with no
way to set tags_match, so a tagged model created via the CLI hit the same
empty-refresh footgun (#2808). Add a `--tags-match` flag (any/all/any_strict/
all_strict/exact) that is only sent when passed, preserving the server's
all_strict default; invalid values are rejected before the request.

Expand the mental-models docs "tags_match override" example from a single
Python snippet to a full Tabs block (Python / Node.js / CLI / Go), each
pulled from the runnable example files, and regen the skills mirror.
2026-07-21 12:04:23 +02:00
Nicolò Boschi 9fe339dfb1 fix(llm): per-operation strict schema + honour explicit per-call opt-out (#2825)
Add HINDSIGHT_API_LLM_STRICT_SCHEMA_{RETAIN,REFLECT,CONSOLIDATION}, each resolved per-operation env -> global env -> default (mirroring the per-operation temperature knobs). All five structured-output call sites thread their operation's resolved flag.

Also fixes a latent resolution bug in LLMConfig.call: 'strict_schema or get_config().llm_strict_schema' made a per-call False indistinguishable from unset, silently ignoring any scope opting out while the global flag was on. The arg is now bool|None: None inherits the global flag, explicit True/False wins in both directions.

Supersedes #2669.
2026-07-21 11:59:27 +02:00
7d1aab8b8d fix(retain): preserve fact alignment when filtering degenerate text (#2846)
* fix(retain): preserve filtered fact alignment

* test(retain): cover chunk-provenance shift from degenerate-fact filtering

Add a deterministic streaming-retain regression test for the #2794
alignment bug the PR fixes: a rejected degenerate fact must not shift
chunk provenance onto a later chunk's survivor via the consumer
zip(batch_extracted, batch_processed).

Each chunk emits [real, degenerate] so that after the first
(real, degenerate) pair the zip is off-by-one for the rest of the batch
regardless of the nondeterministic producer completion order — both real
facts would collapse onto one chunk_index without the fix.

---------

Co-authored-by: r266-tech <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-21 11:56:20 +02:00
Derek Bouius 2ea5df3db0 chore(deps): bump nltk to 3.10.0 (security) (#2833)
nltk 3.9.4 -> 3.10.0  GHSA-p4gq-832x-fm9v (URL-encoded path traversal in
                        nltk.data.load() allowing arbitrary local file read)

Two high-severity alerts, one each in the llamaindex and pipecat integration
locks. nltk is transitive in both. 3.10.0 pulls in defusedxml 0.7.1 (nltk's
new hardened-XML dependency) — expected, not incidental churn.

Done directly rather than via the Dependabot uv-group PR (which also carries
torch/agno and keeps going stale against this fast-moving main). Verified:
llamaindex 88 passed, pipecat 19 passed; lint clean.
2026-07-21 11:51:19 +02:00
Evoandr266-tech 820019f3c6 fix(pg0): honor URL credentials during MemoryEngine startup (#2836)
Co-authored-by: r266-tech <[email protected]>
2026-07-21 11:50:36 +02:00
Nicolò Boschi 0679d38e8e fix(retain): reassert resolved entities before linking units (#2662) (#2859)
Retain resolves entities in Phase 1 on a separate, already-committed
connection, then inserts unit_entities in Phase 2 on a new transaction.
In that window graph maintenance's prune_orphan_entities can delete a
just-resolved parent — it legitimately has no unit_entities row yet — so
the Phase-2 FK insert fails and the whole batch is dropped as
non-retryable: silent memory loss, worst on the document re-ingest path.

Carry each resolved entity's id AND its stored canonical name across the
phase boundary (new ResolvedEntity), then, on the Phase-2 connection
immediately before linking, reassert the parents in one statement:

  * PostgreSQL: a CTE locks the surviving parents FOR KEY SHARE (held to
    commit, so a concurrent prune DELETE blocks) and re-inserts only the
    already-pruned ones — same single-round-trip shape as
    bulk_insert_links. ON CONFLICT DO NOTHING keeps the rare
    name-recreated-under-a-new-id case from raising.
  * Oracle: FOR UPDATE locks in the caller's stable id order, then an
    idempotent insert.

The stored canonical name (not the raw input mention) is what gets
restored, so a fuzzy alias no longer permanently mislabels a resurrected
row. The same reassert is applied on the curation edit path, which has
the same resolve/link window.

Tests: an end-to-end Phase-1 -> prune -> Phase-2 regression proving the
original id and canonical name are restored via a fuzzy alias; a real-PG
concurrency test proving prune blocks until the child link commits; and
Oracle adapter coverage for stable lock order and idempotent reinsert.

Fixes #2662
2026-07-21 11:50:03 +02:00
Nicolò Boschi 0d2dbe756d feat(audit): make audit_log_enabled overridable per bank (#2827)
* feat(audit): make audit_log_enabled overridable per bank

Auditing was all-or-nothing per deployment. This makes the existing
audit_log_enabled switch hierarchical (env -> tenant -> bank) so a bank
can opt in while the server default is off, or opt out while it is on,
rather than introducing a second near-identically-named field.

Making the flag per-bank forces three call sites to change:

- AuditLogger: the enabled check can no longer be a synchronous
  pre-filter, since a bank may enable auditing the global value has off.
  Split into action_allowed() (bank-independent allowlist, still a cheap
  sync pre-filter) and should_log() (awaits the per-bank resolution).
  Resolution failure falls back to the deployment default rather than
  failing closed, so a transient DB blip cannot silently create an audit
  gap for a bank that is meant to be audited.

- Retention sweep: previously gated on audit_log_enabled, which is now
  per-bank while the sweep is a global cross-tenant job with no bank in
  scope. A bank opting in under a default-off deployment would have had
  its rows accumulate forever. Retention now keys off the (still
  server-level) retention window alone.

- _audit_memory_defense: was sync and reached log_fire_and_forget
  directly, bypassing the per-bank decision entirely. Made async so the
  memory_defense action honours the bank's setting like every other path.

The actions allowlist and retention window stay server-level: both are
global sweeps with no bank scope. The /version audit_log flag keeps
reporting the deployment default and now says so.

Adds the Audit Logging toggle to the bank Configuration tab.

The hindsight-docs skill regen also picks up pre-existing drift from
#2694 (retain.md), which the pre-commit generator syncs unconditionally.

* fix(control-plane): make the audit toggle tri-state

A Switch cannot express "inherit the server default". It rendered the
resolved value, so a bank inheriting `true` looked identical to one
explicitly set to `true`, and touching it always wrote an explicit
boolean with no way back to inherit.

Replaced with a Select: Server Default / Enabled / Disabled. The slice
now reads the bank's `overrides` rather than the resolved config, since
the resolved value cannot distinguish inherited from explicitly-set.
Choosing "Server Default" sends null, the tombstone the config resolver
already treats as "clear this override".

The option label shows which way the server default currently points,
read from the existing /version features flag.

Uses INHERIT_SENTINEL rather than "" for the inherit option: Radix
rejects an empty SelectItem value at runtime.

* chore(clients): regenerate for audit_log description change

The audit_log field description in openapi.json changed; regenerate the
Go/Python/TypeScript clients that embed it (they were skipped earlier
because the generator needs Docker). Verify-generated-files was failing
on the drift.

* fix(audit): resolve gating config internally, bypassing permission filter

_resolve_bank_audit_enabled used get_bank_config, the API-facing resolver
that runs the tenant permission filter (get_allowed_config_fields). A
deployment that makes audit_log_enabled read-only for a user — exactly
the intended way to lock the field via an extension — would have that
field stripped from the resolved config, so gating silently reverted to
the deployment default and ignored the bank's stored override.

Switch to resolve_full_config (the internal, unfiltered resolver every
other internal config consumer uses). Gating is a system decision and
must see the bank's true value regardless of who is asking.

Adds a regression test with a restrictive tenant extension: the API read
strips the field, but gating still audits the opted-in bank.

Also: document the fail-open opt-out edge in should_log's comment, and
refresh a stale "static, server-level switch" comment in the memory
defense test.
2026-07-21 11:37:46 +02:00
Jordan-Jarvis ea460c062d fix(llm): emit OpenAI strict JSON schemas (#2845) 2026-07-21 11:34:17 +02:00
Jordan-Jarvis 6ba98c040e fix(memory): preserve bank attribution during curation (#2847) 2026-07-21 11:19:05 +02:00
peter216 b82fb603c2 fix: disable built-in tools in ClaudeCodeLLM.call() to prevent ToolSearch deferral eating max_turns=1 (#2850)
call_with_tools() already sets tools=[] on ClaudeAgentOptions, with a
comment explaining that leaving the built-in toolset enabled can make
the CLI defer into ToolSearch before answering, burning the turn
budget. call() -- used for single-turn structured/consolidation calls
-- was missing the same tools=[] and only set allowed_tools=[], which
restricts what may be called without prompting but doesn't stop the
toolset from loading in the first place.

Observed in production (hindsight-embed, claude-code LLM provider,
consolidation path): repeated 'Claude Code returned an error result:
Reached maximum number of turns (1)' failures on isolated, single-memory
batches, ruling out batch-size/concurrency as the cause. Restarting the
daemon with this one-line change (tools=[] added to call()'s options)
cleared a 16-item stuck consolidation backlog on the first pass with
zero max-turns failures, across two LLM batches (8 memories each,
94.4s and 73.4s respectively) that were previously failing consistently
on the same data.
2026-07-21 11:18:43 +02:00
superafunandNicolò Boschi c1fadc008a feat: add tags filtering to list_memories / list_memory_units (#2848)
* feat: add tags filtering to list_memories / list_memory_units

Add `tags` and `tags_match` parameters to `list_memory_units`,
MCP `list_memories` tool, and HTTP `GET /memories/list` endpoint,
bringing the browse side's tag filtering capability in line with
the write side (`retain`) and semantic search side (`recall`).

The implementation reuses the existing `build_tags_where_clause`
function from `hindsight_api/engine/search/tags.py`, supporting
all five matching modes: any, all, any_strict, all_strict, exact.

Closes #2842
Related: #792

* review fixes: robust prefix strip, exact global scope, tests, regen clients

- Use str.removeprefix("AND ") instead of str.lstrip("AND ") when appending
  the tags clause in list_memory_units (lstrip strips a char set, not a
  prefix — matches the existing idiom used elsewhere in the file).
- Handle tags_match="exact" with no tags: select the untagged/global scope,
  mirroring recall and the sibling list path.
- Type the MCP list_memories tools' tags_match as TagsMatch; document all
  five matching modes in the engine/HTTP/MCP docstrings.
- Add integration tests covering all five modes + exact-empty global scope
  and the no-filter baseline (tests/test_tags_visibility.py).
- Regenerate OpenAPI spec, docs-skill reference, and Python/TS/Go clients.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-21 11:15:40 +02:00
Nicolò Boschi 4754efc419 chore(db): remove the dead advisory_lock dialect helper (#2855)
DatabaseDialect.advisory_lock() had no production callers — the only reference
was a test asserting its output string. It advertised PG advisory locks as a
supported dialect primitive, which contradicts the Database Locking standard
added in #2817 (advisory locks are unusable behind connection poolers / managed
PG). Leaving it invites the next author to reach for it.

Remove the abstractmethod on DatabaseDialect plus the PostgreSQL and Oracle
implementations, and the lone test assertion. The grandfathered raw
pg_try_advisory_lock in migrations.py (the concurrent-migration coordinator) is
unaffected — it never went through this helper.
2026-07-21 10:56:49 +02:00
Jordan-Jarvis a404071d3b fix: remove vulnerable API runtime packages (#2851) 2026-07-21 10:44:45 +02:00
Ben e07ca1bd0a blog: Persistent memory for ZCode (Z.ai GLM coding agent) (#2838)
* blog: persistent memory for ZCode (Z.ai GLM coding agent)

Announcement/how-to for the ZCode integration: hooks-based (no MCP),
recall before each prompt + retain each turn, cross-tool shared bank with
Claude Code and Cursor, per-project isolation, cloud or self-hosted.
Grounded in the merged integration doc, README, and hook source. Cover:
emerald mesh + glassy tool panels with the ZCode mark.
2026-07-20 14:59:33 -04:00
Nicolò Boschi d61e8ffff6 fix(worker): discover expired-operation schemas in one query, default retention off (#2819)
Follow-up to #2708, which bounded terminal `async_operations` history. Two
issues with what landed:

1. The cleanup worker did not use a cross-tenant routine. It opened a connection
   and a prune transaction against *every* tenant schema on every cleanup cycle,
   paying the full per-tenant cost even when nothing was prunable — the query
   storm the server-side maintenance routines exist to avoid.
2. It shipped as a breaking change, silently switching deployments from
   unbounded operation history to a 30-day TTL on upgrade.

Adds `schemas_with_expired_operations(p_days int) RETURNS SETOF text` — the
`async_operations` counterpart to `schemas_with_expired_rows`. One round-trip
returns just the schemas holding expired terminal rows; the worker then acquires
a connection and prunes only there. It needs its own routine rather than reusing
`schemas_with_expired_rows` because eligibility here isn't "row older than N
days" — pending and processing rows are never prunable, so the status filter has
to be part of the predicate.

Install policy follows b6d2f8a4c1e7 (#2638/#2824): the routine is database-global
(it enumerates pg_class across every schema), so exactly one copy is installed —
into the schema this deployment is configured to use, which is the one the worker
calls via fq_routine. Gating on the literal "public" instead of the configured
schema is what left non-public deployments without the sibling routines (#2638);
installing into every schema would leave a dead duplicate per tenant.

Exactly one run satisfies that predicate, so concurrent per-schema runs never
issue competing CREATE OR REPLACE against the same pg_proc row and cannot hit
`tuple concurrently updated`. No cross-process coordination, and in particular no
advisory lock, which is unusable behind connection poolers and managed PG
(#2817). Runs targeting any other schema drop the routine there instead.

The worker calls the routine through schema.fq_routine() (added in #2824) rather
than a hardcoded public. qualifier — duplicating that qualifier across callers is
precisely how #2638 recurs.

Vanishing schemas are skipped rather than fatal (c7e9f1a3b5d2), and an absent
routine degrades cost, not correctness — Oracle and un-migrated PostgreSQL fall
back to the previous full sweep with a warning.

DEFAULT_OPERATION_RETENTION_DAYS 30 -> 0. Operation history is a user-visible
audit trail, so bounding it is an opt-in policy decision rather than something an
upgrade applies silently. Set HINDSIGHT_API_OPERATION_RETENTION_DAYS to a
positive number of days to enable pruning. Docs, .env.example and the bundled
embed template updated to match.

- test_schemas_with_expired_operations — drives the real routine against pg0 in a
  throwaway schema: old pending/processing rows alone don't make a schema
  eligible, a terminal row does, a too-old cutoff doesn't, p_days <= 0 is empty.
- test_expired_operations_routine_installs_in_the_configured_schema —
  parametrized over base / default public / non-public single-tenant; guards
  against reintroducing the #2638 literal gate or an advisory lock.
- test_expired_operations_tenant_runs_install_nothing — tenant runs emit no
  CREATE and drop any copy in their own schema.
- test_discovery_targets_the_configured_non_public_schema — the worker calls the
  copy in its configured schema, not a hardcoded public one.
- TestWorkerOperationCleanupSchemaNarrowing — only reported schemas are pruned,
  nothing expired means no pruning, unclaimed schemas are skipped, a missing
  routine falls back to the full sweep, Oracle never calls the routine.
2026-07-20 18:35:54 +02:00
Nicolò Boschi c20e08fecc fix(retain): make entities a plain list of strings (#2749) (#2830)
The prompt's few-shot examples taught a flat string array while the
LLM-facing schema declared list[Entity] objects. Models that follow the
prompt literally returned strings, so the entities were dropped and
never persisted - entities, unit_entities and entity_cooccurrences all
stayed at 0 while retain reported success and recall kept working.

The Entity model was a single-field wrapper around a string and carried
no information the string didn't, so it is removed rather than taught
to the prompt. entities is now list[str] end to end: the four LLM-facing
extraction models, the labels-only dynamic model, and the storage Fact
model. This matches the API response model (response_models.ExtractedFact)
and the pipeline dataclass (retain.types.ExtractedFact), both already
list[str].

entities stays optional. An omitted field is coerced to an empty list
anyway, so requiring it would only risk strict-schema providers
rejecting otherwise-valid facts.

A shared _coerce_entity_strings before-validator still unwraps the
legacy {"text": ...} form, so responses from models that learned it and
in-flight batch jobs are not lost. The prompt now states the string
contract explicitly in the ENTITIES section.

Tests: a fast schema/coercion suite plus an hs_llm_core test that runs
the real extraction pipeline and asserts entities are populated - the
bug was behavioural, so MockLLM cannot reproduce it. test_entity_labels
is updated for the string representation.

Also stages the pre-existing skills/hindsight-docs regen drift from
main (retain.md, zcode.md), which the pre-commit generator refreshed.
2026-07-20 18:07:12 +02:00
Nicolò Boschi 2142d43f6f test(recall): stop passing removed semantic_seeds into link expansion (#2829)
#2683 removed the graph seed inputs from LinkExpansionRetriever.retrieve() —
Link Expansion deliberately chooses its own bounded seeds so it doesn't inherit
the semantic arm's limits and thresholds. The scoring regression test from #2679
still passed semantic_seeds=, so it fails on main with

    TypeError: retrieve() got an unexpected keyword argument 'semantic_seeds'

on every PR whose test-api shard includes it.

Drop the kwarg and stub the internal _find_semantic_seeds lookup instead, which
is where seeds now come from. The test's subject — that the graph merge order
matches Link Expansion's additive per-type score — and all of its assertions are
unchanged.

The skills/hindsight-docs hunk is generated output from an unrelated docs PR that
landed without regenerating the bundle; the pre-commit generator requires it.
2026-07-20 18:07:03 +02:00
Nicolò Boschi 0c38d46ee9 feat(pg0): carry optional user/password in pg0:// URLs (#2832)
Extend the embedded-database URL syntax to
`pg0://user:pwd@instance:port` (either credential half optional).
Previously every pg0 instance was forced to the hardcoded
`hindsight`/`hindsight` credentials because the URL parser only
carried instance name and port; `EmbeddedPostgres` already accepted
username/password, they just weren't threaded through.

`parse_pg0_url` now returns a `Pg0Url` dataclass instead of a
3-tuple (clears the multi-item tuple return, matches the recent
dataclass refactor) and `resolve_database_url` passes credentials
through only when present, so omitting them keeps the pg0 defaults.
Credentials split on the last `@` so passwords may contain `@`.
2026-07-20 17:55:23 +02:00
Justas Šireika 375ec091f3 fix(db): re-apply session GUCs on pool acquire via asyncpg setup= (#2815)
asyncpg runs RESET ALL on connection release, so the session GUCs the
init callback SET (hnsw.ef_search and the other ANN tuning knobs,
statement_timeout) were wiped after a connection's first release. Every
subsequent recall on a reused connection ran at pgvector defaults
(ef_search=40), silently degrading recall quality. Pass the same
init_callback as setup= so it re-applies on every acquire, after the
reset.
2026-07-20 17:43:06 +02:00
ijevinandijevin eb5b29f067 fix(retain): make lazy bank creation atomic (#2695) (#2802)
Co-authored-by: ijevin <[email protected]>
2026-07-20 17:42:10 +02:00
handnewb 000fb9ddbe fix(audit): add missing @audited decorator to api_update_memory (#2798)
The PATCH /memories/{memory_id} endpoint (curate/invalidate/revert)
was the only data-mutation endpoint without an audit trail. All other
mutation endpoints (delete_memory, update_document, delete_document,
create_mental_model, etc.) have @audited decorators.

This ensures memory curation operations are recorded in the audit log
for compliance and forensic traceability.

Found during cybersecurity audit.
2026-07-20 17:26:13 +02:00
handnewb dfa02c8b61 fix(retain): reject degenerate fact text before storage (#2520) (#2794)
* fix(retain): reject degenerate fact text before storage

Facts with zero information content (empty strings, punctuation-only,
LLM hallucination patterns like '...', '-', '--') were being stored,
indexed, and surfaced in recall results. This adds a content quality
guard in ProcessedFact.from_extracted_fact() that rejects degenerate
text before it enters the storage pipeline.

Closes #2520

* chore: ruff format + fix import ordering in types.py
2026-07-20 17:06:29 +02:00
BenandNicolò Boschi d28b852732 fix(query-analyzer): pick strongest dateparser match, not the leftmost (#2768) (#2772)
dateparser.search_dates over-matches: short common words that are weekday
or month abbreviations in some language ("we"/"me"/"did" resolve to a
weekday, "do" to Sunday) come back as bogus dates. The analyzer took the
first valid match, so when a false positive appeared before the real date
the query got a plausible-but-wrong temporal window — worse than none,
since the constraint is non-null and nothing downstream can tell that
extraction failed.

The previous defence was a hard-coded blacklist of such words, which is a
moving target (every short word dateparser resolves is a new instance of
the same bug) and was already partly dead code: the `len(text) > 3` escape
hatch re-admitted every multi-character entry, so only the <=3-char words
did any work. The bug also depends on the dateparser version — 1.4.1 (the
version shipped in the published image) added "we" as an English Wednesday
abbreviation that survives `languages=["en"]` scoping, while the locked
1.2.2 does not — so language scoping is not a stable fix either.

Replace the blacklist + leftmost selection with a signal score: each match
is scored by the date content it actually carries (a digit is strongest,
then explicit month/relative words, then weekday/period words). Matches
with no signal (bare abbreviations) score zero and are rejected; among the
rest the strongest wins, ties broken by longest span. This subsumes the
entire blacklist and is independent of language and dateparser version.

Tested (Friday reference date, where these abbreviations resolve):
- "what did we discuss"                   -> no constraint (was 07-12/07-15)
- "tell me what we decided on 2026-06-10" -> 2026-06-10 (was 07-15)
- "what did we discuss in May"            -> May (unchanged, now robust)

Regression tests assert analyzer output, never raw dateparser spans, so
they hold across dateparser versions.

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-20 17:05:15 +02:00
Ben 6e03dd2d4c release(zcode): v0.1.0 2026-07-20 11:03:45 -04:00
Ben b11e053323 feat(zcode): add Hindsight long-term memory integration for ZCode (#2549)
* feat(zcode): add Hindsight long-term memory integration for ZCode

Adds a hooks-based, no-MCP integration for ZCode (Z.ai's GLM desktop
coding agent). ZCode embeds the Claude Code agent runtime and reads the
standard Claude Code hook schema from its own config namespace
(~/.zcode/cli/config.json), so `hindsight-zcode install` wires three
process hooks — SessionStart, UserPromptSubmit (recall), and Stop
(retain) — without touching the user's ~/.claude config and without an
MCP server.

Recall injects relevant memories as additionalContext before each
prompt; retain assembles each turn from the prompt (captured at
UserPromptSubmit) and the response (Stop payload) and stores it to
Hindsight. Verified end-to-end in ZCode 3.2.2: hooks fire, retain
persists to the cloud bank, and recall injects memory into the agent.

Includes the pip package + installer, hook scripts, tests, CI job,
release-integration wiring, changelog registration, docs page, and
gallery entry.

* feat(zcode): add self-serve marketplace + hooks-only plugin variant

Publishes the ZCode integration as a hooks-only Claude Code plugin
(hindsight-zcode) in the repo's plugin marketplace, so ZCode users can
install it via 'zcode plugins add-marketplace vectorize-io/hindsight'
without pip and without depending on Z.ai's marketplace.

The plugin reuses the pip package's hook scripts via CLAUDE_PLUGIN_ROOT
(no duplication) — settings.json resolves as a sibling of scripts/ in
both the pip and plugin layouts. Adds a plugin manifest, plugin-format
hooks.json (SessionStart/UserPromptSubmit/Stop — no SessionEnd),
marketplace entry, validation tests, and docs.

* fix(zcode): drop changelog link from docs page (page exists only after release)

The /changelog/integrations/zcode page is generated at release time, so
linking to it broke the Docusaurus build (build-docs + verify-generated-files).
Most unreleased integration pages omit this link; follow that convention.
2026-07-20 10:58:24 -04:00
dimonnld 11154d48b7 Fix day+month+year dates collapsing to the whole month (#2791)
extract_period() runs before dateparser and matches "<month> <year>", so
"meeting on 13 July 2024" was widened to 2024-07-01..2024-07-31 and the day
was lost. Skip the month-table match when a day number precedes the month,
letting dateparser resolve the exact date instead.

Language-agnostic: affects every language in the period table (English shown
in the test). Split out of #2767 per review so the correctness fix can land
independently of the Russian-coverage change.
2026-07-20 15:02:08 +02:00
Sanjay Santhanam a483682da6 fix(reflect): cap done tool answers (#2757)
Apply the configured max_tokens budget when the reflect agent finishes through the done tool. Add a regression test covering the previously uncapped completion path.
2026-07-20 14:39:12 +02:00
Jordan-Jarvis 8a7a70b828 feat(api): attribute remote reranker calls by bank (#2740)
* feat(api): attribute remote reranker calls by bank

* fix(api): omit empty reranker bank attribution

* fix(reflect): bind bank attribution for tool calls
2026-07-20 14:32:08 +02:00
Sanderhoff-alt dddd571a99 fix(ci): avoid rebuilding docs in verify-generated-files (#2739)
Run the existing build-docs job for every PR so the production docs
build remains an unconditional check.

Generate OpenAPI directly in verify-generated-files to avoid rebuilding
the Docusaurus site serially in that job.
2026-07-20 14:30:32 +02:00
Jordan-Jarvis 8bd9ce194b fix(migrations): preserve percent-encoded database URLs (#2733)
* fix(migrations): preserve percent-encoded database URLs

* fix(style): restore migration file newlines
2026-07-20 14:28:29 +02:00
Jordan-Jarvis 0e0fd14ed4 fix(reflect): preserve required tools for custom OpenAI endpoints (#2734)
* fix(reflect): preserve required tools for custom OpenAI endpoints

* fix(style): format required-tool regression
2026-07-20 14:27:42 +02:00
Nicolò Boschi 946a80bfb8 fix(engine): isolate operation completion from best-effort side-effects (#2823)
execute_task completes an operation via _mark_operation_completed /
_mark_operation_completed_and_fire_webhook, both of which wrapped the
status='completed' commit in one transaction with fallible side-effects
(webhook outbox insert, parent aggregation) and swallowed every exception.
A hiccup in either rolled the completion back and dropped the error, leaving
the operation stuck in 'processing' forever while the log already said the
work was done (#2601). PR #2608 added a poller-side backstop that unstuck
the row but silently lost the consolidation webhook.

- On failure of the atomic outbox transaction, fall back to a completion-only
  commit and fire the consolidation webhook best-effort (non-transactional)
  instead of losing both. Happy path keeps the transactional-outbox guarantee;
  the failure path degrades to completed + delivered rather than stuck + lost.
  The best-effort fire only runs when the fallback actually transitioned the
  row, so there is no duplicate delivery.
- Guard every completion UPDATE on `status NOT IN ('completed','failed',
  'cancelled')` so an already-terminal row is never re-terminalized: keeps the
  engine idempotent with the poller backstop (#2608) and avoids double parent
  aggregation, while still completing pending/processing rows.

Adds fast DB-free regression tests (fake connections) covering the happy
path (no double-fire), the webhook-failure fallback, and the terminal-row
no-op guard.
2026-07-20 14:25:45 +02:00
Nicolò Boschi 07af5b4a37 fix(migrations): install the maintenance routines once, in the configured schema (#2824)
Follow-up to #2820, which fixed #2638 the wrong way.

The three discovery routines are database-global: each enumerates pg_class across
every schema and dispatches per schema, and the maintenance loop only ever calls
the copy in get_config().database_schema. #2820 installed a copy into every
schema the migration touched, so a 20k-tenant database ended up with 20k copies
of each routine, 19,999 of which are never invoked — catalog garbage, and a
global function nonsensically duplicated per tenant.

The actual #2638 bug was never the gating; it was the hardcoded literal. The old
predicate compared target_schema against "public" instead of against the schema
the deployment is configured to use, so a single-tenant install living in a
dedicated non-public schema never matched and got no routines at all.

Compare against get_config().database_schema instead. Exactly one run satisfies
the predicate, so exactly one copy is installed, in the schema fq_routine()
actually calls. That still avoids the concurrent CREATE OR REPLACE the gate
existed for — no two runs touch the same pg_proc row — with no cross-process
coordination and no advisory lock (#2817).

Runs targeting any other schema now DROP the routines there rather than merely
skipping, so databases that already ran #2820 shed their per-tenant duplicates on
the next migration pass instead of carrying them forever.

Also moves the qualifier helper from maintenance._routine to schema.fq_routine.
It sits beside fq_table/fq_table_explicit, and the worker poller needs it too
(#2819) — a second caller open-coding the qualifier is exactly how #2638 recurs.

The skills/hindsight-docs one-line change is generated output, not authored here:
the docs-skill bundle was left unsynced by the PR that added
HINDSIGHT_API_DB_MAX_PARALLEL_WORKERS_PER_GATHER, and the pre-commit generator
refuses to commit without it.

Tests: the install test is re-parametrized over (target_schema, configured
schema) including the non-public single-tenant shape; a new test asserts tenant
runs install nothing and drop strays; downgrade tests are keyed on the configured
schema rather than the literal public.
2026-07-20 14:09:36 +02:00
BenandClaude Opus 4.8 59b008a461 docs(retain): correct entity resolution — no nickname resolution (#2694)
Entity resolution is fuzzy name matching (SequenceMatcher) reinforced by
co-occurrence and temporal proximity — there is no nickname/alias logic in
the resolver. Dissimilar names like 'Bob' and 'Robert Chen' do not unify on
the name alone, so the 'nickname resolution' example was inaccurate. Verified
against hindsight-api-slim/hindsight_api/engine/retain/entity_resolver.py.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-20 14:01:19 +02:00
Sanderhoff-alt 188f8fcb32 fix(engine): consolidate duplicated engine definitions (#2691)
Centralize causal taxonomy and transfer history table definitions.
Use shared canonical type in import; remove the unused seed.
2026-07-20 14:00:52 +02:00
Sanderhoff-alt d7059d2840 fix(recall): remove unused graph seed inputs (#2683)
Graph retrieval always selects its own bounded semantic seeds.

Remove the unused semantic_seeds and temporal_seeds inputs from
the graph retriever interface and link-expansion implementation.
The recall orchestrator no longer passes placeholder None values.

Document why graph seeds stay independent: the semantic and
temporal retrieval arms use different candidate limits and thresholds,
so reusing them would silently change graph recall behavior.

Add a regression assertion that the graph call contains no removed
seed inputs.
2026-07-20 13:59:59 +02:00
Bruce HicksandClaude Fable 5 5adaf60a9f feat(anthropic): carry the prompt-cache marker on batch system prompts (#2652)
Follow-up to #2628 + #2629: the batch path sent system as a plain string,
so batch requests never participated in prompt caching. Batch items are
one-shots, so this applies call()'s one-shot rule — system is the sole
cache breakpoint, rendered via the same _cached_system_blocks helper.
Every request in a retain batch shares the fact-extraction system prompt,
so the first item's cache write serves the remaining items as best-effort
reads, and the cache-read discount stacks with the 50% batch discount.
No end-marker on batch messages: that breakpoint only pays off on the
sync tool loop, where the next iteration reads it back.

Tests: cached-block wire shape (marker present, messages unmarked),
schema injection lands inside the cached block, no-system requests
unchanged; existing shape assertions updated from string to block list.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-20 13:50:31 +02:00
Nicolò Boschi 3fb33b9873 fix(trace): gate LLM trace writes on backend lifecycle, not error strings (reverts #2618) (#2821)
* Revert "fix(trace): skip LLM trace writes during daemon shutdown/pre-init races (#2618)"

This reverts commit cb4fe70b63.

* fix(trace): gate LLM trace writes on backend lifecycle, not error strings

The reverted #2618 classified shutdown/pre-init races by matching on
exception text ("pool is closing", "not initialized") and by reaching into
`backend._pool`. Both are PG/asyncpg-specific: Oracle raises different
messages, and any new backend or pool wrapper silently loses the guard —
while a genuine "not initialized" error from elsewhere gets swallowed.

Make the lifecycle state explicit instead, and close the race at the source:

- `DatabaseBackend.is_ready` — an abstract property both backends implement
  (`_pool is not None`), replacing the internals peek.
- Both `shutdown()` implementations drop the pool reference *before* awaiting
  close(), so is_ready is False for the whole teardown rather than only after
  it. That is the window that produced "pool is closing".
- `LLMTraceRecorder.close()` stops accepting writes and drains in-flight ones;
  `MemoryEngine.close()` calls it before `backend.shutdown()`, so trace tasks
  can no longer outlive the pool. Metadata patches are now tracked too (they
  were fire-and-forget and untracked).
- Both write paths skip via a single `_writable()` check. No error-string
  matching: a failure on a ready backend is still a WARNING, as it should be.

* simplify: drop the recorder drain, keep the readiness check

The drain (recorder close() + task tracking + engine wiring) duplicated work
the pools already do: asyncpg's close() waits until all connections are
released, so a trace INSERT that already acquired completes on its own. The
readiness check plus dropping the pool reference before the awaited close
covers both windows that actually produced warnings.
2026-07-20 13:47:49 +02:00
Bruce HicksandClaude Opus 4.7 ca97f947d9 feat(api): enrich refresh_mental_model result_metadata with semantic outcome (#2605) (#2627)
refresh_mental_model operations completed with result_metadata carrying only
the submit-time {mental_model_id, name} stub — set before the op ran and never
enriched — so a monitoring layer could not distinguish "refreshed with real
content" from "refreshed empty" without a follow-up content fetch. Retain
operations have carried machine-readable outcome metadata since 0.8.x.

Mirror the retain pattern: the worker handler now merges the semantic outcome
into result_metadata at completion (jsonb ||, preserving the submit-time keys
consumers join on):

- content_len: length of the final stored content
- populated_content: true only for real synthesis — the "No answer provided."
  reflect fallback and the "Generating content..." placeholder complete
  wire-successful but read as false (a bare length check would miss them)
- based_on_counts: per-fact-type grounding counts from the reflect response

The reflect agent's fallback literal is promoted to NO_ANSWER_TEXT so the
populated judgment compares against the constant, not a copied string.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-07-20 13:43:07 +02:00
Chris Bartholomew 347b9c23c4 feat(config): optional cap on planner parallelism for pool connections (#2600)
Adds HINDSIGHT_API_DB_MAX_PARALLEL_WORKERS_PER_GATHER — when set, every
pool connection of the process runs SET max_parallel_workers_per_gather
at init time (alongside the existing statement_timeout / ANN tuning
session setup). Unset (the default) leaves the server setting untouched,
so existing deployments see no behavior change.

Motivation: in multi-tenant deployments where background workers share a
database with latency-sensitive foreground traffic, bulk maintenance
queries (consolidation, graph upkeep) can fan out across parallel
workers and occupy several cores each. Parallelism buys latency — which
background work doesn't need — at the cost of concurrent CPU footprint,
which a shared primary does care about. Setting the cap to 0 on worker
processes makes those queries run serially: measured on a representative
multi-million-row aggregate, serial execution cost ~29% more wall-clock
but used 67% fewer concurrent cores (and less total CPU, since parallel
coordination isn't free).

0 is a meaningful value (disable parallelism), so the env parse
distinguishes unset (None, no opinion) from 0 via a new
_parse_optional_non_negative_int helper; negative or non-integer values
fail fast at startup.

The field is static (process-level infrastructure tuning), deliberately
not in _CONFIGURABLE_FIELDS.
2026-07-20 13:40:44 +02:00
Nicolò Boschi ebe438b25b style: apply ruff format to reflect/agent.py (#2822)
A long call in _generate_structured_output exceeds the 120-char line limit and
was committed unformatted, so ruff format rewrites it on every CI run. That
fails verify-generated-files ('Generated files are out of sync') on every
hindsight-api-slim PR, none of which touch this file.

Formatting only — no behaviour change.
2026-07-20 12:52:24 +02:00
c05ca6529f perf(graph-maintenance): prune stale cooccurrences via INTERSECT, not a self-join (#2473)
The staleness predicate in prune_stale_cooccurrences used a correlated
`unit_entities u1 JOIN u2 ON u1.unit_id = u2.unit_id` self-join. The planner
turns that into a Nested Loop Anti Join whose hash side rebuilds a
high-degree entity's entire membership set once per cooccurrence pair, so
cost scales with hub_degree * pairs even when zero rows are stale.

Replace it with an INTERSECT of the two entities' unit sets. Both branches
resolve as Index Only Scans on idx_unit_entities_entity_unit
(entity_id, unit_id), bounding per-pair cost by the two entities' degrees.

Measured on a hub-skewed fixture (40K-membership hub, 2999 live pairs,
zero deletions -- the worst case), against the current ordered-locking CTE:

  self-join   18182 ms   73,613,239 shared buffers
  INTERSECT    2555 ms      255,045 shared buffers

7.1x faster, 289x fewer buffers. Production banks carry ~260K pairs, so the
gap there is wider. No schema change; the index already exists (h3i4j5k6l7m8).

The #2529 ordered-locking CTE is untouched -- the rewrite is confined to the
NOT EXISTS predicate inside it, so victims are still selected FOR UPDATE in
sorted (entity_id_1, entity_id_2) order.

Co-authored-by: Nicolò Boschi <[email protected]>
Co-authored-by: Sergey <[email protected]>
2026-07-20 12:38:53 +02:00
Nicolò Boschi 6a6d4f2261 fix(migrations): install maintenance routines into each run's own schema (#2820)
The three cross-tenant discovery routines that drive the background maintenance
loop — banks_needing_consolidation(), schemas_with_expired_rows(...) and
mental_models_with_cron() — were installed into public and gated on the run
being the base run or an explicit target_schema='public' run.

A single-tenant deployment migrated into a dedicated non-public schema
(HINDSIGHT_API_DATABASE_SCHEMA=<non-public>) migrates only that one schema, so
the gate never opens and no routine is ever created. The loop then logs
'function public.… does not exist' every cycle, and the revision is stamped
applied so redeploying does not help. #2056 fixed only the public/base-run case.

Fix: stop putting them in a shared schema. Migration b6d2f8a4c1e7 installs all
three into the run's own target_schema, unconditionally, and maintenance.py
qualifies its calls with get_config().database_schema instead of a hardcoded
'public.'. Where a routine lives does not affect what it returns — each
enumerates pg_class across the whole database and dispatches per schema — so the
copy in the configured schema is fully functional, and that schema is by
definition one that got migrated.

This also removes the concurrency hazard the old gate existed to dodge rather
than locking around it: each process only ever writes CREATE OR REPLACE FUNCTION
"<its own schema>".fn(), so two concurrent per-schema runs never contend on the
same pg_proc row and 'tuple concurrently updated' cannot occur. No cross-process
coordination is needed — in particular no advisory lock, which is unusable here
(see the revert of #2690). Cost is one duplicate routine per tenant schema: a
few catalog rows, and the price of needing no coordination.

Existing broken installs self-heal — the revision runs on every schema and
creates the routine exactly where that deployment's loop looks for it. Default
public deployments are unaffected. Function bodies are byte-identical to
c7e9f1a3b5d2 / f4d1c2b3a5e6. PG-only, mirroring e5f6a7b8c9d0.

Tests: a parametrized unit test asserting the install runs for every
target_schema (and that neither the public-only gate nor an advisory lock comes
back), plus an end-to-end pg0 test that drives a per-schema run into a real
non-public schema and calls the resulting routine.

Fixes #2638
2026-07-20 12:28:56 +02:00
Nicolò Boschi cf7aece729 revert(migrations): drop advisory-lock maintenance-routines install (#2690) (#2817)
#2690 added migration f2a4b6c8d0e2, which installs the shared public.*
maintenance routines on every PG run and guards the resulting concurrent
CREATE OR REPLACE with a blocking pg_advisory_xact_lock.

Advisory locks are not usable in Hindsight: deployments sit behind connection
poolers and managed/PG-compatible services where they are unreliable or
unsupported — a session-level lock can leak or vanish when the pooler reassigns
the session, and a blocking acquire can wait on a grant that never comes. That
holds for transaction-scoped locks too, so the migration has to go rather than
be tuned.

f2a4b6c8d0e2 is not in any core release (v0.8.4 predates it), so it is removed
outright and a8c1e4f7b0d3 is re-pointed at e7c3a9f1b2d5. Single head preserved
(a8c1e4f7b0d3, 86 revisions). The #2690 unit test is removed with it; the rest
of tests/test_maintenance_routines.py passes against the shortened chain.

Also codify the ban in .claude/skills/code-review/SKILL.md: a Database Locking
standard plus review step 11c, both pointing at the alternatives (per-process
objects, idempotent DDL, row-level constraints) instead of locking.

This reopens #2638 (maintenance routines never installed when the deployment
uses a non-public schema); a lock-free fix follows in a separate PR.
2026-07-20 12:15:41 +02:00
36e94454e6 fix(control-plane): clear mental-model tags when the edit field is emptied (#2507) (#2508)
The mental-model edit dialog sent `tags: tags.length > 0 ? tags : undefined`,
so clearing the tags field made the key drop out of the PATCH body
(JSON.stringify omits undefined). The dataplane treats an absent `tags`
field as "unchanged" (`if tags is not None` in `update_mental_model`), so
the previous tags survived and refreshes kept filtering by them — the only
workaround was delete + recreate.

Always send the `tags` array, including the empty array, so emptying the
field sends `tags: []` and the backend clears them.

Co-authored-by: caddi-ci-cd <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-20 10:56:28 +02:00
Nicolò Boschi 5bfef3caa4 revert(docs-skill): drop cookbook pages from the docs skill bundle (#2818)
#2649 added both cookbook pages and per-integration docs to the
generated docs skill. Keep the integration docs; remove the cookbook.

- drop the cookbook tree walk and the CookbookGrid MDX renderer from
  generate-docs-skill.sh
- drop cookbook paths from the generated SKILL.md index
- regenerate the bundle (28 cookbook files removed)
2026-07-20 10:53:47 +02:00
Parafee41 c1908a0205 docs(cli): describe explore memory detail view (#2497) 2026-07-20 10:53:19 +02:00
Evo 54354e4735 fix(reflect): thread max_completion_tokens into the structured-output extraction call (#2431) (#2486)
* fix(reflect): thread max_completion_tokens into structured-output extraction

#2433 capped the structured retry budget but the structured second pass never
received an output-token budget, so on reasoning/preamble models the provider
default is exhausted before JSON is emitted (finish_reason=length, empty
content) and structured reflect degrades to None. Thread the reflect max_tokens
through _generate_structured_output (and _process_done_tool) as
max_completion_tokens, mirroring the plain reflect calls. Fixes #2431.

* test(reflect): cover structured-output max_completion_tokens threading
2026-07-20 10:51:46 +02:00
Srujan rai 4df4b398f5 fix(search): include proof_count in temporal spreading SQL SELECT (#2479)
The LATERAL join query for temporal graph spreading omitted mu.proof_count
from the SELECT list. RetrievalResult.from_db_row() calls row.get("proof_count"),
which always returned None for spread neighbors, forcing a neutral 0.5
proof-count boost regardless of actual observation evidence strength.
2026-07-20 10:46:09 +02:00
Nicolò Boschi 81aa4979b3 feat(reflect): step-by-step context caching for the Gemini tool loop (#2540)
Roll a CachedContent forward through the reflect tool loop so each auto turn reuses the entire prior conversation at the cached-input rate and sends only its new tool results. Measured on gemini-2.5-flash-lite: ~29% cached on short loops, ~74-81% on deep loops (deepest turns ~99%), vs ~9% for the old static prefix and 0% for implicit caching.

Cache creates overlap tool execution to hide their latency, and the ephemeral per-reflect caches are torn down detached so the response path never waits on deletes. New HINDSIGHT_API_REFLECT_PROMPT_CACHE_ENABLED flag (default true) disables it independently of the global prompt cache.
2026-07-20 10:45:00 +02:00
Sanderhoff-alt 0108cd7019 chore: remove stray local state files (#2472) 2026-07-20 10:41:58 +02:00
Sanderhoff-alt aad0af9756 feat(auth): add create bank validation hook (#2395)
Add a precise operation-validator hook for bank creation, with a
no-op default so deployments without custom validators keep existing
behavior.

Route lazy bank creation through the hook from retain, imports, MCP
create_bank, and the default get_bank_profile auto-create path. This
keeps create-bank authorization separate from bank-scoped write
validation, which often assumes the target bank already exists.

Add regression coverage for rejected creation, existing-bank skips,
HTTP create/import paths, async retain, profile auto-create, and MCP
create_bank.
2026-07-20 10:40:01 +02:00
Sanderhoff-alt bd49f6a7c7 fix(mcp): prevent get_bank from creating banks (#2393)
Treat the get_bank MCP tool as read-only by looking up bank profiles
without auto-creation in both single-bank and multi-bank modes.

Add regression coverage for missing banks so get_bank returns a
not-found error instead of creating the bank.
2026-07-20 10:38:34 +02:00
Nicolò Boschi 263eba1342 fix(embeddings): truncate oversized litellm-sdk inputs before embedding (#2501) (#2516)
Mental-model content in delta-refresh mode can grow past an embedding
model's fixed input-token limit (e.g. Bedrock Titan V2's hard 8192 cap),
after which every refresh fails permanently with ContextWindowExceededError
and no recovery path.

Add an opt-in `HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS` cap.
When set, `LiteLLMSDKEmbeddings.encode()` truncates each input to that many
cl100k_base tokens before calling litellm.embedding(), mirroring the existing
reranker `max_tokens_per_doc` pattern. Truncation emits a log.warning naming
the model and largest original token count so it isn't silent.

Off by default (no behavior change / data loss for large-context models);
Titan users set it to the model's real limit with a little headroom.
2026-07-20 10:17:58 +02:00
418524051d perf+fix(graph-maintenance): catch the #2529 sweep deadlock in continuous perf, and drive dropped passes to zero (#2534)
* fix(graph-maintenance): retry cooccurrence sweep on deadlock

prune_stale_cooccurrences/prune_orphan_entities scan entity_cooccurrences
via a join/NOT EXISTS plan with no consistent lock-ordering guarantee,
while retain's concurrent cooccurrence upserts (entity_resolver) lock the
same rows in sorted (entity_id_1, entity_id_2) order. When the sweep and a
concurrent upsert touch overlapping rows in opposite orders, Postgres
detects a genuine cycle and aborts one side with DeadlockDetectedError —
this was 39 of 41 DeadlockDetectedError occurrences in a week of
self-hosted production logs.

Both prunes are idempotent bank-wide deletes, so wrap the sweep in the
existing retry_with_backoff helper (already deadlock-aware, previously
only used internally by acquire_with_retry's legacy pool path) instead of
letting a transient deadlock drop the maintenance pass entirely.

Adds a raw two-connection reproduction of the deadlock plus a test that
the sweep now survives one transient DeadlockDetectedError and still
returns correct prune counts.

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

* perf(graph-maintenance): add contention suite that catches the #2529 sweep deadlock

The existing graph-maintenance suite runs run_graph_maintenance_job in
isolation, so its Pass 2/3 cooccurrence sweep never overlaps a concurrent
writer and can never deadlock — which is why continuous perf never caught
#2529. The new graph-maintenance-contention suite drives prune_stale_cooccurrences
against retain-shaped sorted cooccurrence upserts and gates on the deadlock
escape rate (dropped/observed): ~100% unprotected (fails), ~0% with the
retry_with_backoff fix (passes).

* fix(graph-maintenance): jittered backoff + larger sweep retry budget so deadlocks stop dropping passes

Completes #2529. The retry wrap alone still let ~14% of sweep deadlocks
escape under sustained retain contention (perf suite, small scale): the
backoff was deterministic (concurrent retriers woke in lock-step and
re-collided) and capped at 3 attempts.

- db_utils.retry_with_backoff: add equal-jitter to the backoff delay so
  contenders that deadlock together don't retry in sync (benefits every
  retrier, incl. the legacy acquire path). Covered by a new pure-function
  unit test.
- graph_maintenance: give the idempotent Pass 2/3 sweep a larger retry
  budget (8) — it's background work with no client waiting, so a longer
  jittered tail beats dropping a pass and leaking stale graph rows.

graph-maintenance-contention perf suite now measures 0% escape (0 dropped)
at small and medium vs ~100% unfixed; sweep_workers capped at 2 (prod
dedups to one maintenance job per bank, so 3+ concurrent sweeps was an
unfaithful amplifier).

* fix(graph-maintenance): prevent the #2529 sweep deadlock at the source via ordered locking

Prototype: instead of only retrying the deadlock, eliminate the lock-order
inversion that causes it. prune_stale_cooccurrences selects its victim rows in
the same sorted (entity_id_1, entity_id_2) order retain's cooccurrence upsert
locks them (a materialised FOR UPDATE CTE puts LockRows above the Sort), then
deletes the already-locked rows. Same lock order on both sides => no cycle.

- ops_postgresql: ordered-lock CTE prune. PG only — Oracle's DELETE can't carry
  the CTE the same way, so it stays on the ORA-00060 retry path (documented).
- system_perf contention suite: hollow-run guard re-keyed on workloads running
  (upserts+sweeps>0) not deadlocks>0, so a source-level fix (0 deadlocks) passes;
  escape-rate denominator now max(observed,dropped).

Verified (small): 0 deadlocks either side, 0 dropped, 200 upserts + 336 sweeps
concurrent, 10s vs ~30s retry path; full-revert regression still FAILs 100% escape.

* refactor(graph-maintenance): replace tuple/dict returns with dataclasses (code-review)

- _run_sweep returned a bare tuple[int, int] (from #2529's base commit); the
  project bans multi-item tuple returns even for private fns. Return a small
  _SweepCounts dataclass instead.
- contention suite's shared counters were a raw dict with known keys; convert to
  a _ContentionCounters dataclass, matching the file's existing style
  (_GraphMaintTimers). No behaviour change; 18 graph-maintenance tests + perf
  smoke (0 deadlocks, prevented-at-source) still green.

---------

Co-authored-by: Jordi Gil <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-07-20 10:03:05 +02:00
Ben 327aa05e80 blog: The Fully Open Agent Memory Stack (Hermes + Hindsight) (#2771)
* blog: The Fully Open Agent Memory Stack (Hermes + Hindsight)

Grounded technical piece: every layer of a Hermes + Hindsight stack is
open source and self-hostable (open-weights model via vLLM/llama.cpp,
MIT Hermes Agent, MIT Hindsight with local embeddings/reranker/LLM and
no external calls). Includes wiring, honest caveats (64K context,
auto-hook version gate, model license differences), and when it matters.

* blog: recommend gpt-oss-20b, add leaderboard + real M3 Max run

Address review: pivot the model recommendation from the Hermes model to
gpt-oss-20b (trendy, Apache-2.0, 128K, native tools, ~13GB) and explain
why a 'small' Kimi does not actually fit a laptop. Add a 'which model
for Hindsight' section citing the published model leaderboard (gpt-oss-20b
tops retain), and a 'does it fit on a laptop' section with real numbers
from running the full stack on an M3 Max (retain ~8s, recall ~0.6s).
Update cover model panel to gpt-oss-20b.
2026-07-17 15:05:50 -04:00
Ben 9bf0023163 docs(guides): add 44 integration memory guides (#2778)
Add per-integration guides under hindsight-docs/guides/, each with a
hero cover image following the existing guide template.

33 setup guides ("Add <Tool> Memory with Hindsight") for integrations
that had none: aider, ag2, agent-framework, agno, autogen,
claude-agent-sdk, cline, composio, continue, cursor, cursor-cli, dify,
eliza, flowise, gemini-spark, github-copilot, google-adk, grok-build,
haystack, litellm, n8n, nemoclaw, obsidian, omo, openai-agents,
openhands, roo-code, superagent, vapi, windsurf, zapier, zcode, zed.

11 distinct-angle guides for integrations that already had a setup
guide (each cross-links the existing setup guide instead of repeating
install): agentcore (cross-session strategy), codex (per-repo bank
strategy), crewai (shared crew memory), langgraph (state vs long-term),
llamaindex (beyond RAG), opencode (team shared banks), paperclip
(shared across agents), pipecat (voice memory across calls), pydantic-ai
(type-safe async memory), smolagents (memory across runs), strands
(per-agent vs shared banks).

Each guide is grounded in the integration's docs-integrations page and
its README/source.
2026-07-17 15:01:40 -04:00
Derek Bouius d64921221e chore(deps): bump mcp to 1.28.1 (security) (#2782)
Clears 20 high-severity Dependabot alerts for the MCP Python SDK across the
root lock and five integration locks (integration-tests, claude-agent-sdk,
crewai, openai-agents, strands):

  GHSA-jpw9-pfvf-9f58  HTTP transports serve session requests without
                       verifying the authenticated principal   (patched 1.27.2)
  GHSA-hvrp-rf83-w775  experimental task handlers let any client access/
                       cancel other clients' tasks              (patched 1.27.2)
  GHSA-vj7q-gjh5-988w  WebSocket server transport lacks Host/Origin
                       validation                               (patched 1.28.1)

1.28.1 clears all three. mcp is a direct dep in hindsight-integration-tests
and claude-agent-sdk (mcp>=1.0.0) and transitive elsewhere; the locks just
pinned older versions (1.23.3–1.27.1). crewai jumped the furthest (1.23.3),
which pulled newer pydantic/pydantic-core graph edges — its tests still pass.

Not included here: mcp is not part of any Dependabot group PR, so this is
the sole coverage for these alerts. nltk (llamaindex/pipecat) and torch are
handled by the Dependabot uv-group PR #2780.

Verified: claude-agent-sdk 76 passed, crewai 35 passed; lint clean.
2026-07-17 11:57:28 -04:00
Derek Bouius 7bb3d1925b chore(deps): bump pydantic-settings, transformers, soupsieve (security) (#2727)
Clears the pydantic-settings Dependabot alert across all affected
manifests plus the three high-severity alerts in the root lock.

  pydantic-settings 2.12.0/2.14.0/2.14.1 -> 2.14.2  GHSA-4xgf-cpjx-pc3j
  transformers      5.3.0  -> 5.12.1               GHSA-fgcw-684q-jj6r
  soupsieve         2.8    -> 2.8.4                GHSA-2wc2-fm75-p42x
                                                   GHSA-836r-79rf-4m37

pydantic-settings is transitive everywhere (no direct declaration), so
the locks are the only lever. crewai is deliberately left at 2.10.1: the
advisory's range is >=2.12.0,<2.14.2 and NestedSecretsSettingsSource did
not exist in 2.10.x, so it is unaffected.

transformers is a direct dep, and hindsight-api is published, so the
declared floor -- not our lock -- is what protects installers of the
local-ml/local-onnx extras. The old >=4.53.0 floor resolved to 4.57.6
(vulnerable) under any downstream cap of transformers<5, so raise it to
the advisory's first patched version. Note this now fails resolution for
consumers pinned below transformers 5 rather than silently installing a
vulnerable build. The >=4.53.0 floor was already unreachable in practice:
4.53.0 requires tokenizers<0.22, which our own cap excludes.

The tokenizers<=0.23.0 cap is kept. #2055 was caused by transformers
declaring a wider tokenizers range in metadata than its import-time check
enforces, and the cap is what blocks that; the comment now records this
so it does not read as removable.

Root uv.lock is reformatted from lock revision 1 to 3 because uv rewrites
in its current format whenever it writes. The other 32 locks in the repo
are already revision 3 and CI's setup-uv is unpinned, so this aligns root
rather than drifting it. Only 3 versions actually change.

Verified: local-ml sync resolves tokenizers 0.22.2 under transformers
5.12.1; LocalSTEmbeddings and LocalSTCrossEncoder both initialize and run
(the #2055 import path). Lint passes.
2026-07-17 11:13:08 -04:00
Derek Bouius eca0fd5a29 test(openrouter): set cached-token fields in mock to stop intermittent MagicMock crash (#2776)
test_null_content_recovers_on_retry failed intermittently on the test-api
shard with:

  hindsight_api/metrics.py:591: TypeError: '>' not supported between
  instances of 'MagicMock' and 'int'   (if cached_input_tokens > 0)

The mock in _make_chat_response set completion_tokens_details but not the
cached-token fields, so the cached-token extraction
(openai_compatible_llm.py:948 `response_usage.cached_tokens`, and the
prompt_tokens_details path) read an auto-MagicMock and passed it to the
metrics recorder. It only surfaced when the metrics path actually ran —
which depends on telemetry state that leaks across pytest-xdist workers —
so it presented as an intermittent, co-scheduling-dependent failure rather
than a deterministic one.

Set usage.cached_tokens = 0 and usage.prompt_tokens_details = None so both
extraction paths yield int 0. Verified: both tests pass and both paths
return int 0 (no MagicMock reaches the `> 0` comparison).
2026-07-17 10:56:22 -04:00
Jordan-Jarvis 44398633bb fix(api): decode memory observation scopes (#2735) 2026-07-17 10:48:18 -04:00
Nick Old 52b893b93b fix(embed): defer provider credential validation (#2746) 2026-07-17 10:33:06 -04:00
Jordan-Jarvis 52c216c1fb fix(reflect): preserve bank attribution in provider calls (#2764)
Bind Reflect to the existing per-bank ContextVar so its tool loop and final synthesis preserve provider cost attribution. Replace two direct ContextVar implementation tests with one integrated Reflect binding/reset regression.
2026-07-17 10:19:12 -04:00
Ehsan d9bc612a3c fix(openai): record cached and reasoning tokens on the LLM metrics counters (#2758)
The OpenAI-compatible provider extracts cached_tokens and thoughts_tokens on
both call paths and hands them to TokenUsage, but never passes them to
metrics.record_llm_call, which accepts and buckets both. Two separate effects:

- Reasoning tokens reach no counter at all. #2378 made output_tokens
  visible-only by subtracting thoughts_tokens directly above the
  record_llm_call, so the reasoning half of the billed output was removed
  from the metrics path rather than moved onto llm_tokens_thoughts. Before
  #2378 those tokens were still counted inside output_tokens.
- cached_input_tokens has read 0 for every OpenAI-compatible provider since
  the counter was added; only gemini_llm passes it.

Pass both kwargs at the two call sites that parse a usage object. The
fallback path (no usage) and the Ollama native path (no reasoning or cached
fields) are unchanged.

Invariant: recorded output_tokens + recorded thoughts_tokens equals the
provider's completion_tokens, so every billed token lands on exactly one
counter. The new tests assert on the collector itself; the existing ones
patch it without asserting, which is why this went unnoticed.
2026-07-17 09:58:54 -04:00
Ehsan 1fe43ec3fb fix(reflect): pair each expanded memory_id with its own memory (#2759)
tool_expand zipped memory_ids against valid_uuids, which only collects the
ids that parsed as UUIDs. One invalid id shifts every later pair by one, so
a memory comes back stamped with a different memory's id, and zip truncates
the tail so the last requested id gets no entry at all.

Key each id to its own UUID and iterate memory_ids directly, so an invalid
id can only affect its own entry.
2026-07-17 09:58:46 -04:00
Derek Bouius ca87e29891 test(fact-extraction): stop judging phrasing/attribution the system already captures (#2769)
Two hs_llm_core quality tests failed frequently on the core-LLM job, not
because the judge flaked (it is already temp-0 primary + majority-vote
confirmations) but because they judged model output that is genuinely
variable and already checked deterministically elsewhere.

test_date_field_calculation_yesterday: the resolved date lives in the
structured `occurred_start` field, which the test already asserts is
Nov 12/13. The judge additionally required the absolute date to appear in
the free-text fact prose ("...state the absolute date in the fact text"),
so a correct extraction that wrote "Yesterday" in prose but Nov 12 in
occurred_start still failed. That tested phrasing, not capability. Make the
occurred_start assertion mandatory (require a dated fact — calculating the
date is the point of the test) and drop the date clause from the judged
criteria; the judge now only checks the fuzzy activity-content claim.

test_cognitive_epistemic_dimension: the judge penalised entity/speaker
attribution ("Involving: She/He") that is not what this test is about — it
asserts cognitive/epistemic *states* survive extraction. Scope the criteria
to that dimension and instruct the judge to ignore attribution and wording,
so a state counts as preserved even if attributed to the wrong person.

Both still catch real regressions (missing/incorrect dates, dropped
cognitive states); they just no longer flake on aspects the system either
captures structurally or does not claim to get right. Verified locally: both
pass (extraction gpt-4o-mini, judge gpt-4.1-mini).
2026-07-17 08:17:31 -04:00
Derek Bouius d2b14e51ee fix(test): seed torch._inductor.test_operators to fix test-api shard failures (#2761)
* fix(test): seed native embedding/reranker stack to fix test-api shard failures

test-api's reranker-bearing shard (consistently 2/3) has failed on every
recent run — this repo's dependency PRs and Dependabot's alike — with a
misleading "sentence-transformers is required for LocalSTEmbeddings"
ImportError. sentence-transformers IS installed; the message masks the real
cause. The full worker traceback shows native extensions double-initializing:

  torch._inductor.test_operators (module body runs twice):
    RuntimeError: Only a single TORCH_LIBRARY can be used to register the
    namespace _inductor_test
  safetensors._safetensors_rust (PyO3):
    ImportError: PyO3 modules ... may only be initialized once per
    interpreter process

transformers' lazy loader imports these while resolving classes like
AutoModelForSequenceClassification / GenerationMixin (used by the
cross-encoder), and when they are first imported from inside a fixture's
event loop / sentence-transformers' thread pools — or re-executed by the
loader's retry path — the second init aborts. transformers wraps the error
and re-raises it as the sentence-transformers ImportError, so the symptom
points at the wrong dependency.

This is the same class of bug the adjacent `import torch` seed already guards
against (torch/overrides.py double-init). Extend that seed to the rest of the
native stack: torch._inductor.test_operators, transformers, and
sentence_transformers (which pulls safetensors + tokenizers). Importing them
once at conftest collection time — single-threaded, before any concurrency —
puts every submodule in sys.modules so later imports are cache hits and no
body re-executes. Verified locally.

Version-independent (reproduced at transformers 5.3.0 and 5.12.1, torch 2.10
and 2.12), which is why it blocked every uv.lock-changing PR regardless of
what they bumped.

* fix(test): auto-assign embedded postgres port in backfill migration test

test_backfill_populates_null_observation_search_vector pinned its embedded
postgres to a hardcoded port 5568. Under pytest-xdist that collides with a
concurrent or left-over instance:

  FATAL: could not create any TCP/IP sockets
  could not bind IPv4 address "127.0.0.1": Address already in use

which the pg0 retry loop reports as "Failed to start embedded PostgreSQL
after 5 attempts". This was the lone remaining error on test-api shard 2/3
after the native-import fix (66 of 67 errors were the masked double-init;
this was the 67th).

EmbeddedPostgres already supports port=None to auto-assign a free port, and
the fixture uses the URL from ensure_running(), so nothing needs the fixed
port. Switch to auto-assign.
2026-07-17 07:36:27 -04:00
Ehsan 9676fc1699 fix(entity-resolver): keep every co-occurrence pair when canonicalising order (#2750)
The pair canonicalisation in _link_units_to_entities_batch_impl swapped
entity_id_1 and entity_id_2 in place, but entity_id_1 is the outer loop's
iterate:

    for i, entity_id_1 in enumerate(entity_list):
        for entity_id_2 in entity_list[i + 1:]:
            if entity_id_1 > entity_id_2:
                entity_id_1, entity_id_2 = entity_id_2, entity_id_1

Once a swap happens, entity_id_1 stays swapped for the rest of that inner
loop, so every later pair in the same outer iteration is built from the
wrong first element. Those pairs collide with ones already emitted, so the
effect is silently missing edges rather than wrong ones.

entity_list comes from a set, so the ordering (and the bug) varies per run.

Move the canonicalisation into a _canonical_cooccurrence_pairs() helper that
orders each pair into fresh locals, leaving the iterate untouched, and cover
it with order-pinned unit tests that need no database.
2026-07-16 16:21:45 -04:00
Ehsan 73a5b576c9 fix(reflect): keep horizontal rules inside fenced code blocks (#2755)
parse_markdown() blanked every line matching the horizontal-rule pattern
before any fence tracking ran, so a --- / *** / ___ line inside a fenced
code block was replaced by an empty line and the content was lost.

_strip_separators() was fence-unaware and ran first; _split_blocks() is
the pass that tracks fences. Fold the rule-skip into _split_blocks, which
already carries the in_fence state, so there is one fence state machine
instead of two. A rule between sections still counts as blank and still
never becomes a paragraph.

Fixes #2752
2026-07-16 16:13:47 -04:00
Ben 685e50b9af blog: One Bank or Many? A Field Guide to Structuring Agent Memory (#2747)
* blog: One Bank or Many? structuring agent memory

A field guide to bank strategy in Hindsight: a bank is a recall
boundary, when to use separate banks vs tags within one bank, the
dynamicBankId/granularity config, anti-patterns, and a decision
checklist. All claims grounded in the source.
2026-07-16 14:37:55 -04:00
Derek Bouius c27fafb298 chore(deps): npm transitive pins (1 critical + mediums) and pydantic-ai-slim (#2751)
* chore(deps): bump pydantic-ai-slim to 1.107.1 (security)

  pydantic-ai-slim 1.99.0 -> 1.107.1  GHSA-cg7w-rg45-pc59

Closes the SSRF-blocklist-bypass alert (IPv4-compatible / SIIT/IVI /
NAT64 IPv6 addresses; incomplete fix of CVE-2026-46678; patched 1.102.0).

Transitive via the hindsight-pydantic-ai integration. Held to the 1.x
line rather than the 2.x that an unconstrained upgrade resolves to
(2.11.0) -- pydantic-ai 2.x is a major with its own migration surface,
out of scope for a medium security bump. 1.107.1 clears the advisory
within the same major.

Verified: uv run pytest tests -> 37 passed.

* chore(deps): pin websocket-driver/http-proxy-middleware/js-yaml/uuid via overrides (security)

Closes one critical and three medium Dependabot alerts on transitive npm
deps in the root lock, using the repo's existing `overrides` mechanism.

  websocket-driver      0.7.4  -> 0.7.5    GHSA-xv26-6w52-cph6 (CRITICAL:
                                           message corruption via protocol
                                           length headers) + GHSA-mp7j-qc5w-4988
  http-proxy-middleware 2.0.9  -> 2.0.10   GHSA-64mm-vxmg-q3vj (Host-header
                                           routing bypass); capped <3 to stay
                                           on the 2.x major webpack-dev-server
                                           expects
  js-yaml (3.x)         3.14.2 -> 3.15.0   GHSA-h67p-54hq-rp68 (merge-key DoS);
                                           scoped to @istanbuljs/load-nyc-config
                                           and gray-matter so the 4.x copies are
                                           untouched
  uuid (sockjs)         8.3.2  -> 11.1.1   GHSA-w5hq-g745-h8pq (buf bounds);
                                           scoped to sockjs so the top-level
                                           uuid 14.x is untouched

All four are dev/build tooling (webpack-dev-server, sockjs, istanbuljs
coverage, gray-matter frontmatter). Applied by adding overrides then
`npm update <pkg>` per target -- `npm install` alone registers an override
but will not upgrade an already-locked transitive to satisfy it. Verified
`npm ci` installs the lock cleanly and resolves the patched versions.

Two root-lock npm alerts are intentionally left for separate PRs:
- postcss <8.5.10 (GHSA-qx2v-qp2m-jg93): only reachable via [email protected],
  which pins postcss==8.4.31 exactly. npm registers an override but will
  not rewrite next's nested copy, and forcing it risks next's build. The
  real fix is a next bump. Low real risk -- the app compiles first-party
  (Tailwind) CSS, not attacker-controlled input.
- @hey-api/openapi-ts <0.97.3 (GHSA-hhx9-57xq-r5rw): the SDK generator;
  the patched line is a breaking change that needs client regeneration.

* chore(deps): bump langgraph-checkpoint and langgraph-sdk (security)

  langgraph-checkpoint 4.1.0  -> 4.1.1   GHSA-fjqc-hq36-qh5p
  langgraph-sdk        0.3.14 -> 0.3.15  GHSA-w39p-vh2g-g8g5

Both transitive medium alerts in the hindsight-langgraph lock. (The
langsmith bump that originally shared this file landed separately in
#2743; only checkpoint/sdk remain.)

Verified: uv run pytest tests -> 60 passed, 6 skipped.
2026-07-16 12:11:17 -04:00
Ben 37fa0adf93 docs: fix conversation-scoped bank claim in Omnigent post (#2748)
A conversation-scoped bank is not wiped when the conversation ends.
The bank persists; a new conversation simply resolves to a new bank,
so memory does not carry across conversations. Corrects an inaccurate
'wiped' claim in the bank-scoping section.
2026-07-16 10:28:46 -04:00
Derek Bouius b95055ba28 chore(deps): migrate pipecat integration to pipecat-ai 1.x (security) (#2380)
Bumps pipecat-ai from 0.0.x to >=1.4.0,<2.0, clearing four high-severity
Dependabot advisories for the file-read CVEs in the older 0.0.x/1.0.x line
(telephony /ws + runner /files path traversal; alerts #1006, #1005, #560, #559).

pipecat 1.x replaced the per-provider OpenAILLMContext with the universal
LLMContext and removed the pipecat.processors.aggregators.openai_llm_context
module. The integration already imported the modern LLMContextFrame, so the
runtime change is small:

- memory.py: drop the now-impossible legacy OpenAILLMContextFrame import branch
  and match on LLMContextFrame directly. LLMContext.messages is still a live
  list of OpenAI-format dicts, so the in-place injection logic is unchanged.
- tests: build frames from LLMContextFrame; add TestRealLLMContext that exercises
  a real pipecat LLMContext + LLMContextFrame to pin the live-list mutation
  contract the integration depends on.
- examples: migrate to LLMContext + LLMContextAggregatorPair and the LLMRunFrame
  kickoff (create_context_aggregator / get_context_frame were removed in 1.x).
- pyproject: pipecat 1.x requires Python >=3.11, so bump requires-python and
  drop the 3.10 classifier (CI already runs 3.11).

Tests: 19 passed, 1 skipped (live).
2026-07-16 09:48:37 -04:00
Derek Bouius 4b78761d20 chore(deps): bump langsmith and ws (security) (#2743)
langsmith  0.8.3  -> 0.10.5  GHSA-f4xh-w4cj-qxq8 (arbitrary server-side
                               file read in TracingMiddleware; patched 0.8.18)
  ws         8.18.0 -> 8.21.0  GHSA-96hv-2xvq-fx4p (memory-exhaustion DoS)

Both are transitive. langsmith pulls in distro/sniffio/websockets as new
langsmith 0.10.x deps. hindsight-api-slim already carries a langsmith
>=0.8.18 floor; this covers the langgraph lock, which did not.

ws could not be bumped directly: miniflare pins it exactly (ws==8.18.0),
so the fix is via wrangler. wrangler >=4.108.0 requires peer
@cloudflare/workers-types ^5, a types major we don't want in a security
fix, so pin 4.107.1 -- the newest wrangler still on workers-types v4
(peer ^4.20260702.1) and the earliest line carrying patched ws 8.21.0.
That moves workers-types 4.20260617.1 -> 4.20260702.1 within v4. wrangler
is a devDependency, so this ws is dev-only (miniflare's local dev server);
the deployed Worker's only runtime dep is @cloudflare/workers-oauth-provider.

The langgraph lock also picks up hindsight-langgraph 0.2.0 -> 0.3.0.
That is pre-existing drift, not part of this change: release(langgraph)
v0.3.0 (2c5362942) bumped pyproject without re-locking. uv corrects it here.

json-repair (GHSA-xf7x-x43h-rpqh) is deliberately not addressed: it is
blocked upstream. Every crewai release, including the latest 1.15.2, pins
json-repair~=0.25.2 (>=0.25.2,<0.26.0), and the advisory is not patched
until 0.60.1. No crewai version permits a fixed json-repair.

Verified: cloudflare-oauth-proxy `npm ci` + `npm run typecheck` (CI's gate)
pass, npm audit reports 0 vulnerabilities, vitest 50 passed; langgraph
pytest 60 passed, 6 skipped. Lint passes with LINT_ALL_INTEGRATIONS=1.
2026-07-16 09:47:41 -04:00
BenandClaude Opus 4.8 1549987015 docs: Add Omnigent integration page (#2710)
* docs: add Omnigent integration page

Adds the Omnigent integration to the docs site:
- docs-integrations/omnigent.md — full integration guide (install, YAML
  config, how runner-local dispatch works, bank scoping, config reference,
  self-hosted, Remy example, harness table, further reading)
- src/data/integrations.json — registry entry (category: framework, official)
- static/img/icons/omnigent.png — placeholder icon (to be updated)

Tool names use the correct Omnigent source names: memory_recall/retain/reflect.

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

* docs: fix broken links, real Omnigent logo, regen skill

- Remove changelog link (Omnigent has no released Hindsight package/changelog)
- Drop the not-yet-merged blog self-link; add Omnigent GitHub link instead
- Replace placeholder icon with the real Omnigent logo (from omnigent-ai/omnigent)
- Regenerate skills/hindsight-docs integration reference for omnigent

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

* docs(omnigent): correct tool names to hindsight_* + fix harness table

- Revert memory_* -> hindsight_recall/retain/reflect: released omnigent v0.5.1
  (and main, and the Remy example) use hindsight_* names. The memory_* rename
  is on an unmerged branch (integration/hindsight-memory-tool), not released.
- Fix the harness table: Codex and OpenCode have official Hindsight integrations,
  Pi has a community one (epimetheus); reframe around 'one central setup' rather
  than implying those tools have no native support.
- Regenerate skills/hindsight-docs reference.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-15 15:38:15 -04:00
Ben b0e5d103d8 Blog: Give Every Agent You Run in Omnigent a Persistent Memory (#2709)
* Blog: Omnigent as the universal Hindsight memory bridge

Tutorial-style post on adding persistent memory to Omnigent. Key angle:
Omnigent intercepts hindsight_recall/retain/reflect at the runner level, so
every wrapped harness (Claude Code, Codex, Cursor, Hermes, Pi) gets memory
through one setup, even those with no native Hindsight support. Covers
install, YAML spec, bank scoping, the Remy example, and cloud/self-hosted.
Grounded in omnigent-ai/omnigent source. Bridge-diagram cover.
2026-07-15 15:15:07 -04:00
Minghao Xiao 5ab6bdc9b6 fix(openclaw): gate append retention on stored text (#2511)
Fixes #2505: the OpenClaw append-capability probe only checked API version, ignoring features.store_document_text, so every session-scoped retain 400'd (silent memory loss) on text-disabled deployments. Now gates update_mode=append on BOTH version >= 0.5.0 AND features.store_document_text=true, falling back to per-turn document IDs otherwise. Verified locally: 281/281 openclaw tests pass on the PR head.
2026-07-15 11:18:46 -04:00
handnewb ed1083803b fix: coerce non-string metadata values to strings in MemoryFact.parse_metadata (#2623)
Fixes the consolidation blocker from non-string metadata values (e.g. integer `original_id` from observation bookmarks) by coercing all metadata values to str in `MemoryFact.parse_metadata`. Verified locally: 4/4 regression tests pass (integer coercion, JSONB-string-with-int, string passthrough, None).
2026-07-15 11:08:05 -04:00
Ben ec3b415c42 feat(retain): optional fail-on-extraction-errors flag (#2721)
Add opt-in HINDSIGHT_API_FAIL_ON_EXTRACTION_ERRORS (default False, preserves behavior). When enabled and a retain accumulated extraction errors (extraction_errors_count > 0), the operation is marked failed instead of silently completed. Self-contained: config flag + _mark_operation_completed decision + docs + .env.example. Deferred follow-ups (status API field, completed_with_errors status, webhook field, metric) noted in the PR.

Own change verified (config + status tests pass, verify-generated-files green, ruff/tsc clean). Remaining CI reds are unrelated: Core LLM + pg0 test-api flakes, a transient ts-client-oracle, and test-embed-windows (the #2676 regression already fixed on main by #2723; #2721 does not touch the embed daemon).

Fixes #2700
2026-07-15 10:58:00 -04:00
Ben 3a188971b9 fix(embed): use --target sibling binary unconditionally (#2723)
Resolve the #2676-vs-#1240 conflict that broke test-embed-windows on main: scope #2676's 'missing sentence-transformers -> uvx' fallback to the sysconfig-scripts path only. A --target-bundled sibling binary is deliberate and is used unconditionally (preserving #1240). Adds a regression test; fixes a test fixture that conflated the two binary-resolution paths.

Greens main.
2026-07-15 10:56:57 -04:00
Nick Old b3e32ce5b6 fix reranker heap release after local scoring (#2530) 2026-07-15 10:49:21 -04:00
Ben d0bbfa1015 fix(gemini): grammar-enforce batch structured output regardless of strict flag (#2719)
The Gemini batch request builder only set the native response_schema
(responseJsonSchema) when json_schema.strict was truthy, but
HINDSIGHT_API_LLM_STRICT_SCHEMA defaults to False. The interactive Gemini
path always grammar-enforces via response_schema regardless of strict
(strict is an OpenAI concept, meaningless to Gemini). At default config
batch requests therefore got only responseMimeType + a textual schema hint
and intermittently emitted malformed JSON, dropping every fact in the chunk.

Set responseJsonSchema whenever a schema is present so batch mirrors
interactive. Update the batch translation unit test accordingly.

Fixes #2699
2026-07-15 10:33:39 -04:00
Ben 043a68fa44 fix(retain): recover batch extraction JSON via parse_llm_json (#2720) 2026-07-15 10:32:21 -04:00
Ben 7542035e44 fix(test): update Oracle session-schema tests for #2708 reset behavior (#2722)
#2708 changed OracleBackend._set_session_schema to always reset CURRENT_SCHEMA
to the connection's default (SESSION_USER) schema — including for the public
schema — because Oracle pooled sessions retain CURRENT_SCHEMA across checkouts.
That intentional change left two #2613 unit tests asserting the old
'public = noop, no cursor' contract, and their mock cursor lacked the fetchone()
now used to look up SESSION_USER, so both failed on main.

Update the tests to the new contract: public now resets to the default schema
via ALTER SESSION, and the mock cursor provides fetchone(). The synchronous
cursor.close()-not-awaited assertion is preserved.
2026-07-15 10:30:05 -04:00
Ben bee6f5d114 fix(control-plane): show bank name (fallback bank_id) in bank selector (#2693)
The bank selector rendered bank_id for both the dropdown items and the
selected-bank trigger, ignoring the bank's friendly name even though it's
already available on BankInfo (name). Admins who rename banks via
PATCH /v1/default/banks/{bank_id} saw only the immutable bank_id in the UI.

Display name || bank_id in the dropdown items and look up the selected
bank's name for the trigger, falling back to bank_id (then the 'select'
placeholder) so there's no regression before the bank list loads or when a
bank has no name. bank_id remains the key/value/clipboard identifier.

Fixes #2686
2026-07-15 10:17:32 -04:00
Liam Zhang e20b1815fc [verified] docs(embed): expose local CPU workarounds (#2707) 2026-07-15 10:03:50 -04:00
Ben 395823f7b6 release(claude-code): v0.7.5 2026-07-14 14:19:40 -04:00
Nick Old a910fd8a0b fix(claude-code): retain session deltas (#2648)
* fix(claude-code): retain session deltas

* fix(claude-code): commit retain checkpoint after success
2026-07-14 14:18:26 -04:00
Parafee41 64ee029a18 Avoid slim embedded daemon startup without local ML deps (#2676)
* fix(embed): avoid slim daemon without local ML deps

* test(embed): pin slim binary preconditions
2026-07-14 14:18:22 -04:00
Elan Hasson 25df91ca53 fix(claude-code): surface the CLI's real error text on is_error results (#2703)
ClaudeCodeLLM's streaming loops ignored ResultMessage entirely. When the CLI reports quota exhaustion with is_error=true and subtype="success", the SDK's fallback produced the misleading 'error result: success'. Add _result_error_detail() that prefers message.result over subtype, wired into both loops. 4/4 regression tests pass.

Fixes #2702
2026-07-14 10:10:02 -04:00
Parafee41 8987fb8267 fix(codex): share OAuth refresh per auth-file path (#2706)
Multiple CodexLLM instances (default/retain/reflect/consolidation configs) each created their own CodexAuthManager with an instance-local lock, so refresh was only single-flight within one manager. Concurrent refreshes from sibling managers hit refresh_token_reused. Add a path-scoped in-process lock and fcntl advisory file lock so all managers for the same CODEX_HOME coordinate as one refresh domain; pre-read auth.json under the lock to adopt credentials rotated by a sibling before making a network call.

27 Codex OAuth tests pass. CI green.

Fixes #2704
2026-07-14 10:08:36 -04:00
Voscko 86ff344c93 fix(worker): bound terminal operation history (#2708)
Add configurable TTL (default 30 days, 0=keep-forever) for terminal async_operations rows. Expired completed/failed/cancelled rows are pruned in bounded batches (1000/cycle) by a background task that never touches pending/processing work. Batch children are protected until their parent is pruned; cancelled-child cleanup atomically cancels a pending parent first. PG uses FOR UPDATE SKIP LOCKED; both PG and Oracle re-check eligibility under the row lock before deleting. Includes indexes, docs, and regenerated SDKs.

184 retention/worker/operation-status tests pass locally. All CI green.

Fixes #2705
2026-07-14 10:04:26 -04:00
DK09876andClaude Opus 4.8 b6c7b2a2e9 feat(devin-desktop): two-tier bank scoping + visible memory use (v0.2.0) (#2692)
* feat(devin-desktop): two-tier bank scoping + visible memory use (v0.2.0)

Reworks the Devin Desktop integration from a single hardcoded `devin-desktop`
bank (all projects share one memory pool) to per-project isolation plus a
shared cross-project bank, and makes Hindsight usage visible in chat.

Scoping (multi-bank mode):
- Connect to the multi-bank `/mcp/` endpoint (was `/mcp/<bank>/`); the model
  routes `bank_id` per call, guided by the committed rule.
- Global bank `devin-desktop` (user prefs/style) named in global_rules.md;
  per-project bank `devin-desktop-<slug>` derived from the git remote (stable
  across machines/teammates) named in the committed .devin/rules/hindsight.md.
- `X-Bank-Id: <global>` header as the fallback bank when the model omits it.
- Verified against live Cloud: bank_id routing + full isolation (no cross-bank
  leak) + read-after-write via sync_retain.

Visibility (no sound, per product decision):
- Rule now tells the agent to briefly acknowledge memory use in chat
  (reverses the prior "do not mention" line) and to use `reflect`/`sync_retain`.

Audit fixes:
- Write both documented MCP config locations (`~/.codeium/windsurf/` and
  `~/.codeium/`) since Devin's own docs disagree on the path.
- Explicit "press Refresh in the MCP panel" step (config doesn't hot-reload).

New modules: project.py (git-derivation), global_rules.py (global_rules.md
managed block). Backward-compatible: legacy `bankId` config maps to the global
bank. 55 tests pass; ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* feat(devin-desktop): also wire the Devin Local agent (not just Cascade)

Devin Desktop ships two agents with separate config, and the prior version only
wired Cascade — so a user on Devin Local (the successor agent) got no memory.
`init` now configures both:

Cascade (unchanged): ~/.codeium/windsurf/mcp_config.json (serverUrl),
.devin/rules/hindsight.md, ~/.codeium/windsurf/memories/global_rules.md.

Devin Local (new):
- ~/.config/devin/config.json — mcpServers.hindsight with `url` + `transport:"http"`
  + `headers` (Devin Local's schema, not Cascade's `serverUrl`); preserves other
  keys (e.g. version).
- permissions.allow += "mcp__hindsight__*" — Devin Local prompts before every MCP
  tool by default; this makes recall/retain run automatically.
- AGENTS.md always-on rules (Devin Local doesn't read .devin/rules/): repo-root
  AGENTS.md (per-project) + ~/.config/devin/AGENTS.md (global), each a fenced
  managed block that preserves user content.

New modules: devin_local.py, managed_block.py (shared block writer, also used by
global_rules.py). Same multi-bank + routing-rule design across both agents.
status/uninstall cover both. README + docstrings updated. 74 tests pass; ruff
clean (ruff 0.14.9 + root config).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* docs(devin-desktop): tell users to click Connect (Devin Local) after init

Devin Local registers the MCP server from config.json but requires an explicit
Connect click in the Devin MCP Marketplace (verified in-app). init output and
README now spell out the per-agent activation step: Cascade = Refresh, Devin
Local = Connect.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* feat(devin-desktop): deterministic auto-recall hook + Windows paths

Two things for 0.2.0:

1. Windows paths: Devin Local config/AGENTS.md now resolve to %APPDATA%\devin on
   Windows (was ~/.config/devin unconditionally, which is wrong there). Cascade's
   ~/.codeium/windsurf is already cross-platform.

2. Deterministic auto-recall (Devin Local only): init adds a SessionStart hook to
   config.json that recalls project + global memory and returns it as
   `additionalContext`, which Devin injects into the agent's context before the
   model acts — so memory loads even if the model forgets to call recall. The
   hook (hindsight_devin_desktop.hook) reads the connection from config.json and
   derives the project bank from DEVIN_PROJECT_DIR; it's dependency-free (stdlib
   urllib MCP call), times out fast, and fails silently so it never breaks a
   session. Opt out with `init --no-hooks`. Cascade gets no hook (its hooks can't
   inject context). Auto-retain is intentionally not added (SessionEnd can't see
   the transcript); retain stays model-driven via the MCP tool.

Verified live against Cloud: the hook recalls a stored fact and emits correct
additionalContext JSON. 89 tests pass; ruff clean. README documents both.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* feat(devin-desktop): retain-nudge hook, no-silent recall, Cascade visibility banner

Round out the hooks/visibility work for 0.2.0:

- Retain-nudge (Devin Local, default on): a `Stop` hook forces one retain pass
  before the agent stops (loop-guarded via stop_hook_active) — deterministic
  *trigger*; the model decides what's durable and calls retain. Devin's hooks
  can't hand a script the transcript, so this is the closest to deterministic
  retain. Opt out with --no-retain-hook; --no-hooks disables both hooks.

- No silent failures (recall hook): the SessionStart hook now ALWAYS reports
  status via additionalContext — loaded N / empty / unavailable(reason) — and
  tells the model to surface it. Never exits non-zero (never breaks a session).

- Cascade visibility banner: init adds a `post_mcp_tool_use` hook to
  ~/.codeium/windsurf/hooks.json with show_output:true that prints
  "🧠 Hindsight: <tool> used" (filtered in-script to the hindsight server, since
  Cascade hooks have no matcher). Makes Cascade's recall/retain visibly obvious.

New module cascade_hooks.py; hook.py gains retain-nudge + banner subcommands.
README documents both hooks, the honest retain limitation, and the banner.
102 tests pass; ruff clean. Recall + retain-nudge output verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* fix(devin-desktop): stop the retain-nudge polluting memory with meta-facts

Real in-app testing showed the Stop retain-nudge caused the model to (a) retain
facts ABOUT the memory system/instructions as 'user preferences', and (b)
re-retain things already saved this session. Tighten both the nudge and the
always-on rule: retain ONLY real facts about the code/project/user's actual
preferences, NEVER facts about Hindsight/memory/hooks/these instructions, and
don't re-retain what's already stored.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* fix(devin-desktop): rule tweak to stop redundant retains

In-app testing showed the model firing sync_retain per-fact (and re-saving),
producing duplicate memories. Reframe the rule: retain (async) is the default;
retain each distinct fact EXACTLY ONCE in a single call (batch same-subject
facts); sync_retain only for same-task read-after-write.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* feat(devin-desktop): hook proof-of-life log (~/.hindsight/devin-hook.log)

Devin Local hooks are silent (no output panel), so it's hard to tell whether a
hook actually fired vs the model just following the always-on rule. Each hook
invocation now appends one line (recall loaded/empty/error, retain-nudge
blocked/skipped, banner shown/skipped) with the resolved banks — proof-of-life
so users (and we) can confirm the hooks run.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* feat(devin-desktop): --no-global-bank opt-out (local-only memory)

Add a local-only mode so users can opt out of the shared cross-project bank:
everything (project facts + the user's preferences) goes to the single project
bank, the global rule files are removed instead of written, and the recall +
retain-nudge hooks run with --local-only (recall only the project bank, nudge
routes everything there). The rule becomes a single-bank variant. Cascade
banner + MCP config unchanged. For people who don't want a shared profile
(e.g. work vs personal machines).

108 tests pass; ruff clean. Verified end-to-end: no global files written, hooks
carry --local-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* fix(devin-desktop): don't let the test suite write the hook log to $HOME

The proof-of-life hook log wrote ~/.hindsight/devin-hook.log unconditionally, so
running the tests (which call the hook functions) polluted the real user log.
Make the path env-overridable (HINDSIGHT_HOOK_LOG, 'off' disables) and add a
conftest that sets it off during tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* feat(devin-desktop): recall hook reports the session-start preload

The SessionStart hook's additionalContext now tells the model to OPEN its reply
by announcing that memory was preloaded (e.g. '🧠 Hindsight preloaded N memories
for this session'), and that it doesn't need to re-call recall for the baseline
— making the deterministic preload visible to the user and cutting redundant
recall calls. Empty/error variants also lead with a user-facing status line.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* docs(devin-desktop): add 'verify it's working', which-agent, and Windows notes

Help new users get started with both agents: a 'Verify it's working' section
(the preload status line / hook log / Cascade banner / status command), a note
on the agent selector, and the Windows config path.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-13 15:52:21 -07:00
BenandClaude Opus 4.8 9efce6a470 Blog: Inside retain() — What Happens When Your Agent Remembers (#2689)
* Blog: inside retain() — what happens when your agent remembers

A feature explainer walking the retain() write path end to end through one
sentence: fact extraction (meaning, not words), entity recognition + resolution,
the knowledge graph (entity/time/meaning/causal), dual temporal grounding, and
async consolidation into evidence-grounded observations. Grounded in the retain
and observations developer docs. Pipeline-diagram cover.

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

* Blog: address review — drop async-only timing claims, note original text is stored

- Remove 'returns almost immediately' / inline-extraction language that only
  holds for one retain mode; frame consolidation as the always-background step
- Add that retain also stores the original text (chunked if long), available
  alongside the extracted memory
- Cover line updated to 'the raw text is kept, and memory is built on top'

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

* Blog: fact-check fixes from full code+docs audit

- Remove 'nickname resolution' (Bob/Robert Chen): code has no nickname/alias
  logic; resolution is fuzzy name match + co-occurrence + temporal proximity
- Temporal: second axis is the mention time, not the DB insert moment; recency
  ranks off event/mention time, not ingestion
- Soften 'source is never lost' -> 'stays available' (original-text storage is
  default-on but operator-configurable)

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

* Blog: editorial cover (cream + serif, teal retain())

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-13 15:25:12 -04:00
Ben 6cc7484b78 fix(migrations): install public maintenance routines on non-public schema runs (#2690)
Install public.banks_needing_consolidation() / public.schemas_with_expired_rows() on every PG run (advisory-lock-guarded) so single-tenant deployments migrated into a non-public schema get the routines the maintenance loop needs. Prior migrations gated on target_schema being falsy/public, leaving non-public deploys logging 'function public.… does not exist' forever.

All real CI tests pass (test-api 1/3+3/3, and 1237/1237 in shard 2/3; test-upgrade, both Oracle suites, verify-generated-files green). Sole failing check is a persistent pg0 'Address already in use' runner-infra error in an unrelated backfill test — not a test failure and not from this diff; recurred on all 3 reruns.

Fixes #2638
2026-07-13 13:29:13 -04:00
DK09876 920afcce20 release(devin-desktop): v0.1.0 2026-07-13 10:00:35 -07:00
Evo 6d82c8a554 fix(consolidation): request JSON dedup decisions (#2663)
The dedup prompt literally told the model to 'respond action="merge"', so weaker models emitted key=value instead of JSON and json.loads crashed every dedup-eligible consolidation into an infinite retry loop. Rewrite the prompt to demand a JSON object (braces escaped for .format()), and add a defensive parser that accepts str/dict/model and defaults to action=keep on invalid output. Fork CI skips pytest (no secrets); test_consolidation_dedup.py verified locally (32/32), ruff+ty clean.

Fixes #2658
2026-07-13 11:03:49 -04:00
Sanderhoff-alt 4b52b10e2e fix(recall): preserve combined graph activation scores (#2679)
Link expansion ranks candidates by an additive entity, semantic,
and causal score, but returned the raw score from one signal as
activation. Cross-fact-type graph merging then re-ranked candidates
using that raw value.

Store the final additive score as activation and add a regression test
for cross-fact-type ordering.
2026-07-13 10:35:20 -04:00
Vilius PuidokasandVilius Puidokas ac06df1ade fix(control-plane): route consolidation-poll tick through a ref so it sees current tag/scope filters (#2680)
Co-authored-by: Vilius Puidokas <[email protected]>
2026-07-13 10:31:04 -04:00
Evoandr266-tech 5f1a867650 fix(search): avoid year-0 crashes in Chinese rolling-window temporal extraction (#2636)
* fix(search): guard Chinese rolling year underflow

* fix(search): complete Chinese year underflow guard

---------

Co-authored-by: r266-tech <[email protected]>
2026-07-13 10:14:28 -04:00
Ben d284119246 fix(control-plane): forward document search q to dataplane (#2687)
The /api/documents proxy route dropped the q search param, so document search-by-ID in the control plane did nothing (all browsers, not just Safari). Forward q to the dataplane's substring-on-ID filter. Adds a vitest route test.

Fixes #2678
2026-07-13 10:11:56 -04:00
Sanderhoff-alt 84b9aa56ce fix(engine): clarify causal link compatibility (#2685)
Clarify that retain creates caused_by only. Storage and recall keep reading
historical causal link types, and transfer import alone restores them.

Correct stale code comments and tests, and preserve legacy edge types and
endpoints during transfer without widening the retain write contract.
2026-07-13 10:07:26 -04:00
Parafee41 f58ecee0b9 fix(retain): preserve append document metadata (#2684) 2026-07-13 10:01:17 -04:00
Sanderhoff-alt b9d16fe86f fix(recall): scope entity fanout cap by fact type (#2681)
Apply each entity fanout cap only after filtering candidates by fact type.
This prevents high-volume fact types from excluding valid target candidates.

Cover the PostgreSQL and Oracle CTE builders with a regression test.
2026-07-13 09:54:22 -04:00
Parafee41 2ccde7a5cd accept top-level fact arrays in retain parsing (#2556) 2026-07-13 09:32:00 -04:00
Parafee41 d2ca26afaf Include cookbook and integration docs in docs skill (#2649)
Extends generate-docs-skill.sh to walk hindsight-docs/src/pages/cookbook/ and docs-integrations/, so the docs skill bundle ships the cookbook recipes/applications and per-integration docs its SKILL.md already advertised. Fixes the ghost-path index described in #2641. Regeneration is drift-free (verify-generated-files passes) and link validation passes; bundle grows from ~85 to 168 files.

Fixes #2641
2026-07-10 16:08:40 -04:00
Cyprian Kowalczyk b52feb305e fix(consolidation): normalize dedup action case/whitespace before validation (#2611) 2026-07-10 16:06:56 -04:00
558b2f8b67 fix(llm): raise OpenRouter Qwen3 verification budget (#2633)
* fix(llm): raise OpenRouter Qwen3 verify budget

* test(llm): apply response hardening lint fixes

* fix(llm): generalize verification token budget

---------

Co-authored-by: r266-tech <[email protected]>
Co-authored-by: Ben <[email protected]>
2026-07-10 15:48:32 -04:00
Evoandr266-tech 383f0caa16 deps(security): require LiteLLM 1.84.0 (#2651)
Co-authored-by: r266-tech <[email protected]>
2026-07-10 15:36:51 -04:00
Ben 16e8c4e216 Blog: one shared memory across Cursor, OpenClaw, and Vapi (#2655)
A first-person, day-in-the-life post on running three AI tools (code, chat,
voice) against a single Hindsight bank: a decision made in Cursor is recalled
by the OpenClaw Slack agent and the Vapi voice agent, because all three point
at the same bank id. Grounded in each integration's actual bank config.
Hub-and-spoke cover.
2026-07-10 15:12:54 -04:00
JiehoonKwak c6a1b507ba Fix Codex OAuth request identity (#2647) 2026-07-10 15:08:15 -04:00
Liam ZhangandBen cb4fe70b63 fix(trace): skip LLM trace writes during daemon shutdown/pre-init races (#2618)
* fix(trace): skip LLM trace writes during daemon shutdown/pre-init races

LLMTraceRecorder._safe_write and _attach_memory_ids produce spurious
WARNING logs during two race windows:

1. Pre-init: MemoryEngine.initialize() runs verify_llm() inside the
   parallel init gather before the DB backend pool is ready. The
   pool_getter returns a backend object that raises RuntimeError on
   acquire.

2. Shutdown: MemoryEngine.close() calls backend.shutdown() (sets
   _pool=None) before setting self._backend=None. Fire-and-forget trace
   tasks see a non-None backend whose internal pool is already closed,
   hitting either RuntimeError('not initialized') or
   InterfaceError('pool is closing').

Both are expected lifecycle states, not actionable errors. Fix:
- Add a getattr(pool, '_pool') None guard before the acquire attempt
- Downgrade 'not initialized' and 'pool is closing' exceptions to DEBUG
  in both _safe_write and _attach_memory_ids
- All other write failures still warn

Supersedes #2562 (closed without merge), which only covered the
pre-init RuntimeError path. This PR additionally covers the shutdown
'pool is closing' race and the _attach_memory_ids write path.

5 regression tests covering: pool=None, backend._pool=None,
pool-is-closing, unexpected error (still warns), and
_attach_memory_ids with _pool=None.

* style: ruff format test_llm_trace.py

---------

Co-authored-by: Ben <[email protected]>
2026-07-10 14:08:49 -04:00
handnewbandBen 408d7c34c8 fix: handle FK violation in observation_history during parallel consolidation (#2620)
* fix: handle FK violation in observation_history during parallel consolidation

Wrap the INSERT into observation_history with a try/except for
ForeignKeyViolationError. Under parallel/batched consolidation, one
batch may delete an observation while another writes its history,
causing a race condition. Instead of failing the entire consolidation
task, log a warning and skip the history entry.

Also adds the missing  needed to catch the specific
exception type.

Closes #2597
Closes #2506

* test: regression for observation_history FK race (#2597, #2506)

---------

Co-authored-by: Ben <[email protected]>
2026-07-10 11:12:04 -04:00
B HicksandClaude Opus 4.7 7f2df54e01 feat(anthropic): implement the batch API interface via Message Batches (50% token discount) (#2628)
The engine's batch path (retain fact extraction, gated on
retain_batch_enabled) has been available to the OpenAI-compatible and Gemini
providers but not Anthropic — AnthropicLLM implemented none of the
LLMInterface batch methods, so supports_batch_api() returned False and the
gate hard-failed.

Implement all four methods against Anthropic's Message Batches API, which
bills every token at 50% of standard price:

- submit_batch translates the engine's OpenAI-JSONL-shaped entries into
  Messages batch requests, mirroring call()'s conversion rules: system
  messages fold into the system param, max_completion_tokens -> max_tokens,
  temperature is dropped (the sync path never sends it either), and
  response_format json_schema becomes a forced tool_use tool when strict
  (native constrained decoding, issue #1002) or a system-prompt schema
  injection otherwise. Operator extra_body params merge directly (batch
  params are the raw Messages body).
- get_batch_status maps processing_status onto the OpenAI vocabulary the
  engine's poll loop speaks: "ended" -> "completed" (per-request failures
  surface in results, matching OpenAI's completed-with-errors semantics),
  non-terminal states pass through; request_counts are aggregated to
  total/completed/failed.
- retrieve_batch_results renders succeeded messages as
  choices[0].message.content (forced-tool JSON re-serialized as the content
  string) with OpenAI-keyed usage, and errored/canceled/expired entries as
  per-result errors.

8 new tests covering translation in both directions, status mapping, and the
not-ended guard; existing batch-path and Anthropic provider suites pass
unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-07-10 10:57:01 -04:00
Ben 1758d8510b release(github-copilot): v0.1.0 2026-07-10 10:49:53 -04:00
B HicksandClaude Opus 4.7 7c3a5619f9 feat(anthropic): prompt caching via inline cache_control markers (#2629)
The Anthropic provider sent no cache_control at all, so every call paid full
input price on content the engine resends verbatim: fact extraction reuses
the same system prompt across every chunk, and the reflect agent loop resends
the entire growing conversation on each of its (up to
HINDSIGHT_API_REFLECT_MAX_ITERATIONS) iterations. Anthropic cache reads bill
at ~10% of base input price.

Implement the "inline-marker provider" strategy that
LLMInterface.get_or_create_cached_prefix already documents for Anthropic —
no engine changes, no new config:

- call() and call_with_tools() render the system prompt as a block list with
  a cache_control breakpoint (a prefix match, so tools + system cache
  together); schema text-injection happens before marking and lands inside
  the cached block.
- call_with_tools() additionally marks the final message content block, so
  each agent-loop request's end-marker becomes the next iteration's cache
  read point. 2 of the 4 allowed breakpoints used.

Marking is safe unconditionally: below the model's minimum cacheable prefix
the marker is silently ignored (no write premium), and cache_read_input_
tokens already flows through _usage_from_anthropic_response into metrics.

One existing assertion updated for the representation change
(test_non_strict_keeps_text_injection_fallback checked a substring on system
as a string; the schema-in-prompt behavior itself is unchanged and still
covered). 5 new tests pin the marker placement on both entry points.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-07-10 10:39:16 -04:00
Ben 2b5b47f97d Blog: Give Aider a Memory That Outlives the Session (#2642)
* Blog: give Aider a persistent, cross-session memory

Add a post on hindsight-aider, the drop-in wrapper that recalls project
memory before an Aider session (via a --read context file) and retains the
transcript after, scoped per git repo. Grounded in the v0.1.1 integration
source. Git-diff cover.

* Blog: use Aider-branded cover (real logo, brand green, VT220 font)
2026-07-09 15:59:35 -04:00
Ben b2508bf2d6 release(claude-code): v0.7.4 2026-07-09 15:54:05 -04:00
Ben b0fb1111ec fix(claude-code): skip primary + duplicate banks in recallAdditionalBanks (#2625)
The additional-banks recall loop recalled every entry with no dedup against
the resolved primary, so bidirectional cross-bank setups (primary listed in
recallAdditionalBanks) re-recalled the primary on every prompt — a wasted
recall call plus duplicate context. Guard the loop with a seen-set seeded with
the primary bank; also dedups repeated entries. Fixes #2604.
2026-07-09 15:48:53 -04:00
Nick Old 4bf126bf52 fix(claude-code): isolate MCP server cwd (#2635) 2026-07-09 15:47:51 -04:00
Evoandr266-tech e4449326e6 fix(reflect): tolerate null-like tool integer limits (#2639)
Co-authored-by: r266-tech <[email protected]>
2026-07-09 15:36:11 -04:00
Sanderhoff-alt 7bab4db28d fix(recall): decouple temporal seed threshold (#2595)
Keep recall min_scores.semantic scoped to the semantic retrieval arm.

Temporal retrieval uses embeddings only to choose time-window entry
points. Reusing the request-level semantic floor there made temporal
recall unexpectedly narrower.

Callers that only wanted to prune weak semantic matches could also
narrow temporal recall. That made the min_scores contract surprising
and inconsistent with graph seed selection.

Use the temporal entry-point default instead. Semantic and BM25 request
floors remain unchanged.
2026-07-09 10:28:20 -04:00
e29ee58603 fix(ollama): make native num_ctx opt-in (#2589)
* fix(ollama): make native num_ctx opt-in

* docs(ollama): add HINDSIGHT_API_LLM_OLLAMA_NUM_CTX to .env.example

* fix(config): keep Ollama num_ctx optional for direct config construction

---------

Co-authored-by: r266-tech <[email protected]>
Co-authored-by: Ben <[email protected]>
2026-07-09 10:25:21 -04:00
Evo 82e67315a4 fix(worker): complete successful operations (#2608) 2026-07-08 14:35:28 -04:00
DK09876andClaude Opus 4.8 2e82edee14 docs(oracle): document the required schema, dimension matching, and start-oracle re-run (#2615)
Verifying the Oracle guide end-to-end surfaced three setup steps that weren't
documented and that block a first-time deployment:

- HINDSIGHT_API_DATABASE_SCHEMA must be set to the Oracle schema user. The
  default `public` is a PostgreSQL notion and makes migrations fail with
  ORA-01435. Added it to the configure step (with a warning), the quick start,
  the config reference table, and troubleshooting.
- Migrations must run with the same embedding dimension as the serving model,
  or retain fails with ORA-51803. Added a warning to the migrate step and a
  troubleshooting row (including the --embedding-dimension resize path).
- The dev quick-start container can report a provisioning error on a cold
  start's first run; noted that re-running the idempotent script succeeds.

Mirrored into versioned_docs/version-0.8 and regenerated the docs skill.


Claude-Session: https://claude.ai/code/session_01PginSDrapXsoDd6gN5Pszo

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 20:35:16 -07:00
DK09876andClaude Opus 4.8 0338ab4d73 fix(oracle): don't await the synchronous cursor.close() in _set_session_schema (#2613)
oracledb's AsyncCursor.close() is not a coroutine, so awaiting it raised
"object NoneType can't be used in 'await' expression" on every acquire()
under a non-public schema. This broke the database health check and all
retain/recall/reflect operations on Oracle whenever a non-public schema was
active — which is the norm on Oracle, since a schema is a user and the
default `public` schema does not exist there.

Drop the erroneous await. Add unit regression tests (no live Oracle needed —
a fake cursor whose close() is synchronous, exactly like oracledb) covering
both the non-public path (previously raised TypeError) and the public no-op
path. These run in the standard test suite, unlike the label-gated Oracle
integration job.


Claude-Session: https://claude.ai/code/session_01PginSDrapXsoDd6gN5Pszo

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 16:49:51 -07:00
DK09876andClaude Opus 4.8 f4106fff55 docs: add Oracle Database setup guide (#2612)
* docs: add Oracle Database setup guide

Hindsight supports Oracle Database 23ai as a storage backend, but the docs
only mentioned it in passing — a one-paragraph note on the Storage page and a
couple of Configuration reference rows, with no `oracle+oracledb://` example
anywhere. This adds a dedicated Oracle Database page under Hosting.

The guide covers requirements (Oracle 23ai, the ASSM-tablespace requirement
for VECTOR columns, Oracle Text / CTXAPP), installing the python-oracledb
driver, a local quick start via scripts/dev/start-oracle.sh, production
provisioning SQL + connection URL + env vars + migrations, a config reference,
the differences from PostgreSQL, and troubleshooting. Content is grounded in
the CI Oracle job, the dev script, and the backend code.

Registered in the sidebar and cross-linked from Storage and Configuration.
Regenerated the docs agent-skill and mirrored the change into
versioned_docs/version-0.8 so it ships on the currently-served version.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01PginSDrapXsoDd6gN5Pszo

* docs(oracle): correct managed-service note, add connection caveat

The connection layer builds the Oracle DSN from the URL as a plain
host:port/service_name descriptor — wallet-based mTLS, TLS/TCPS, and TNS
aliases / full connect descriptors are not wired up. The previous "Least
privilege" note implied Oracle Autonomous Database works via an
ADMIN-provisioned user, which is misleading since ADB defaults to wallet/mTLS.

- Reworded the managed-service note to drop the specific ADB claim while
  keeping the accurate requirement (ASSM tablespace + CTXAPP).
- Added an "Easy Connect only" warning documenting that wallet/mTLS/TLS and
  TNS descriptors are unsupported, and that transport encryption must be
  handled at the network layer.

Applied to the current and version-0.8 copies; regenerated the docs skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01PginSDrapXsoDd6gN5Pszo

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 14:58:03 -07:00
BenandClaude Opus 4.8 d4f700ed22 Blog: Give Zed's AI Assistant a Persistent Memory (#2598)
* Blog: persistent memory for the Zed editor (hindsight-zed v0.1.0)

New post on the Zed integration: wires Zed's Agent Panel to the Hindsight MCP
server (recall/retain/reflect) plus a global AGENTS.md rule, so the assistant
remembers decisions and conventions across sessions. Grounded in the v0.1.0
source; em-dash-free. Adds a series-style cover.

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

* Blog: update Zed install to the Node CLI (npx), per #2599

hindsight-zed is now a zero-dependency Node CLI: `npx hindsight-zed init`
(or `npm install -g`). Node.js only, no Python. Mechanism unchanged.

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

* Blog: swap Zed cover to the typographic "Memory for Zed" poster

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 16:31:25 -04:00
Ben 041f0f13f4 release(zed): v0.2.0 2026-07-07 16:02:06 -04:00
9dee1c594b refactor(zed): port setup CLI to Node, drop the Python dependency (#2599)
* refactor(zed): port setup CLI to Node, drop the Python dependency

The Zed integration is configuration-only — it writes the `context_servers`
entry into Zed's settings.json and a recall/retain rule into AGENTS.md — and the
MCP server it configures runs via `npx mcp-remote`, so Node.js was already a hard
requirement. Requiring Python *as well* just to write two config files meant
users needed two runtimes.

Port the `hindsight-zed` CLI to a zero-dependency Node CLI so the integration
needs only Node:

- Node CLI under `src/` + `bin/hindsight-zed.js`, shipped via `package.json`
  (matches the existing TypeScript integrations; release-integration.yml already
  detects package.json for npm publishing).
- Behavior-preserving: same commands (`init`/`status`/`uninstall`), flags,
  `--print-only`, env/file/flag config resolution, JSONC-safe settings edits,
  and fenced AGENTS.md rule block.
- Tests ported to Node's built-in runner (`node --test`) — 21 tests.
- CI (`test.yml`) updated to run `npm test` on Node 22 instead of pytest.
- Removes the Python package (`hindsight_zed/`, `pyproject.toml`, `uv.lock`,
  Python `tests/`).

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

* fix(zed): make the Node package publishable by the release workflow

The release workflow classifies any integration with a package.json as
`type=typescript` and unconditionally runs `npm ci` + `npm run build` in
the integration dir. This zero-dependency, no-build JS package had neither,
so `integrations/zed/v*` would fail at release time (invisible in test CI,
which only runs `npm test`):

- add a no-op `build` script so `npm run build` succeeds
- commit package-lock.json so `npm ci` succeeds (it refuses to run without
  one, even with zero deps); lockfile has no node_modules entries, so
  check-integration-lockfiles.sh passes trivially
- drop the stray settings.json (a local `init` scaffold accidentally
  committed) and gitignore it

Verified locally: node --test (21/21), npm ci, npm run build, and
npm publish --dry-run all pass; tarball ships only bin/src/README.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* docs(zed): update setup to Node/npx (drop pip install)

* refactor(zed): scope npm package as @vectorize-io/hindsight-zed

Match the scoped-name convention of the other TS integrations
(@vectorize-io/hindsight-ai-sdk, -chat, -openclaw). CLI/bin command stays
'hindsight-zed'; npx/global-install references updated to the scoped name.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: DK09876 <[email protected]>
2026-07-07 16:01:33 -04:00
Ben a1ebb2d9c6 feat(llm): recover outer JSON span when fence stripping yields non-JSON (#2610)
Grafts the parse-validated fallback from #2557 onto the line-based fence
stripper merged in #2563: after stripping, if the candidate is not valid
JSON (partial/absent fence, prose-wrapped or truncated output), fall back
to the outermost parseable {..}/[..] span. Never returns a worse candidate
than the raw content.
2026-07-07 15:44:02 -04:00
Parafee41 06ddf041e5 fix(minimax): disable thinking by default (#2558) 2026-07-07 15:27:50 -04:00
poog26andBen d251fcb7d2 Fix _strip_code_fences truncating JSON when content contains inner backticks (#2563)
* Fix _strip_code_fences truncating JSON when content contains inner backticks

The old implementation used content.split('')[0] to strip
markdown code fences from LLM responses. This finds the FIRST occurrence of '''
after the opening fence — so when the extracted JSON itself contains literal
triple-backtick characters (e.g. facts about code fence formatting), the split
matches those inner backticks and truncates the JSON mid-string.

Replace with line-based fence detection that only matches fences at line
boundaries per the markdown spec. Inner backticks inside JSON string values
are preserved since they aren't at line boundaries.

* test(llm): cover inner-backtick fence stripping regression

---------

Co-authored-by: Ben <[email protected]>
2026-07-07 15:25:55 -04:00
Parafee41 e839c65537 fix(cli): set default user agent (#2564) 2026-07-07 15:12:18 -04:00
Evoandr266-tech 0cde79b831 fix(consolidation): default invalid dedup actions to keep (#2565)
Co-authored-by: r266-tech <[email protected]>
2026-07-07 15:04:04 -04:00
Ben 8767a518db docs(skill): sync configuration reference for BM25 term cap (#2609) 2026-07-07 15:01:21 -04:00
Parafee41 10ed288d80 build control plane client dependency (#2566) 2026-07-07 14:48:40 -04:00
7143684a81 feat(recall): add opt-in BM25 query term cap (#2567)
* Add opt-in BM25 query term cap

* docs(config): document HINDSIGHT_API_BM25_MAX_QUERY_TERMS

---------

Co-authored-by: r266-tech <[email protected]>
Co-authored-by: Ben <[email protected]>
2026-07-07 14:41:02 -04:00
Nick Old 11da432db2 Fix chunk delete deadlock ordering (#2570) 2026-07-07 14:28:43 -04:00
ishanmalikandishanmalik 73d3231bbd fix(api): accept verbatim extraction mode in bank manifests (#2576)
Co-authored-by: ishanmalik <[email protected]>
2026-07-07 14:06:35 -04:00
Ben 59d825dfca release(opencode): v0.2.7 2026-07-07 13:50:23 -04:00
Sanderhoff-alt ae7099fd02 fix(opencode): install plugin SDK at runtime (#2574)
The OpenCode plugin imports @opencode-ai/plugin/tool from its
built dist entrypoint, so the package must be present in
OpenCode's isolated plugin cache. Keeping it only as a peer
dependency lets npm skip it during plugin installation, which can
make the plugin fail to load with ERR_MODULE_NOT_FOUND.

Move @opencode-ai/plugin into dependencies and remove the
peer-only declaration. Keep @vectorize-io/hindsight-client as a
runtime dependency, and sync package-lock.json so npm installs the
cache tree needed by OpenCode.

Verified with npm run build, npm pack --json, and a local opencode
plugin install from the generated tarball. The generated cache
contained both runtime dependencies and direct import of the
plugin entrypoint succeeded.
2026-07-07 13:49:47 -04:00
Ben 56db6d7cf6 release(codex): v0.3.1 2026-07-07 13:47:50 -04:00
Ben 0eb52762ae release(claude-code): v0.7.3 2026-07-07 13:47:12 -04:00
ishanmalikandishanmalik e93c560288 feat(integrations): add recall score floors (#2575)
Co-authored-by: ishanmalik <[email protected]>
2026-07-07 13:44:42 -04:00
Evo 1f213d00f7 fix(api): allow clearing memory occurred dates with null (#2607) 2026-07-07 13:42:19 -04:00
Parafee41 8f51f99dde fix(retain): preserve JSON chunks during output retry (#2579) 2026-07-07 11:34:24 -04:00
Sanderhoff-alt 5cc1482a72 fix(memory): return metadata from memory browse endpoints (#2583)
The list and get memory-unit paths selected tags and timing fields
but skipped the memory_units metadata column, so metadata retained
on facts was invisible outside recall.

Select and serialize metadata for live and invalidated memory units,
add a curation regression test for both paths, and update docs plus
OpenAPI examples.
2026-07-07 11:11:04 -04:00
Chris Bartholomew 639d84ad32 fix(parsers): coerce non-bytes buffers before the UTF-8 charset probe in markitdown (#2586)
* fix(parsers): coerce non-bytes buffers before the UTF-8 charset probe

MarkitdownParser._utf8_stream_info() is type-hinted file_data: bytes and calls
file_data.decode('utf-8') to check whether a text file is valid UTF-8 before
handing markitdown an explicit charset hint. But callers may pass a
buffer-protocol object that is not a concrete bytes (a memoryview, or a
native/Rust-backed buffer), which has no .decode — raising
AttributeError: '...' object has no attribute 'decode' and failing every
text-file parse (.txt/.json/.md/.csv/.html/...).

The temp-file write in _convert_sync a few lines up already relies only on the
buffer protocol, so the decode probe was the sole spot assuming concrete bytes.
Coerce via bytes(file_data) before the probe. Adds a regression test with a
buffer-only object (no .decode).

* test(parsers): use memoryview stand-in for the non-bytes buffer case

The previous fixture defined a PEP 688 __buffer__ class, which bytes() only
recognizes on Python 3.12+; on 3.11 the CI shard raised
'TypeError: cannot convert ... object to bytes'. Use a memoryview instead — it
has no .decode and bytes(memoryview) works on every supported version, so the
test stays portable while still exercising the coercion path.
2026-07-07 11:04:22 -04:00
Evoandr266-tech 1c74f795a6 docs(eve): sync assistant reply default (#2588)
Co-authored-by: r266-tech <[email protected]>
2026-07-07 10:34:59 -04:00
Parafee41 b4f9fbe1b5 refresh search vector on memory curation (#2552) 2026-07-07 10:13:17 -04:00
Evoandr266-tech b992ba996d fix(agent-sdk): release recall token fix as 0.1.1 (#2596)
Co-authored-by: r266-tech <[email protected]>
2026-07-07 09:54:00 -04:00
Ben f00d3c7f66 Blog: Eve automatic memory (hindsight-eve v0.2.0) (#2584)
* Blog: Eve automatic memory (hindsight-eve v0.2.0)

New post covering the v0.2.0 rewrite of the Vercel Eve integration: memory
is now automatic (instructions resolver recalls before each turn, hook
retains after) with no model-called memory tool. Supersedes the v0.1 draft
in #2480. Adds cover + three demo screenshots (teach -> observation -> recall).
2026-07-06 14:57:48 -04:00
Ben e97b615547 release(eve): v0.2.1 2026-07-06 14:08:30 -04:00
Ben 29cc1d7fdc feat(eve): retain the assistant reply by default (#2585)
Flip `includeAssistantReply` to default `true` so the auto-retain hook stores
both the user's message and the assistant's reply, not just the user's message.
The assistant's reply is usually where the answer lives (the decision, the
solution, the code), and this matches every other Hindsight integration that
does automatic retain:

- agent-framework (same provider/after_run pattern as eve): include_input +
  include_response both hardcoded true
- opencode: retainMode "full-session" (user + assistant) by default
- claude-code: retainRoles ["user", "assistant"] by default

eve was the only auto-retain integration defaulting to user-only. Set
`includeAssistantReply: false` to keep the old behavior.

Updates JSDoc, README, and repurposes the "user-only by default" tests to
assert the new default (both), with the opt-out (false) still covered.
2026-07-06 14:04:35 -04:00
Ben 016b5f0363 release(eve): v0.2.0 2026-07-03 11:06:47 -04:00
Ben dd7e252452 feat(eve): auto-memory mode (v0.2.0) — no model tool-calling (#2527)
Replace the MCP-connection helper with automatic long-term memory backed by
Hindsight's REST API. Memory no longer depends on the model choosing to call a
tool (which proved unreliable — the model would reach for bash, a subagent, or
just acknowledge a fact without saving it).

Two authored files now give an Eve agent memory that just works:
- agent/instructions/hindsight.ts -> hindsightMemory(): a defineDynamic
  instructions resolver that recalls the user's stored memory and injects it as
  a system message before each turn.
- agent/hooks/hindsight.ts -> hindsightRetainHook(): a defineHook that retains
  the user message + assistant answer after each turn.

Pure core (HindsightRestClient, resolver, turn-pairing, recall formatting) is
split from the eve-importing wrappers and unit-tested with a mocked fetch.
Config via HINDSIGHT_API_KEY / HINDSIGHT_API_URL / HINDSIGHT_BANK_ID. Recall is
profile-based (eve's instruction resolver can't see the live user message).
Feedback-loop guard fences injected context so recalled facts are never
re-retained. Docs + integrations.json updated; bumped to 0.2.0 (breaking).
2026-07-03 11:02:54 -04:00
Ben fda1a77f70 Add architxt + Hindsight community blog post (#2526)
Community-contributed integration post (by Gareth Cooper) on architxt's
Temporal Mosaic: turning fragmented enterprise architecture documents into
a queryable, current-state view backed by Hindsight. Includes 5 product
screenshots + a co-branded cover, and registers the author in authors.yml.
2026-07-03 09:26:01 -04:00
Nicolò Boschi 0accef8e98 test(retain): add missing llm_temperature_retain to _build_request_body mock (#2537)
`_build_request_body` reads `config.llm_temperature_retain` (added by the
per-operation temperature work, #2459), but test_batch_request_body_strict_
follows_config's SimpleNamespace config never set it, so the test raised
`AttributeError: 'SimpleNamespace' object has no attribute
'llm_temperature_retain'`. It only fails on PRs that touch hindsight-api-slim;
main hides it via path-filtering, so it went unnoticed.

Set it to None (temperature omitted) so the test still asserts purely on the
`strict` flag it targets.
2026-07-03 14:05:31 +02:00
Nicolò Boschi c77e2368de feat(control-plane): animate the memories constellation & open memories in a dialog (#2536)
Constellation (memories + entities views):
- Ambient motion so the star map feels alive: slow per-node drift, a size
  pulse and brightness twinkle (each desynchronized by an id-derived phase),
  a calm breathing shimmer across idle links, and twinkling hub halos.
- On hover, a bead of light travels each of the node's links, so connections
  read as live signal paths rather than static lines.
- Re-measure the canvas via ResizeObserver when its container reflows (e.g.
  the Fullscreen toggle / layout changes) — window "resize" alone missed
  container-only changes, so CSS stretched the old bitmap and squeezed text.

Memories (data) view:
- Drop the right-hand control/detail side panel. Clicking a memory node now
  opens the same rich MemoryDetailModal the table/timeline use.
- Move the constellation controls (Color by, Group by scope, Link types) into
  an inline row above the graph, next to the view toggle — giving the star map
  full width.
2026-07-03 11:46:21 +02:00
Nicolò Boschi 38ef0247c2 fix(curation): drop archive search_vector column, recompute on revert (#2503) (#2514)
The curation archive (invalidated_memory_units) is a `LIKE memory_units`
clone with no index. It carried a `search_vector` column purely as a passive
copy in the invalidate/revert row-move — nothing ever reads it (no text-search
index, and recall/list/get/export all exclude it). But its type is fixed at
tsvector by the clone, while `ensure_text_search_extension` reconciles
`memory_units.search_vector` to text/bm25vector on non-native backends
(pgroonga / pg_textsearch / pg_search / vchord). The archive was never
reconciled, so the curation INSERT ... SELECT round-trip failed:

    column "search_vector" is of type tsvector but expression is of type text

This is the exact situation `embedding` was in (#2209): a config-derived,
recall-only column that has no business on the cold archive. Fix it the same
way `embedding` was fixed (d4f6a8c2e1b3):

- Migration e7c3a9f1b2d5 drops search_vector from invalidated_memory_units
  (PG + Oracle), so there is no column left to mismatch.
- The curation move omits search_vector from arch_cols (alongside embedding),
  so invalidate/revert never copy it.
- On revert, search_vector is recomputed from the row's own text/context/
  text_signals using the *current* text-search backend — right next to the
  existing embedding recompute. This is more correct than the old verbatim
  copy, which could restore a stale/wrong-type vector if the backend changed
  while the fact sat archived.

The per-backend search_vector SQL is extracted into pg_search_vector_expr as a
single source of truth shared by insert and revert (also collapses the three
near-identical insert query blocks into one). pgroonga/pg_textsearch/pg_search
index base columns directly and leave search_vector empty, so the expression is
None for them and the column is simply not written.

Tests: extend the curation suite to assert the archive drops search_vector and
that revert repopulates it (native); add fast unit tests for
pg_search_vector_expr and the per-backend insert column shape.
2026-07-03 11:44:13 +02:00
Parafee41 a158b819f3 fix(control-plane): keep memory filters visible on empty results (#2532) 2026-07-03 09:27:41 +02:00
illidanandillidan 381963c28a Fix JSON viewer unicode output display (#2531)
Co-authored-by: illidan <[email protected]>
2026-07-03 09:27:18 +02:00
BenandBen ba158c9cdb Add Devin Desktop blog cover image (#2524)
Co-authored-by: Ben <[email protected]>
2026-07-02 14:45:05 -04:00
Ben 767a2c0061 Blog: Devin Desktop persistent memory (formerly Windsurf) (#2483)
* Add Devin Desktop persistent memory blog post

Integration walkthrough for hindsight-devin-desktop (Devin Desktop, formerly
Windsurf): persistent memory via a remote MCP server plus an always-on
.devin/rules rule. Supersedes the earlier Windsurf post (same product,
renamed by Cognition in June 2026).
2026-07-02 09:47:40 -04:00
Nicolò Boschi 36334f27a1 refactor(control-plane): drop the Graph view from memories (#2517)
* refactor(control-plane): drop the Graph view from memories

Removes the Cytoscape-based "Graph" visualization from the memories views,
leaving Constellation, Table, and Timeline. The Graph view was the only
consumer of cytoscape, cytoscape-fcose, and the slider UI control.

- Delete the Graph2D component (src/components/graph-2d.tsx); move the
  shared graph data model + API-response converter (still used by the
  Constellation and entities views) into src/components/graph-data.ts.
- Remove the "graph" ViewMode, its tab button, render section, and
  graph-only state/effects (showLabels, maxNodes, linkStats) from
  data-view.tsx. The shared /api/graph data source that feeds all views
  is untouched.
- Drop cytoscape, cytoscape-fcose, @types/cytoscape and the now-orphaned
  @radix-ui/react-slider dependency + ui/slider.tsx.
- Remove the dead graph2d i18n namespace and graph-legend dataView keys
  from all locale catalogs (parity + used-keys tests stay green).

* chore: sync docs-skill openapi.json to 0.8.4

Pre-existing drift: the v0.8.4 release did not regenerate the bundled
docs-skill OpenAPI snapshot, leaving verify-generated-files red. Running
generate-docs-skill.sh bumps only the version string (0.8.3 -> 0.8.4).
Unrelated to the graph-view removal but required to make CI green.
2026-07-02 13:47:01 +02:00
Nicolò Boschi 6a479dddb9 fix(codex): implement strict_schema via forced tool call + repair invalid \escape (#2504) (#2513)
strict_schema was a dead no-op in codex_llm: structured output always went
through prompt-injected schema + raw json.loads on the model's free-form text.
Escape-heavy content (code, serial/CLI commands, Windows paths, regexes) makes
weaker models emit invalid \escape sequences, so every parse attempt fails and
retain/consolidation burn all retries and fail (same class as #1002/#2339).

- strict_schema=True now routes structured output through a single forced
  function tool (constrained decoding into the response schema), mirroring the
  Anthropic forced-tool_use fix (#2339). No prompt-injected schema, no
  json.loads on free-form text.
- The non-strict fallback and tool-argument parsing now repair invalid
  \escape sequences before giving up, stopping the deterministic retry storm
  for the default config.
2026-07-02 12:09:17 +02:00
Nicolò Boschi 7058d1aad7 fix(control-plane): show all mental models instead of capping at 100 (#2512)
* fix(control-plane): load all mental models instead of capping at 100

The mental models view fetched without a limit, so the dataplane applied
its default cap of 100. Any bank with more than 100 mental models silently
hid the rest — the dashboard's pagination and the files view both operate
over the full in-memory list, so nothing past the first 100 was reachable.

Thread limit/offset through the client and proxy route, and page through
the API in loadData() until a short page is returned, accumulating every
mental model for the bank.

* fix(control-plane): use page size of 100 for mental models paging
2026-07-02 12:02:38 +02:00
Nicolò Boschi 265192e509 docs: restore audio in v0.8.4 release-notes video 2026-07-01 14:26:54 +02:00
Nicolò Boschi 8f2cee4568 docs: changelog and blog post for v0.8.4 (#2474)
* docs: changelog and blog post for v0.8.4

* docs: add compressed release-notes video for v0.8.4 blog
2026-07-01 14:17:12 +02:00
Nicolò Boschi 92f433c904 Release v0.8.4
- Update version to 0.8.4 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.8
2026-07-01 13:43:13 +02:00
Parafee41 f8ce15b9bf show full memory details in explorer (#2490) 2026-07-01 13:37:08 +02:00
Nicolò Boschi d68f618969 feat(stats): distributed bank_stats cache + ?refresh param + stats perf suite (#2495)
* feat(stats): distributed (table-backed) bank_stats cache on PostgreSQL

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: skip synthetic tool_result user messages in sliceLastTurnsByUserBoundary

* fix: skip synthetic tool_result user messages in sliceLastTurnsByUserBoundary

* style(openclaw): prettier-format hasRealTextContent block

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

* feat(config): add HINDSIGHT_BANK_ID_PREFIX env override

* fix(opencode): rename HINDSIGHT_RECALL_CONTEXT to HINDSIGHT_RETAIN_CONTEXT

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

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

* Support per-bank recall tag filters

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

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

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

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

Closes #2458

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes: observations invisible to BM25 retrieval arm.

* Implement test for search vector population in observations

Add test for observation creation with native search vector

* style: run lint

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

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

* chore: remove accidentally committed git-lfs hooks

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

close #2409

* Enhance chunking tests for JSON array formats

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

* Add test for valid JSON in append mode

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: regenerate clients + openapi spec for thoughts_tokens field

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

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

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

No hand edits in any generated file.

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

Fixes #2312

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(docs): mark agrasandhany as community integration

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

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

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

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

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

* docs: drop Richer MCP Tools section from 0.8.3 blog
2026-06-18 11:31:33 +02:00
Nicolò Boschi e1014cc790 Release v0.8.3
- Update version to 0.8.3 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.8
2026-06-18 11:13:37 +02:00
Kuba OdiasandClaude Opus 4.8 da2125cf13 feat(metrics): instrument async worker completion path with operation metrics (#2253)
* Instrument async worker completion path with operation metrics

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

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

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

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

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

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

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

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

* style: apply ruff format

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

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

* docs: correct reflect coverage in worker metric comment

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Format generated Gemini service tier files

---------

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

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

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

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

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

7 files changed, 97 insertions(+).

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

* test(openclaw): harden retain context handling

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

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

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

---------

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

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

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

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

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

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

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

* review: address feedback on backlog metrics

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

Route all four engine fixtures through a `_teardown_memory_engine` helper that
always unregisters the recorder in a `finally` (idempotent — no-op when close()
already did it). Add a fast regression test asserting the registry is left clean
even when close() is skipped.
2026-06-16 15:30:46 +02:00
Nicolò Boschi 3fae76e392 fix(migrations): merge two divergent alembic heads (#2234)
#2209 (d4f6a8c2e1b3, drop archive embedding column) and #<links-index>
(2071c7518f88, add memory_links index) were authored off the same parent and
merged in parallel, leaving the DAG with two heads. `alembic upgrade head`
is ambiguous in that state and CI's test_single_head fails for everyone.

Add a no-op merge revision unifying both heads.
2026-06-16 15:13:43 +02:00
Nicolò Boschi 2f075dedea feat(tags): officially surface tags_match=exact across UI, docs, clients (#2230)
The `exact` set-equality match mode landed in the API + generated clients
in #2149 but was never exposed in the control plane, documented, or added
to the hand-maintained SDK wrappers. This completes the feature.

Control plane: add `exact` to the TagsMatch type/unions and to the
tags_match dropdowns in think-view, search-debug-view, and both
mental-model trigger forms; add translated labels to all 10 locales.

Docs: document `exact` in the recall tags_match table + tag_groups,
the reflect tags value list, and the observations scope-listing guide;
regenerate the docs skill mirror.

Clients: add `exact` to the hand-maintained Python and TypeScript wrapper
Literals/unions and docstrings (generated clients already had it; Rust is
generated from openapi.json at build time).

Supersedes #2159.
2026-06-16 14:58:23 +02:00
Nicolò Boschi 08cfa5d369 test(control-plane): validate t() keys resolve against the catalog (#2232)
Add a vitest guard that walks every src .ts/.tsx file, resolves each
useTranslations("ns") binding, and asserts every static t("key") /
t.rich("key") reference maps to a leaf key in en.json. This closes the
gap between the two existing i18n checks: messages.test.ts only compares
locale catalogs against each other (a key missing from *every* catalog,
en included, passes parity), and find-untranslated.ts does the inverse
(flags strings *not* wrapped in t()). Neither walked from a t() call
site back to the catalog, so a missing key only surfaced as a runtime
next-intl error in the browser.

Runs under the existing `npm test` step in the build-control-plane CI
job, so no workflow change is needed.

The guard immediately surfaced 14 keys referenced by the curation
feature (#1976) but missing from all 10 catalogs (filterActive,
filterInvalidated, invalidatedHint, invalidatedFactsTitle, and the
memoryDetailPanel curation*/editField* set). Add translations for all
locales so the suite is green. Supersedes #2226, which patched only
filterActive.
2026-06-16 14:42:17 +02:00
Nathaniel Clay ArnoldandNicolò Boschi 0135fa39c9 fix(mcp): give update_memory/invalidate_memory non-empty descriptions (#2215)
* fix(mcp): give update_memory/invalidate_memory non-empty descriptions

update_memory and invalidate_memory (added in #1976) used an f-string as
their docstring:

    f"""{_EDIT_DOC}
    Args:
        ...
    """

An f-string is an expression, not a string literal, so Python never assigns
it to the function's __doc__ (it stays None). FastMCP derives a tool's
description from __doc__, so both tools — and their bank_id variants — were
registered with an empty description.

Amazon Bedrock's Converse API rejects any toolSpec whose description is an
empty string, so every Bedrock request that advertised these tools failed
mid-stream (surfacing to clients as a generic 'internal error occurred while
processing the stream'). Providers that tolerate empty descriptions were
unaffected, which is why this only showed up on Bedrock.

Fix: pass the shared doc constant explicitly via @mcp.tool(description=...),
matching how retain/recall already register, and keep a plain-literal
docstring for the Args section. Add a regression test asserting every
registered tool exposes a non-empty description (both registration paths).

* test(mcp): statically reject @mcp.tool definitions without a description

AST-parse mcp_tools.py and fail if any @mcp.tool-decorated function
lacks both a description= kwarg and a real string-literal docstring
(an f-string docstring leaves __doc__ None). Complements the runtime
description test by also covering flag-gated tools and pointing at the
offending line; needs no engine mocking.

* chore(lint): enable ruff B021 (f-string used as docstring)

Catches the f-string-docstring footgun repo-wide at lint time — the
root cause of the empty update_memory/invalidate_memory descriptions.
Clean across hindsight-api-slim; tests/** are excluded from lint so the
static test guards that surface instead.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-16 14:22:20 +02:00
Nicolò Boschi 4d799b5629 test: pin migration-remaining-bankid tests to one xdist worker (#2225)
test_migration_remaining_bank_id_text.py runs two tests that share a
module-scoped pg0 instance on a fixed port (5568). CI runs pytest with
`--dist loadgroup`, which — with no xdist_group on the module — can scatter
those two tests across workers that each instantiate the module fixture and
race to provision the SAME instance. That surfaced as recurring flakes:
"Instance already running", a pg_type UniqueViolation (concurrent CREATE
EXTENSION during migrate-to-head), and "server closed the connection".

Pin the module to a single worker with a shared xdist_group so the fixture
provisions the instance exactly once. Test-only change.
2026-06-16 13:42:14 +02:00
Ben 5e71cebc82 fix(docs): deflake memories.py example by draining async ops between curation steps (#2152)
The memories.py API doc example ran update_memory edit → edit-fields →
invalidate → restore back-to-back on the same unit. Each edit re-embeds and
re-consolidates in the background (a tracked consolidation op), so a later
step could race that work and 404 on a unit mid-rewrite — the restore
intermittently failed with "Memory unit not found". The fixed sleep(3) after
the seed retains was also unreliable under CI load with a live LLM.

Replace the sleep with a wait_for_idle() helper that polls list_operations
until the bank has no pending/processing operations, and drain between each
curation step. All waits sit outside the [docs:...] blocks, so the rendered
documentation snippets are unchanged.
2026-06-16 13:40:54 +02:00
Evo a92e25bcf5 fix(api): gate dry-run extraction behind the operation precheck (#2211)
POST .../memories/dry-run-extract (added in #2205, enabled by default) makes a
real LLM call but was the only enabled-by-default LLM-billable route with no
OperationValidator gating. Wire Depends(precheck_for("dry_run_extract")) like
retain/recall/reflect/mental_model_*/files_retain, and move the feature-flag
check into a dependency declared before the precheck so a disabled route still
returns 404 first. No behavior change when no validator is configured.
2026-06-16 13:40:26 +02:00
8927ab73ae perf(api): index memory_links.bank_id on PostgreSQL (#2223)
* perf(api): index memory_links.bank_id on PostgreSQL

bank_id was added to memory_links in c5d6e7f8a9b0 so bank-scoped reads could
filter the link table directly instead of joining memory_units (an 18s+ JOIN on
large banks), but it landed without an index, so every bank_id = $1 predicate
still sequential-scans the whole table.

Add the missing btree, built CONCURRENTLY inside an autocommit_block with IF NOT
EXISTS for idempotency across retries and re-migrated tenant schemas. The Oracle
baseline (o1a2b3c4d5e6) already creates idx_ml_bank_id on memory_links(bank_id);
this brings the PostgreSQL dialect in line. PG-only by design, so the Oracle
slot is intentionally absent.

* fix(migration): drop invalid leftover index before recreating bank_id index

CREATE INDEX CONCURRENTLY can leave an INVALID index behind if a prior
build is interrupted (lock conflict, disk pressure, signal). IF NOT EXISTS
would then skip recreation, leaving bank_id queries on a seq scan forever.
Drop only an invalid leftover of this name (never a healthy index) before
the concurrent (re)build, mirroring b8c9d0e1f2a3.

* perf(api): composite (bank_id, link_type) index + drop dead entity filter

The stats endpoint's bank-scoped link query is
  SELECT link_type, COUNT(*) ... WHERE bank_id = $1 GROUP BY link_type
A composite (bank_id, link_type) index serves the filter, grouping and
count as an index-only scan, vs a bank_id-only index that still heap-reads
every row to recover link_type. link_type is low-cardinality so the extra
column barely grows the index.

Also remove the now-dead 'link_type <> entity' predicate from the stats and
graph-expansion queries: entity edges were deleted from memory_links and are
no longer written (migration e9b2c7d1f3a4); they're derived on demand from
unit_entities. Removing the predicate also lets the composite index cover
the stats query.

---------

Co-authored-by: zommiommy <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-16 12:29:42 +02:00
Sanderhoff-alt d0c54560ad feat(api): improve Chinese temporal query parsing (#2220)
Move explicit period extraction out of DateparserQueryAnalyzer. The analyzer
now delegates range parsing to temporal_periods, which keeps the public API and
non-Chinese rules while Chinese-specific rules and boundary handling live in
chinese_temporal_periods.

Handle simplified and traditional Chinese expressions for relative days,
weeks, months, years, weekends, half-year periods, Chinese month names,
quarters, and rolling past/future windows.

Separate precise point expressions from fuzzy range expressions so phrases such
as 两天前, 前两天, 几天前, and 一两周前 map to the intended constraint shape
instead of relying on dateparser fallback behavior.

Keep open future starts such as 明天起, 下周起, and 三天后开始 unconstrained
because the API only represents closed ranges.

Guard Chinese matching so non-CJK queries skip the Chinese regex path, while
Chinese substring checks avoid treating ordinary names and words as temporal
constraints.
2026-06-16 12:20:59 +02:00
Nicolò Boschi 946af18c91 fix(curation): drop the embedding column from invalidated_memory_units (#2209) (#2210)
* fix(curation): drop embeddings from invalidated_memory_units archive (#2209)

Invalidating a memory copied the live row — including its embedding —
into the invalidated_memory_units archive via INSERT … SELECT. After an
embedding-model switch, the live tables are re-dimensioned but the
archive is not, so the move failed with "expected 384 dimensions, not
1536".

The archive is cold storage and never a recall surface, so it has no
business keeping an embedding. Instead of also migrating its dimension,
stop storing the embedding there at all:

- invalidate: project NULL into the embedding slot on the move
- revert: recompute the embedding from text/dates/entities (mirroring
  how an edit re-embeds) so the reverted unit is searchable again

This makes the archive's embedding-column dimension irrelevant, so a
model switch can no longer trip a dimension mismatch. A forward
migration clears any embeddings earlier versions already stored.

* refactor(curation): drop the archive embedding column instead of NULLing it

Make "the invalidated_memory_units archive holds no embedding" a
schema-enforced invariant rather than a convention the move queries must
remember. The embedding column is dropped (migration d4f6a8c2e1b3, PG +
Oracle), so:

- invalidate moves every memory_units column EXCEPT embedding into the
  archive
- revert moves them back (live embedding defaults to NULL) and recomputes
  the embedding from text/dates/entities

This structurally prevents #2209 — there is no archive vector to fall out
of sync with the live model's dimension, so a model switch can't reintroduce
the dimension mismatch via a future code change. DROP COLUMN is metadata-only
on both dialects.

The archive's readers (get_memory_unit/list enumerate columns; export does
SELECT * then strips derived columns) never referenced embedding, so nothing
breaks.

* refactor(curation): never create the archive embedding column

Remove the embedding column at its creation sites rather than creating it
and dropping it afterward:

- PG: c9a1b2d3e4f5 drops the LIKE-inherited embedding right after cloning
  invalidated_memory_units from memory_units
- Oracle: the baseline CREATE TABLE no longer lists the embedding column

The forward drop migration (d4f6a8c2e1b3) stays as a no-op (DROP … IF EXISTS /
Oracle ORA-00904 swallow) on fresh databases and does the real drop on
databases created before the column was removed here.
2026-06-16 11:57:09 +02:00
Evo abc1439675 fix(skill-docs): convert all admonition keywords in the docs→skill generator (#2218)
The MDX→skill converter in scripts/generate-docs-skill.sh only handled
:::tip / :::warning / :::note, and each rule required an inline title.
So :::info and :::caution admonitions — and any title-less opener (e.g.
a bare :::note) — were left as raw `:::` markdown in the CI-enforced
agent-facing skill mirror (skills/hindsight-docs/references/**), where the
generic `:::\s*\n` cleanup then ate the closing fence and the admonition
body bled into the following section.

Most visibly, #2202 added a :::caution "shared vs [[]] vs []" warning to
retain.mdx, which now renders as broken raw markdown in retain.md.

Teach the converter every supported keyword (tip/note/warning/info/caution)
with an optional inline title, mapping each to a blockquote (title-less
openers fall back to the capitalized keyword). Regenerated the skill mirror;
this also repairs pre-existing :::info/:::caution/title-less leaks across the
core API reference docs.

Note: source files that are plain .md (e.g. configuration.md) are copied
verbatim by the generator rather than run through this converter, so their
admonitions are unaffected here — happy to extend the converter to that
copy path in a follow-up if desired.
2026-06-16 11:12:20 +02:00
DK09876 c05ab9103f feat(continue): add Continue.dev integration via HTTP context provider (#2213)
Adds hindsight-continue: Hindsight memory for Continue.dev via its native http context provider (@hindsight recall) plus an optional MCP-server + rules setup. Includes the adapter package, tests against Continue's HTTP contract + a gated E2E, CI job, release registration, docs, and registry entry.
2026-06-15 14:38:39 -07:00
Ben 359b2bc762 feat(zapier): add Hindsight Zapier app (actions + REST Hook triggers) (#2119)
* feat(zapier): add Hindsight Zapier app (actions + REST Hook triggers)

A Zapier Platform CLI app that brings Hindsight memory into Zaps.

Actions:
- Retain Memory (create) -> POST /v1/default/banks/{bank}/memories
- Recall Memories (search) -> POST .../memories/recall
- Reflect (search) -> POST .../reflect

Triggers (instant, via Hindsight's webhook API — subscribe POSTs /webhooks,
unsubscribe DELETEs it):
- Retain Completed, Consolidation Completed, Memory Defense Triggered

Auth: API key as Bearer token, Cloud default with self-hosted override; the
Bank field is a dynamic dropdown from GET /v1/default/banks.

Built on zapier-platform-core 19; 'private': true so the npm release path can
never publish it (Zapier publishing is manual via zapier push/promote, not
release-integration.yml — and zapier is intentionally NOT in VALID_INTEGRATIONS).

Adds test-zapier-integration CI job (npm install -> zapier validate -> npm test)
and a repo README row. 15 mocha/nock unit tests; 'zapier validate' is
structurally clean.

Docs-site gallery card + doc page + icon are a follow-up (need the official
Zapier brand asset; omitted here to keep build-docs green).

* fix(zapier): make apiKey optional for no-auth self-hosted + prettier-clean

- authentication.js: apiKey now optional (required: false). The middleware only
  adds the Bearer header when a key is present, so you can connect to a
  self-hosted instance running without auth by leaving it blank; Cloud still
  requires a working key (blank -> 401 fails the connection test).
- README: document self-hosted / localhost usage, the optional key, and correct
  the CLI binary name to 'zapier-platform' (v19 renamed it from 'zapier'); show
  the .env approach so 'zapier invoke' needs no global install.
- Run prettier across the integration (fixes pre-existing format drift that was
  failing verify-generated-files on this branch).

zapier validate still structurally sound; 15 tests pass.

* fix(zapier): correct reflect answer field + recall output shape (found via live test)

Extensive live testing against Hindsight Cloud surfaced two response-shape bugs
the mocked unit tests missed (they mocked the wrong shapes):

- reflect: the synthesized answer is in the response's `text` field, not
  `answer`. searches/reflect read `data.answer` (undefined), so a Zap got no
  answer. Now reads `data.text` and surfaces it as `answer`. Test mock fixed to
  use the real `text` field so it actually guards this.
- recall: results carry no numeric `score`, and the fact-type field is `type`
  (not `fact_type`). Corrected the sample + outputFields so the Zap editor only
  advertises fields that actually populate; test mock made realistic.

Verified live end-to-end: auth, bank dropdown, retain (full + minimal), recall
(real fact extraction), reflect (now returns the grounded answer), and the
webhook subscribe/list/delete lifecycle. 15 unit tests pass; zapier validate clean.

* docs(zapier): correct .env auth-field prefix to authData_ in README

zapier invoke reads .env auth fields with the authData_ prefix (e.g.
authData_apiKey, authData_apiUrl), not bare apiKey/apiUrl. Confirmed against a
working local .env during live testing.

* docs(zapier): add integrations gallery card + doc page

- gallery entry in integrations.json (id zapier, official, category framework)
- doc page docs-integrations/zapier.md (actions + REST Hook triggers, setup)
- official Zapier logo at static/img/icons/zapier.png

check-integrations passes (forward: entry → doc page); JSON valid; prettier-clean.

* feat(zapier): verify webhook HMAC signatures + optional async retain (review notes 2 & 4)

#2 — Webhook signature verification (was: relying only on Zapier's unguessable URL):
- performSubscribe now generates a random 32-byte secret and registers it with
  the webhook; the secret is stored in subscribeData.
- perform verifies the X-Hindsight-Signature: sha256=<hmac> header (HMAC-SHA256
  of the raw body) and rejects mismatches. (Corrected the header name — the API
  sends X-Hindsight-Signature, not X-Webhook-Signature; body is delivered
  byte-for-byte via content=, so the recomputed HMAC matches.)

#4 — Optional 'Process asynchronously' toggle on Retain (default false). Lets
users with very large content avoid Zapier's action timeout; pairs with the
Retain Completed trigger.

17 unit tests pass (added valid/invalid signature cases); zapier validate clean.
2026-06-15 16:44:04 -04:00
Ben 78b1df8d8a blog(gemini-spark): Gemini Spark persistent memory via MCP (#2208)
* blog(gemini-spark): add Gemini Spark persistent memory post

Walkthrough of the config-only Hindsight + Gemini Spark integration:
agent-initiated recall/retain over MCP (no plugin host, no hooks),
Hindsight Cloud direct path vs self-hosted OAuth proxy, setup for both
the Antigravity desktop mcp_config.json and the antigravity.yaml manifest.
2026-06-15 13:50:12 -04:00
Parafee41 989f30e215 fix typescript shared observation scope (#2207) 2026-06-15 17:37:41 +02:00
Nicolò Boschi d382b340f0 feat(api): dry-run fact extraction endpoint (preview, no persistence) (#2205)
Add POST /v1/default/banks/{bank_id}/memories/dry-run-extract — a
read-only tool that previews what the retain step would extract from
text WITHOUT changing the bank: extraction only, no entity resolution,
links, embeddings, or persistence. The "dry-run-extract" path makes the
non-mutating nature explicit.

Returns a dedicated DryRunExtractionResult: the candidate facts plus the
aggregated LLM token usage. Each fact (ExtractedFact) is a subset of the
memory-unit shape — only what a fresh extraction produces: text,
fact_type, occurred_start/end, entities[] (raw, unresolved names).

Every prompt-affecting setting is overridable per call (retain_mission,
extraction_mode, custom_instructions, chunk_size, entity_labels,
entities_allow_free_form, llm_output_language) plus the narrator
(agent_name), so a candidate config can be A/B'd against the bank's
current one. The reference date field is named `timestamp` to match the
retain item payload. The engine authenticates the tenant before reading
any bank-scoped config.

Gated by a static server-level flag HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT
(default true). Since extraction makes a real LLM call, set it to false
to remove the endpoint (returns 404) on cost/abuse-sensitive deployments.

Control plane: a "Dry-run extraction" dialog opened from the Memory Bank
actions menu (text input + raw JSON output, side-by-side).

Regenerated OpenAPI spec + Python/TypeScript/Go client SDKs.
2026-06-15 16:25:31 +02:00
Sanderhoff-alt 59d5319b84 feat(retain): make structured chunk size configurable (#2139)
Add retain_structured_chunk_size as an explicit retain chunking knob
for structured inputs. When unset, structured inputs follow the
effective retain_chunk_size instead of the hidden 1.5x overflow factor.

Thread the setting through retain extraction, append/prepend chunking,
bank config resolution, templates, MCP docs, maintained clients,
generated OpenAPI artifacts, the control-plane retain strategy UI, and
the Rust CLI set-config command.

Validate retain_chunk_size and retain_structured_chunk_size as positive
integers while allowing either value to be smaller. Keep the existing
retain_max_completion_tokens check scoped to retain_chunk_size.

Preserve upstream validation details for client errors through the
control-plane proxy so UI alerts and toasts can show concrete
configuration errors without exposing server-side failure details.

Update chunking, config, hierarchical config, template, MCP, client
payload, control-plane serialization, SDK-response, API-client, and
retain UI validation tests for the new behavior.
2026-06-15 15:51:51 +02:00
Nicolò Boschi 460fc63d9b feat(migrations): parallelize tenant schema migrations (#2203)
Add HINDSIGHT_API_MIGRATION_CONCURRENCY to migrate tenant schemas concurrently (each in its own spawn process; per-schema work stays sequential). NullPool on migration engines bounds per-worker connections. Validated at 20k schemas: no-op resweep ~60min->~11min (5x) at concurrency=12, peak 29 connections, 0 errors. Default 1 (sequential).
2026-06-15 15:39:42 +02:00
Nicolò Boschi e20a36c959 feat(api): omit null fields from JSON responses where wire-safe (#2204)
* feat(api): omit null fields from JSON responses where wire-safe

API responses included every optional field as `"x": null`. Install a
custom route class (ExcludeNoneRoute) that enables response_model_exclude_none
for routes whose response model has no required-and-nullable field, so those
nulls are dropped.

Routes whose model carries a required-nullable field (e.g. DocumentResponse
.content_hash, OperationResponse.error_message) keep emitting nulls — omitting
a key the OpenAPI `required` set declares would break strict generated clients
(the Rust progenitor client decodes those without serde defaults). Detection is
recursive over nested models/generics, so it stays correct as models evolve.

The OpenAPI schema is unchanged (exclude_none is runtime-only), so the spec and
all generated clients are byte-identical and existing clients remain compatible.
Verified the published 0.8.2 client deserializes recall/retain/reflect/list
responses against a server running this change.

* test(api): tolerate omitted null fields in response assertions

Responses now omit null optional fields (ExcludeNoneRoute). Update the four
tests that read these keys via direct indexing to use `.get(...) is None`,
which holds whether the key is absent or explicitly null:
- operations progress (OperationStatusResponse / OperationsListResponse)
- bank-health latency_ms (when LLM not configured)
- reflect based_on (null when facts not requested)
- bank export bank/mental_models/directives (empty bank)
2026-06-15 13:50:01 +02:00
formatmeandNicolò Boschi 13f3e081b7 fix(api): mental model delta refresh (prompt size, JSON, consolidation) (#2170)
* fix(api): shrink mental model delta LLM prompts for provider limits

Delta refresh (scope mental_model_delta_ops) was sending the full
structured document, reflect synthesis, and every fact ever merged into
based_on. That payload grew on each refresh and triggered Z.ai HTTP 400
code 1261 (Prompt exceeds max length).

- Send only facts from the current reflect to the structured-delta LLM;
  accumulated based_on remains stored for audit.
- Budget and truncate user prompt sections (~24k cl100k tokens default).
- Use compact JSON for the current document block.
- Keep normal APIStatusError retries for 1261.

Tests: prompt budget, retry behavior, delta plumbing assertion update.

* chore(control-plane): knip ignore react-dom (Next.js peer, no direct import)

* fix(api): delegate with_config on ConfiguredLLMProvider

Consolidation calls _consolidation_llm_config.with_config(...) after an
initial with_config bind; without delegation, Python raised TypeError for
bank_id/operation kwargs on the wrapper class.

Add regression test for re-bind trace attribution.

* fix(api): robust mental model delta JSON + lint sync

- parse_delta_operation_list: parse_llm_json + balanced-object extract
- Prompt: JSON escaping rules for glm-style invalid output
- Ruff format on touched files (verify-generated-files)
- Tests: test_delta_operation_parse.py

* fix(api): skip invalid delta ops instead of full-synthesis fallback

When the model omits required fields (e.g. replace_block without index),
validate operations one-by-one and apply the rest. Tighten structured-delta
prompt on index requirement.

* fix(api): drop dead with_config, guard delta facts + all-invalid ops

- Remove redundant ConfiguredLLMProvider.with_config: the __getattr__ proxy
  already forwards to LLMProvider.with_config (which accepts bank_id), and
  every caller (_retain/_reflect/_consolidation_llm_config) is an LLMProvider,
  so the method was never reached. Drop its test (passed against main too).
- Add regression test locking the delta supporting-facts fix: only THIS
  refresh's facts go to the structured-delta prompt, while based_on still
  accumulates all facts for grounding. Fails on the pre-fix code.
- Harden parse_delta_operation_list: when the model emits ops but every one
  fails validation, raise DeltaAllOpsInvalidError so the caller falls back to a
  full rewrite instead of applying zero ops and silently dropping new facts.
  A genuine empty operations array stays a valid no-op.
- knip.json: restore trailing newline (prettier).

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-15 13:40:18 +02:00
EvoandNicolò Boschi 20f1a77ea0 fix(db): widen remaining live bank_id columns to TEXT on PostgreSQL (#2106 follow-up) (#2175)
* fix(db): widen remaining live bank_id columns to TEXT on PostgreSQL (#2106 follow-up)

* fix(db): drop mental_model_versions from bank_id widen migration

mental_model_versions is created in j5e6f7g8h9i0 but dropped (DROP TABLE
... CASCADE) in o0j1k2l3m4n5 and never recreated on the upgrade path, so
it does not exist at head. ALTER TABLE mental_model_versions therefore
raised UndefinedTable and -- because migrations run inside the
lifespan-startup transaction -- rolled the whole migration back, bricking
API startup (the exact failure class this repair targets).

Widen only the live tables that exist at head and still carry VARCHAR(64)
bank_id: directives and mental_models. Update the test accordingly (it
previously could not pass: the head migration crashed before any
assertion, and the now-removed FK insert referenced the dropped table).

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-15 12:55:45 +02:00
Nicolò Boschi 0a046f97ae feat(consolidation): add "shared" observation_scopes keyword (#2202)
* feat(consolidation): add "shared" observation_scopes keyword

Add a "shared" value for observation_scopes that resolves to a single
global, untagged scope ([[]]). Memories consolidate into one observation
regardless of their tags, while the tags stay on the source facts for
recall filtering.

This is the supported way to deduplicate observations across volatile
per-call provenance tags (e.g. per-session ids): with combined/per_tag,
a unique session tag puts every retain in its own scope, so near-identical
facts never dedup and accumulate one observation per session. "shared"
keeps recall and the dedup probe on the same (empty) scope, fixing
consolidation quality rather than only the duplicate count.

- consolidator: _resolve_obs_tags_list -> [[]], _resolve_write_scopes -> [frozenset()]
- API/engine type literals + OpenAPI + regenerated Python/TS/Go/Rust clients
- CP client type kept in sync
- docs: retain.mdx 'shared' section (+ shared vs [[]] vs [] caveat),
  observations.mdx dedup pointer; regenerated hindsight-docs skill mirror
- tests: unit scope-resolution + e2e parallel-consolidation scope correctness

* chore(opencode): apply prettier formatting to plugin.test.ts

Pre-existing lint drift unrelated to this PR — CI's verify-generated-files
job reformats all integrations (LINT_ALL_INTEGRATIONS) and flagged this file.
Folding the one-line reflow in here to get the gate green.
2026-06-15 12:50:17 +02:00
156a543cf6 docs(reflect): align reflect_async docstring with read-only tool set (#2200)
The docstring listed a 'learn: Create/update mental models with new insights'
step that is not wired into the reflect agent. reflect_async only hands the
agent read tools (search mental models, recall, search observations, expand),
so reflect synthesizes an answer from stored memories and persists nothing.
Update the docstring to match the actual implementation.

Co-authored-by: Kuba Odias <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-15 12:10:56 +02:00
Evo c3970cfd21 fix(metrics): don't count a client-disconnect cancellation as a failed operation (#2185)
run_cancellable_on_disconnect (added in #2127/#2131 for #2122) converts the
engine's OperationCancelledError into HTTPException(499), which propagates out
through record_operation's blanket `except Exception: success = False`, so every
abandoned recall/reflect was counted as a failure on hindsight.operation.total.

Exclude a client cancellation from the counter entirely (neither success nor
failure), detecting it via the exception's __cause__ chain so an unrelated 499
is still recorded as a failure. Adds regression tests.
2026-06-15 12:10:39 +02:00
Miguel de Benito Delgadoandmdbenito 9500a6bf43 fix(opencode): drop utility re-exports from plugin entry to satisfy legacy loader (#2193)
OpenCode's legacy plugin loader (getLegacyPlugins) iterates Object.values(mod)
and calls every function export as a Plugin factory. It deduplicates by
reference, so default and HindsightPlugin (same fn) are fine. But the entry
also re-exported loadConfig and deriveBankId, which the loader invokes as
plugins and registers as hooks — returning a string and a config object
respectively, neither of which is a valid hooks object.

Earlier versions of the dist (e.g. 0.2.1) additionally exported
DEFAULT_HINDSIGHT_API_URL as a string constant, which the loader would call
as a function and crash on with 'Plugin export is not a function'. That was
fixed in 0.2.2 by dropping the string re-export, but the function re-exports
remained and would still produce silently-wrong hook objects on every session.

The plugin itself imports loadConfig and deriveBankId directly from their
submodules, so removing the re-exports is backwards-compatible: no internal
callers change, no public API is removed (these were undocumented
convenience re-exports), and the default export remains a callable function
for direct import.

Add a regression test that asserts the entry has exactly two function
exports, both pointing at the same reference. This is the legacy-loader
invariant: anything else will be incorrectly invoked.

Closes the use case for the local plugins/hindsight.js wrapper required to
load the package as an npm plugin.

Co-authored-by: mdbenito <[email protected]>
2026-06-15 12:10:14 +02:00
Evo 3d6b19af59 fix(search): use effective-time fallback (mentioned_at, occurred_end) for recency scoring (#2197)
* fix(search): use effective-time fallback for recency scoring

* test(search): cover mentioned_at/occurred_end recency fallback

* style: apply ruff format (collapse multi-line ternaries within 120c)

Clears verify-generated-files CI: ruff format collapses the recency
effective-time fallback (reranking.py) and a main-drift one-liner in
consolidator.py that both fit the 120-char line length.
2026-06-15 12:09:36 +02:00
Evo 99fe231b36 docs(litellm): replace removed opinion fact-type with observation (#2198)
* docs(litellm): replace removed opinion fact-type with observation

* style: apply ruff format to consolidator.py (CI generator-sync)

verify-generated-files requires committed files match ruff format output;
collapses a main-drift multi-line ternary that fits the 120-char limit.
2026-06-15 12:09:03 +02:00
Evoandr266-tech 9be7f59c03 docs(models): register the nous provider so the Models page lists it (#2128)
#2102 added the native Nous Portal provider to config.py
PROVIDER_DEFAULT_MODELS and the models.mdx prose, but not to
hindsight-docs/src/data/llmProviders.json -- the single source of truth
that renders the Models page provider grid, default-models, and
capabilities tables -- so Nous is documented in prose but invisible on
the canonical Models grid.

Add the nous entry and regenerate the CI-enforced skill mirror via
scripts/generate-docs-skill.sh. Same pattern as #1911 (fireworks).

Co-authored-by: r266-tech <[email protected]>
2026-06-15 12:05:01 +02:00
Ben 42e72601f4 blog: Cursor persistent memory (editor + CLI in one post) (#2171)
* blog: add Cursor persistent memory post covering both integrations

One post covers both new integrations:
- hindsight-cursor (editor, first-party): plugin hooks + MCP server,
  with the Cursor 3.x additionalContext workaround via workspace
  rules-file fallback
- hindsight-cursor-cli (CLI, community-built by @Korayem): four
  lifecycle hooks (sessionStart, beforeSubmitPrompt, stop, sessionEnd)

The angle of the post is that both surfaces can share a single bankId
and switch between editor and CLI mid-task without losing context.

Every claim sourced from the integrations' README files:
- Editor: install commands, sessionStart/stop hooks, MCP config, the
  Cursor 3.x bug + rules-file workaround, useRulesFileFallback flag,
  bankId default = "cursor"
- CLI: four-hook table, install command, ~/.cursor/hooks.json shape,
  Cursor CLI v0.45+ requirement, bankId default = "cursor-cli",
  dynamicBankGranularity including gitProject

Test stamps from both integrations on current main:
- hindsight-cursor: 82 passed, 4 skipped
- hindsight-cursor-cli: 87 passed, 0 skipped

Cover is a placeholder (Codex art) for now; swap before merging.


* blog(cursor): swap placeholder cover for Hindsight x Cursor card
2026-06-12 15:17:38 -04:00
Nicolò Boschi 4835bf73d2 docs: add Memory Defense + missed items to 0.8.2 blog post (#2173) 2026-06-12 18:05:42 +02:00
Nicolò Boschi a38fc3453c docs: changelog and blog post for v0.8.2 (#2172)
* docs: changelog and blog post for v0.8.2

* docs: clarify per-bank cost attribution is opt-in via env flag
2026-06-12 17:53:50 +02:00
Nicolò Boschi 6f59a09479 Release v0.8.2
- Update version to 0.8.2 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.8
2026-06-12 17:44:54 +02:00
Nicolò Boschi ea45930949 fix(docs): correct Memory Defense link after dir conversion (#2077) 2026-06-12 17:42:47 +02:00
Ben 9a5aecd178 release(agent-framework): v0.1.0 2026-06-12 08:40:23 -04:00
Nicolò Boschi d81486ff9b fix(control-plane): stop double-fetching graph data on bank view (#2168)
* fix(control-plane): stop double-fetching graph data on bank view

The DataView component had two effects that each loaded graph data — one
keyed on factType/bank/document/chunk, one on tag/scope filters. Both run
on initial mount, so every bank/tab view fired /api/graph twice (issue
2158).

Collapse them into a single auto-loader. When the context changes we drop
the now-meaningless observation scope and feed the cleared value straight
into the same reload, guarding the setSelectedScope(null) echo render with
a ref so the reset never produces a second fetch.

Refs #2158

* fix(control-plane): make graph auto-load idempotent

Add a fetch-signature guard so identical consecutive auto-loads collapse
to a single /api/graph request. This defeats React's mount-effect
double-invoke (dev StrictMode, client-side navigation) and any redundant
re-render that would otherwise re-issue the same query — verified with
Playwright that switching fact-type tabs now fires exactly one request
per view (was two on tab clicks in dev).

Manual reloads (search, load-more, consolidation poll) call loadData
directly and intentionally bypass the guard.

Refs #2158
2026-06-12 12:49:51 +02:00
Evo f6710963e6 fix(consolidation): honor an observation scope limit of 0 (no new observations) (#2163)
The per-scope observation limits added in #2140 document 0 as 'no new
observations', but the two call-site guards used '> 0', so a configured limit of
0 left remaining_observation_slots=None and _build_response_model(None) built an
unconstrained model -- limit:0 behaved like unlimited, the inverse of intent.

Make the guards '>= 0' (matching the truncation guard which already uses '>= 0'),
short-circuit the count query for the 0 case, and add a regression test asserting
a scope cap of 0 creates no new observations.
2026-06-12 11:56:47 +02:00
Ben a63a0c0e70 docs(api): list all 3 supported webhook event types in CreateWebhookRequest (#2155)
The event_types field description said 'Currently supported: consolidation.completed',
but the API actually emits and accepts all three events:
- retain.completed (memory_engine.py)
- consolidation.completed (memory_engine.py)
- memory_defense.triggered (retain/orchestrator.py)

Update the description accordingly and propagate through the regenerated OpenAPI
spec + the embedded copies in the go/python/typescript clients. Description-only
change; no behavior change.
2026-06-12 11:55:51 +02:00
Nicolò Boschi 30fb287d10 fix(api): normalize torch default dtype to float32 after concurrent model init (#2167)
transformers' dtype context manager (entered by SentenceTransformer /
CrossEncoder / from_pretrained) does a non-thread-safe save/restore of the
process-global default dtype. When an fp16 embedding model and an fp32
reranker/query-analyzer load in parallel during MemoryEngine.initialize(), an
unlucky interleave can leave the global default stuck at float16. Every later
encode() then emits NaN vectors that pgvector rejects ("NaN not allowed in
vector") on MPS, or raises "c10::Half != float" on CPU -- non-deterministically
across restarts.

Keep the model loads fully parallel and, once asyncio.gather() has joined every
load thread, normalize the global default dtype back to float32 -- the inference
state a healthy boot already converges to. The reset is race-free (all threads
have finished) and only touches torch if a local provider actually loaded it.

Fixes #2162
2026-06-12 11:51:07 +02:00
Nicolò Boschi f0802b826b chore(ci): enforce unused imports/vars + advisory dead-code scan (#2144)
* chore(ci): enforce unused imports/vars + advisory dead-code scan

Enable ruff F401 (unused imports) and F841 (unused variables) -- previously
ignored as "too noisy" -- across hindsight-api-slim, hindsight-dev, and
hindsight-embed, and clean up the resulting violations. These are now blocking:
lint.sh auto-removes them and the verify-generated-files CI job fails on any
leftover diff.

Add an advisory dead-code scan for what the linter cannot see -- whole unused
Python functions (vulture) and orphaned files/exports/dependencies in the
control plane (knip):
- scripts/hooks/check-unused.sh runs both locally
- new non-blocking check-unused-code CI job surfaces findings on PRs
- hindsight-control-plane/knip.json tunes out toolchain false positives

vulture stays advisory because its function/argument heuristics false-positive
on FastAPI/SQLAlchemy/Pydantic patterns; knip can be flipped to blocking once
the control-plane dead code (PR #2135) lands.

* chore(ci): make knip blocking on unused files/deps; remove dead deps

#2135 deleted tooltip.tsx but left @radix-ui/react-tooltip in package.json, and
react-chrono / three were never imported. Remove all three, and declare
@radix-ui/react-visually-hidden (used in directive-detail-modal but unlisted).

With the control-plane tree now clean, the check-unused-code job runs
`knip --include files,dependencies,unlisted` as a BLOCKING step. vulture and
knip's unused-exports check (the shadcn/ui surface is kept intentionally) stay
advisory.
2026-06-12 11:06:31 +02:00
Chris Latimer 87448b1616 fix(webhook) missing fields in payload 2026-06-12 00:14:47 -06:00
Chris Latimer f304ce7e01 feat(webhooks): SIEM enrichment fields on MemoryDefenseEventData
Adds five optional fields to MemoryDefenseEventData so downstream extensions
(e.g. hindsight-cloud) can surface the per-decision context SIEM operators
need to act on a leaked-secret webhook: severity, the API key that submitted
the retain, fingerprinted hit previews for correlation against credential
inventories, and pointers into the audit trail.

Backward-compatible: all five fields default to None and OSS's built-in
regex defense leaves them unset, so existing OSS receivers see no shape
change. Receivers should treat absence as "not provided" rather than "no
match" — the OSS path still populates matched_types as before.

The hit preview is wrapped in a new MemoryDefenseHit model whose docstring
pins the rule that preview must be a fingerprinted rendering of the value,
never the raw secret. Validates that both detector and preview are present
to guard against extensions accidentally posting the raw value as the only
field.

Closes the gap that motivated keeping a separate memory_defense.violation
event in cloud before the recent consolidation: cloud can now ship the
same SIEM-actionable payload through the canonical memory_defense.triggered
envelope.

feat(webhooks): populate hits[] with fingerprinted previews on OSS

Builds on the schema added in the previous commit by populating the
SIEM-relevant hits field from the OSS regex defense. SIEM receivers
now get a per-match preview (e.g. ghp_AAAA...AAAA) for every redaction
the OSS extension fires, in addition to the existing matched_types list.

Three changes:

1. New _fingerprint_value helper produces a length-aware redaction-
   identifiable rendering of a matched value:
   - length < 6:  returns "[redacted]" (avoid leaking material on
     short matches like an isolated -----BEGIN... marker)
   - length 6-15: first-2 + ellipsis + last-2
   - length > 15: first-4 + ellipsis + last-4
   The raw value never appears in the output.

2. apply_redaction returns a hits list alongside matched_types - one
   entry per matched substring (so two GitHub tokens produce two hits
   rather than collapsing into a single label). Hits threaded through
   RedactionResult -> DefenseDecision -> MemoryDefenseEventData via the
   orchestrator's fire helper.

3. _fire_memory_defense_webhook translates the decision's raw hit dicts
   into MemoryDefenseHit entries. None when the decision carries no
   per-hit data so receivers can distinguish "no preview info" from
   a hypothetical empty list.

Test plan:
- New unit tests for _fingerprint_value across all three length
  buckets (parametrized) plus apply_redaction shape: per-match
  fingerprinted previews, raw value never present, multiple matches
  of the same pattern produce multiple hits.
- Extended test_screen_redacts_secret to assert the regex extension
  passes hits onto DefenseDecision.
- Extended test_retain_fires_webhook_on_redact to assert the wire
  payload carries hits[].
- Helper _memory_defense_webhook_events now orders most-recent-first
  so events[0] always reflects the latest delivery.
- Full test_memory_defense.py + test_webhooks.py: 100 passed.
- ruff + ty: clean.

feat(webhooks): more useful message
2026-06-11 23:27:41 -06:00
DK09876 c6db44b101 fix(test): update hierarchical-config count for observation_scope_limits (#2156)
#2140 (per-scope observation limits) added observation_scope_limits to
_CONFIGURABLE_FIELDS but didn't bump the count tripwire in
test_hierarchical_fields_categorization, so it asserts 38 while the real count
is 39 — failing test-api deterministically on every open PR.

The field is correctly configurable (a per-bank behavioral override). Bump the
count to 39 and add an explicit assertion for the field, matching the test's
documentation pattern.
2026-06-11 15:36:50 -07:00
Chris Latimer 7d57711e75 Merge remote-tracking branch 'origin/main' into feat/parser-accept-cloud-detectors 2026-06-11 14:23:56 -06:00
Chris Latimer 4d369a9a89 fix(broken tests) 2026-06-11 14:13:53 -06:00
Ben e24614db22 blog: Haystack persistent memory (drop-in tools + auto-recall wrapper) (#2147)
* blog: add Haystack persistent memory integration post

Walkthrough of hindsight-haystack — two integration modes:
- create_hindsight_tools() returning a list[Tool] for an Agent
- HindsightMemoryWrapper, a Toolset subclass with auto_recall and
  auto_retain that runs the memory work before/after each turn.
Plus the three memory primitives (retain/recall/reflect) and the
include_* flags to drop any subset.

Every concrete claim verified against the README and
hindsight_haystack/tools.py:
- Package name + version (0.1.0)
- Python >= 3.10, haystack-ai >= 2.12.0, hindsight-client >= 0.4.0
- Exported names from __init__.py
- create_hindsight_tools() and HindsightMemoryWrapper signatures
- "Use toolset.run(agent, ...) not agent.run(...) for auto behavior"
- configure() shape and acceptable kwargs

Underlying integration unit tests: 83/83 passing (3 e2e skipped for
lack of API keys in CI sandbox).

Cover is a placeholder (Codex art) for now; swap before merging.
2026-06-11 15:16:25 -04:00
Ben f5a6c300f1 feat(agent-framework): Hindsight memory for Microsoft Agent Framework (no MCP) (#1989)
* feat(agent-framework): add Hindsight memory integration via context provider

Persistent memory for Microsoft Agent Framework (the successor to Semantic
Kernel) without MCP. HindsightProvider is a ContextProvider whose before_run
recalls relevant memories and injects them into the agent's instructions, and
whose after_run retains the conversation. Reuses the LlamaIndex integration's
client/config pattern and the hindsight-client Python SDK.

Targets the agent-framework-core 1.x before_run/after_run + SessionContext
contract (verified against the installed package since the API has churned).
15 unit tests subclass the real ContextProvider so drift fails loudly, plus a
gated e2e. Includes CI job, release + changelog + docs wiring, and an icon.

* chore(agent-framework): refresh lock to agent-framework-core 1.8.1 (verified no API drift)

* fix(agent-framework): drop unused per-op timeout constants

TIMEOUT_RETAIN/TIMEOUT_RECALL/TIMEOUT_BANK were defined but never used: the
hindsight-client SDK sets one timeout on the constructor and has no per-call
timeout argument, so per-op values can't be wired in. Keep the single
constructor-level TIMEOUT_DEFAULT and document why. Addresses review feedback.
2026-06-11 13:57:20 -04:00
Ben 443cce8146 docs(integrations): use the Gemini logo for Gemini Spark, not the generic MCP icon (#2146)
The Gemini Spark gallery card showed the generic MCP paperclip icon
(/img/icons/mcp.png). Every other named-product integration uses its
own brand mark, so swap in the official Google Gemini 2025 sparkle
(public-domain logo from Wikimedia Commons, {{PD-textlogo}}).
2026-06-11 13:47:44 -04:00
Ben 54e1242193 docs(superagent): add prerequisites to integration quick start (#2137)
* docs(superagent): add prerequisites to integration quick start

The quick-start example calls Superagent guard/redact on the first
retain (both on by default), so it fails immediately without the
required keys. The page documented none of them. Add a Prerequisites
section covering SUPERAGENT_API_KEY and OPENAI_API_KEY, clarify the
hindsight_api_url endpoint (self-hosted vs Cloud), and note that
Superagent's hosted guard-model endpoints are currently unreliable.

* docs(superagent): default the quick start to Hindsight Cloud

Drop the explicit localhost URL so the example uses the package's
default Cloud endpoint (https://api.hindsight.vectorize.io), add
HINDSIGHT_API_KEY to the prerequisites, and show self-hosting as the
opt-in alternative.
2026-06-11 13:43:06 -04:00
Ben 9197498b70 blog: 763,365 downloads in 30 days: Hindsight crosses 1M (#2134)
* blog: hindsight-client passes 1,000,000 downloads
2026-06-11 13:41:25 -04:00
Nicolò Boschi b08f43496a feat(observations): enumerate + filter + visualize observation scopes (#2149)
* feat(observations): enumerate + filter observations by scope

Add an exact (set-equality) tag match mode, a list_observation_scopes
engine method + GET /observations/scopes endpoint, and a scope filter in
the control-plane Observations tab (list + graph views). A scope is the
exact tag set an observation was consolidated under; the empty set is the
global/untagged scope. Regenerated OpenAPI + clients + docs skill.

* fix(i18n): add missing memoryDetailPanel curation* keys

invalidate-memory-dialog.tsx references memoryDetailPanel.curationInvalidateTitle/
Explain/ReasonPlaceholder/Cancel/Invalidate, but these keys were never added to any
locale (the parity test passed because all 10 locales lacked them equally), so the
invalidate dialog logged IntlError: MISSING_MESSAGE and rendered raw key names.
Add all five strings across the 10 locales. Pre-existing gap, unrelated to scopes.

* fix(observations): keep scope-filter trigger single-line for long/multi tags

The scope dropdown trigger relied on SelectValue, which clones the selected
item's wrapping pill layout; a multi-tag or long-tag scope (e.g. [session:2,
user:nicolo]) wrapped to two lines and overflowed the fixed-height control.
Render a compact, single-line, truncating summary in the trigger instead,
keeping the full pills only in the open dropdown list.

* feat(documents): capture observation_scopes in retain_params, show in detail dialog

observation_scopes passed at retain time was only persisted per source fact
(memory_units), never on the document, so the document detail dialog couldn't
show which scoping was requested. Capture it into documents.retain_params in
_build_retain_params (alongside context/event_date/metadata) and surface it as
a top-level field on the get_document response. The control-plane document
detail dialog now shows an 'Observation scopes' row (mode badge or scope chips).

New-documents-only by design: existing docs have no captured value and show
nothing. Note: this also clarifies that all_combinations on 2 tags correctly
creates 3 scopes — the transient '2' is async consolidation still in flight.

* feat(observations): live consolidation refresh + scope clusters on the constellation

Two UX improvements to the observations view:

1. Live refresh while consolidating. The 'In Sync' badge previously read a
   one-shot, up-to-60s-cached stat, so it could show green while observations
   were still materializing (each scope is a separate consolidation pass). The
   view now polls every 4s while pending_consolidation > 0, silently refreshing
   the observations, scope list, and badge in place until consolidation settles.

2. Group-by-scope clustering on the Constellation. A new 'Group by scope' toggle
   lays observations out around per-scope centroids (instead of the id-hash ring),
   colors each scope distinctly, and wraps each scope's nodes in a translucent,
   labeled convex-hull blob — so overlapping tag scopes read as visual clusters.
   Adds clusterKeyFn/clusterColorFn/clusterLabelFn props to Constellation and an
   inline monotone-chain convex hull; suppresses the heat legend while clustering.

* fix(observations): cap scope dropdown height to the viewport

With many scopes the scope filter dropdown grew past the bottom of the screen.
Cap its height at min(60vh, --radix-select-content-available-height) so it fits
the space below the trigger and scrolls for the rest, instead of overflowing.

* feat(observations): make the scope filter a searchable combobox

Replace the plain Select with a Popover + Command (cmdk) combobox so scopes can
be searched by typing — matching the tag filter's search UX — which matters once
a bank has many scopes. Uses a substring filter over each scope's tags (not
cmdk's fuzzy default, which over-matches scattered letters). Keeps the compact,
single-line, height-capped trigger; selection still applies exact-scope filtering.

* chore(cli): mark list_observation_scopes UI-only in coverage manifest

The new scope-enumeration endpoint powers the control-plane scope filter/clusters
and isn't a useful end-user CLI command, so add it to the [skip] list (matches
the other UI-only endpoints) to satisfy check-cli-coverage.

* fix(tests): import TokenUsage from response_models

#2135 removed the TokenUsage re-export from llm_wrapper, but test_load_large_batch
and test_retain still imported it from there, breaking test collection across the
API test jobs. Import it from response_models (where it's defined), matching every
other test.
2026-06-11 19:33:37 +02:00
Chris Latimer f15b93f5cb fix(broken tests) 2026-06-11 11:15:04 -06:00
Nicolò Boschi 5b9027ef16 Merge remote-tracking branch 'upstream/main' into feat/parser-accept-cloud-detectors 2026-06-11 18:45:10 +02:00
Nicolò Boschi b385393b6d feat(consolidation): per-scope observation limits (#2140)
Add an `observation_scope_limits` config field that overrides the bank-wide
`max_observations_per_scope` on a per-scope basis. Each rule maps a scope
pattern (a list of fnmatch tag-globs) to a limit; a consolidation scope
matches under *exact cover* — every tag matched by a glob and every glob
matched by a tag — so `["shared"]` caps the `{shared}` scope without affecting
`{run_1, shared}`, and `["run_*", "shared"]` caps the combined scope only.
The first matching rule wins; scopes matching no rule fall back to
`max_observations_per_scope`.

- config: new `HINDSIGHT_API_OBSERVATION_SCOPE_LIMITS` (JSON), hierarchical
  (per-tenant/bank overridable)
- consolidator: resolve the cap per scope at slot computation; wildcards live
  only in the resolution layer so the SQL count stays exact and indexed
- exposed on `BankTemplateConfig`; regenerated OpenAPI + clients
- unit tests for rule parsing, exact-cover matching, and resolution
2026-06-11 18:27:43 +02:00
Nicolò Boschi 8e6dc5fcd7 fix(control-plane): drop empty meeting-note message that failed locale guard
The enterprise discovery panel used an empty-string en.json value as a
"render nothing for English" sentinel, but the locale catalog guard
(tests/messages/messages.test.ts) bans empty leaf values. Remove the
memoryDefenseEnterpriseMeetingNote key from all locales and the conditional
render — it was low-value copy (a language disclaimer on a demo CTA) and the
source of the build-control-plane / test-hindsight-all failures.
2026-06-11 18:06:24 +02:00
Nicolò Boschi b5f97418cf refactor(memory-defense): accept any detector name instead of a fixed union
The parser no longer gates rules[*].on against a hardcoded detector list.
Unknown detectors are silent no-ops in the OSS extension anyway (only
sensitive_data is screened), so pinning the OSS roster to cloud's just forced
an OSS bump for every new cloud detector to avoid 422-ing a write it never
interprets. on now only has to be a non-empty string; dispatch and
entitlement stay the loaded extension's job.

Also soften the enterprise discovery panel's emerald styling to a more
refined low-saturation tint.
2026-06-11 17:40:29 +02:00
Sanderhoff-alt c96106cc01 chore: remove dead code and stale config (#2135)
Remove unreferenced backend helpers, stale UI/docs components, and
unused imports across the API, control plane, clients, and integrations.

Drop obsolete consolidated-observation helpers and unused scoring code,
clean orphaned React/docs components, and remove stale Radix dependencies.

Align release scripts, Helm docs, lockfiles, generated clients, and
current API examples with the package and endpoint surface still in use.
2026-06-11 17:12:03 +02:00
Nicolò Boschi 4032b27912 feat(embed): local control center web app (#2132)
A persistent, localhost-only control center web app bundled in hindsight-embed:
LLM config wizard, raw .env editor (with effective-only view), daemon +
control-plane Start/Restart/Stop with live API/UI health, editable per-profile
API/UI ports + component versions, daemon + control-plane log tail, profile
delete, deep-linking, token-gated /api/* (CSRF-safe), localhost everywhere.

UI built with Preact + Tailwind (Vite), output committed to static/ and served
by the embed's stdlib http.server (no Node at runtime, offline). Ports moved
from metadata.json into each profile's .env (HINDSIGHT_API_PORT /
HINDSIGHT_EMBED_CP_PORT). CI job verifies the bundle builds + is wired.
2026-06-11 17:09:35 +02:00
Ben e8884bc0a0 docs: remove broken gitcgr code-graph badge (#2133)
The gitcgr.com SSL certificate has expired, so the code-graph badge
image (added in #648) renders as a broken-image icon in the README for
all visitors. Remove it since the third-party service appears defunct
and we don't control the cert.
2026-06-11 17:06:28 +02:00
Chris Latimer 259c82ec01 feat(memory-defense): accept full 7-detector vocabulary in parser
The OSS regex extension still only enforces sensitive_data, but the parser
should accept the full known detector vocabulary so cloud-shape policies
(prompt_injection, size_anomaly, protected_keys, detect_secrets,
base64_decode, llm_screen) pass through the OSS PATCH layer unchanged.
Dispatch + entitlement enforcement happen in the loaded extension; an
on-name the active extension doesn't implement is a silent no-op.

Adds test_parse_policy_accepts_full_detector_union covering all 7 names.
The reject-unknown-detector test still passes for unrelated values like
"nope".
2026-06-11 09:04:04 -06:00
Ben b83f621611 fix(docs): credit cursor-cli integration to its community author (@Korayem) (#2109)
The Cursor CLI integration was contributed by Salem Korayem (@Korayem) in
PR #1975, but the integrations gallery listed it as official/Hindsight Team.
Flip it to a community entry attributed to the author.
2026-06-11 09:16:31 -04:00
Nicolò Boschi 19e607b287 fix(api): make recall/reflect disconnect cancellation actually work (#2131)
The cancellation merged in #2127 never fired in production. Live testing
(curl --max-time against a real server) showed abandoned recall/reflect
requests still ran to completion; 0 cancellations under a 4000-request storm.

Two root causes, both found by black-box testing + ASGI probes:

1. Request.is_disconnected() is broken behind BaseHTTPMiddleware. This app
   installs two @app.middleware("http") handlers (BaseHTTPMiddleware), which run
   the route in a child task behind anyio memory streams, so http.disconnect
   never reaches the route's Request and is_disconnected() returns False forever.
   The #2127 watcher therefore never tripped. (Reproduced in isolation: one
   no-op @app.middleware("http") flips detection from working to broken.)

   Fix: ClientDisconnectCancellationMiddleware, a pure-ASGI middleware installed
   OUTSIDE the BaseHTTPMiddleware layer where it owns the real receive channel.
   It drains receive in a background pump, trips a CancellationToken on
   http.disconnect, and stashes it on the ASGI scope. Only wraps recall/reflect
   (small JSON bodies); everything else passes straight through.

2. Even once the token tripped, recall did not cancel: _search_with_retries
   wraps its whole body in a broad except Exception and re-raises as RuntimeError,
   burying OperationCancelledError. Fix: re-raise OperationCancelledError ahead of
   the broad handlers (both the search body and the connection-retry loop).
   Kept OperationCancelledError as a plain Exception (not BaseException) on
   purpose: BaseException dodges the broad handlers but also slips past the reflect
   agent's isinstance(result, Exception) gather handling and crashes it.

run_cancellable_on_disconnect now just reads the scope token onto RequestContext;
the polling is_disconnected() watcher is gone.

Verified live (real server, real corpus): RECALL CANCELLED and REFLECT CANCELLED
fire; a 30s socket-closing storm produced 397 recall + 80 reflect cancellations
with 40/40 canary recalls served, health all 200, 0 errors, full recovery.
Reflect cancellation is best-effort between agent iterations (a disconnect during
the final-answer LLM call is not interruptible), analogous to the rerank stage.
2026-06-11 12:52:38 +02:00
Nicolò Boschi bc83eacc7b chore(embed): drop unused HINDSIGHT_EMBED_BANK_ID + fix HINDSIGHT_EMBED_LLM_* docs (#2130)
* chore(embed): remove unused HINDSIGHT_EMBED_BANK_ID config var

`HINDSIGHT_EMBED_BANK_ID` was collected (env + interactive/non-interactive
configure), persisted to the profile .env, printed, and round-tripped through
config dicts, but never consumed: the daemon env-builder and run_cli ignore
the `bank_id` key, the memory commands (`memory retain|recall|reflect <bank>`)
take the bank as a required positional arg, and hindsight-api only reads
`HINDSIGHT_API_*` vars. The only reader was a test assertion.

Removes the prompt/read/persist sites in cli.py, the doc rows in the embed
README and sdks/embed.md, and updates the two tests that referenced it.

* chore(embed): fix HINDSIGHT_EMBED_LLM_* docs to the real HINDSIGHT_API_LLM_*

The embed docs/README documented `HINDSIGHT_EMBED_LLM_API_KEY` (marked
Required), `_PROVIDER`, and `_MODEL` as the user-facing LLM config, but no
code reads those names — the CLI honors `HINDSIGHT_API_LLM_*` / `OPENAI_API_KEY`,
`configure` writes the `HINDSIGHT_API_` prefix, and the daemon env-builder
forwards `HINDSIGHT_*` keys verbatim (no EMBED→API rewrite). A user following
the docs literally got "LLM API key is required".

Renames every `HINDSIGHT_EMBED_LLM_*` occurrence to the working
`HINDSIGHT_API_LLM_*` in sdks/embed.md, the embed README, and the two profile
tests (which only assert .env round-trip). Also drops a leftover "memory bank
ID" mention from the README configure description.
2026-06-11 12:37:25 +02:00
Nicolò Boschi 4e7780e593 feat(retain): chunk JSONL at line boundaries (#2126)
* feat(retain): chunk JSONL at line boundaries

Newline-delimited JSON (e.g. session logs) now chunks the same way as
JSON conversation arrays: whole lines are packed into chunks so no line
is split mid-object. This lets JSONL be ingested with mode `append`
without manual coercion to a JSON array.

A single line/turn that overflows the budget is kept whole only up to
1.5x (_CHUNK_OVERFLOW_FACTOR); beyond that it is split as text. The
extractor has no second re-chunk pass, so an unboundedly oversized chunk
would just error at the LLM — this caps the overflow for both the new
JSONL path and the existing conversation-array path.

Closes #2113

* test(retain): assert exact chunk output across text/JSONL/conversation modes
2026-06-11 11:55:39 +02:00
Nicolò Boschi e0221cae6e docs: flag Intel (x86_64) macOS as slim-only in supported-platforms grid (#2115) (#2129)
* docs: flag Intel (x86_64) macOS as slim-only in supported-platforms grid (#2115)

`pip install hindsight-all` on Intel Macs silently backtracks to a
months-old release: every release since 0.4.18 pulls hindsight-api-slim[all],
whose local-ML extra requires torch>=2.6.0 and mlx, neither of which ships
x86_64 macOS wheels. The docs' supported-platforms grid claimed Intel macOS
bare-metal pip was "fully supported", which is false.

- Split the macOS grid row into Apple Silicon (fully supported) and
  Intel/x86_64 (Docker + pg0 , bare-metal pip ⚠️ slim only).
- Mirror the grid into README.md.
- Point Intel-Mac users to hindsight-all-slim / hindsight-api-slim plus a
  hosted embeddings/reranker provider or the in-process ONNX backend
  (which has x86_64 macOS wheels).
- Replace the ad-hoc warnings with one-line pointers to the grid.

Refs #2115

* docs: move Supported Platforms grid to bottom of README

* docs: simplify README platform table to icons, link docs for details
2026-06-11 11:53:16 +02:00
Nicolò Boschi 07c85da988 fix(api): cancel abandoned recall & reflect via cooperative cancellation token (#2127)
* fix(api): cancel abandoned HTTP recall via cooperative cancellation token

Recall ran to completion even after the client disconnected, burning ~2 CPUs
for 60-95s per abandoned request and accumulating toward RECALL_MAX_CONCURRENT
until the instance starved (issue #2122).

Approach: a CancellationToken carried on RequestContext (already threaded into
every engine operation) that the engine checks at recall pipeline stage
boundaries (pre-retrieval, pre-rerank, pre-enrichment), aborting before
dispatching the next expensive stage. The HTTP layer attaches a token that
fires when the client disconnects and maps the resulting OperationCancelledError
to 499.

This is cooperative: it cannot interrupt work already inside a worker thread
(the cross-encoder rerank runs via run_in_executor and cannot be cancelled
once dispatched), but it stops an abandoned recall from progressing into - or
past - that work. The token lives on RequestContext so reflect/consolidation/MCP
and a deadline-based driver can adopt the same checkpoints later.

Scoped to HTTP recall; internal recalls pass no token so checkpoints are no-ops.

* fix(api): extend disconnect cancellation to reflect; share HTTP wiring

Reflect has the same abandoned-work problem as recall (agentic LLM loop +
nested recalls). Thread the same RequestContext cancellation token through it:
the agent loop checks between iterations and the nested recall tool already
checks at its stage boundaries, so an abandoned reflect stops instead of
running every remaining LLM round-trip (issue #2122).

Factor the HTTP wiring into a shared run_cancellable_on_disconnect() helper
used by both the recall and reflect handlers: it attaches the disconnect-driven
token and maps OperationCancelledError to 499, so neither handler duplicates
the try/except.

run_reflect_agent gains an optional cancel_check hook (default None -> inert),
so internal/non-HTTP reflect callers are unaffected.
2026-06-11 11:24:29 +02:00
Mani Saint-Victor 0afa046fc5 fix(migrations): catch CommandError-wrapped ResolutionError in rolling-deployment skip (#2117)
command.upgrade() never raises ResolutionError directly — alembic's
ScriptDirectory._catch_revision_errors wraps it in CommandError, so the
newer-bank rolling-deployment handler never fired and startup died with
a raw traceback. Catch the wrapped form (cause-checked) and route it to
the same warn-and-skip path; unrelated CommandErrors still propagate.

Fixes #2114
2026-06-11 10:33:53 +02:00
DK09876 a1228ec3a7 release(haystack): v0.1.1 2026-06-10 15:45:45 -07:00
DK09876andDK09876 f83fafa45b refactor(haystack): rename HindsightToolset -> HindsightMemoryWrapper (#2118)
The class subclasses Haystack's `Toolset` but is used as an automatic
memory wrapper (auto_recall / auto_retain around an Agent), not a tool
collection. Reusing the `Toolset` name was confusing next to Haystack's
own `Toolset` abstraction — flagged by deepset DevRel in review of the
haystack-integrations gallery entry (deepset-ai/haystack-integrations#505).

Pure rename across the package, tests, README, and docs pages. The class
still subclasses `haystack.tools.Toolset`. No backward-compat alias — the
package is at 0.1.0 with no adoption yet, so the rename is clean.

Co-authored-by: DK09876 <[email protected]>
2026-06-10 15:42:03 -07:00
DK09876 0a74ce3f07 fix(docs): read observation history before curating facts in memories.py (#2120)
memories.py listed memories, then ran edit -> invalidate -> restore on a fact.
Any update_memory call re-consolidates the bank and recreates derived
observations with new ids, so the observation id from the earlier listing was
stale by the time the example called get_observation_history — which the engine
correctly 404s on (NotFoundException), failing test-doc-examples (python) on
every PR.

Move the observation-history read to immediately after list_memories, before
the curate operations, so it uses a live id. No re-consolidation timing
dependency. The existing `if observation is not None` guard still covers the
no-observation case.
2026-06-10 14:24:39 -07:00
Ben 5b5188af82 blog: Flowise persistent memory (three Tool nodes for any chatflow) (#2108)
* blog: add Flowise persistent memory integration post

Walkthrough of the Hindsight Flowise integration — three Tool nodes
(Retain, Recall, Reflect) plus a shared Hindsight API credential.
Every claim verified against source in hindsight-integrations/flowise:
zod schemas, default budget = "mid", default URL, category, the
exposed tool names (hindsight_retain/recall/reflect), and the
DynamicStructuredTool return shape.

Install section is honest about Flowise's distribution model
(upstream monorepo PR, not npm install) rather than promising a
package that doesn't ship that way today.

Underlying integration tests: 17/17 passing (vitest).

Cover is a placeholder (Codex art) for now — swap before merging.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* blog(flowise): fix install-section wording — no upstream PR is open

Searched FlowiseAI/Flowise for any open or closed PR matching
"hindsight" / "vectorize" or authored by any Hindsight contributor
(benfrank241, chrislatimer, cdbartholomew, nicoloboschi, DK09876,
fabioscarsi) — zero results. The draft's phrasing implied a PR was
already open and pending merge. Reword to "the eventual distribution
path is an upstream contribution; until those nodes ship in a Flowise
release..." so the post doesn't promise a PR that doesn't exist yet.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* blog(flowise): drop the "eventual distribution path" sentence

Tighten the install-section preamble per reviewer feedback. The post
now just tells readers how to install today, without speculating on
where the nodes will eventually live.

Install procedure verified end-to-end:
- Cloned FlowiseAI/Flowise 3.1.2 (commit f4e2794)
- Copied the three Hindsight tool nodes and credential
- pnpm add @vectorize-io/hindsight-client (resolved to 0.8.1)
- pnpm install at the root
- pnpm --filter flowise-components build → SUCCESS
  - tsc completed, gulp finished, no type errors
  - dist/nodes/tools/Hindsight{Retain,Recall,Reflect}/*.{js,d.ts} all emitted
  - dist/credentials/HindsightApi.credential.{js,d.ts} emitted
  - Compiled JS correctly requires @langchain/core/tools,
    @vectorize-io/hindsight-client, and zod

The install path in the post is now build-verified, not just
copy-faithful to the README.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* blog(flowise): fix broken link to /developer

The build-docs and verify-generated-files CI jobs failed because the
post linked to /developer, but the developer-docs landing page has
slug: / (it's the docs root, not /developer).

Repoint the "Hindsight API reference" item to /developer/api/quickstart,
which is the actual API entry point and the link other recent posts
use.
2026-06-10 14:21:34 -04:00
Nicolò Boschi 621ab7e66b fix(api): widen history bank_id to TEXT on PostgreSQL (#2106) (#2110)
The split-history migration a7b8c9d0e1f2 declared observation_history.bank_id
(and mental_model_history.bank_id) as VARCHAR(64) on PostgreSQL, but the
backfill source memory_units.bank_id is TEXT (unbounded), as are banks,
documents and entities. Any deployment with a bank_id over 64 chars aborts the
backfill with StringDataRightTruncation; because the migration runs in lifespan
startup inside a transaction, the whole thing rolls back and the API never
comes up — unrecoverable from the running container.

The Oracle path is unaffected (both sides are VARCHAR2(256)), so the fix is
PostgreSQL-only.

- Correct a7b8c9d0e1f2 to create bank_id as TEXT. This recovers deployments
  that *failed*: the migration rolled back, so re-running the fixed DDL
  succeeds. Inert for deployments that already succeeded.
- Add forward-repair migration c3e5a7b9d1f4 (new head) that widens the column
  in place for deployments that already succeeded with the narrow column;
  no-op on already-TEXT columns, so all upgrade paths converge. Mirrors the
  b2d4f6a8c1e3 repair pattern.
- Add a regression test seeding the 78-char bank_id shape from the issue.

Fixes #2106
2026-06-10 18:11:42 +02:00
Nicolò Boschi 72d9881a6a fix(cli): parse get-memory response correctly (#2111)
The `hindsight memory get` command deserialized the API response into a
local `MemoryUnitDetail` struct whose shape had drifted from what
`GET /memories/{memory_id}` (MemoryEngine.get_memory_unit) actually
returns:

- `entities` is a flat list of canonical-name strings, but the struct
  expected a list of `{id, name}` objects, so serde failed whenever a
  memory had entities — surfaced to users as the misleading
  "Invalid API response format".
- the fact type is exposed as `type`, but the struct renamed it to
  `fact_type`, so the Type line always printed UNKNOWN.

The endpoint returns an untyped JSON body in the OpenAPI spec, so the
generated client never validates it and the mismatch only blew up in the
CLI handler. These commands had no test coverage (docs use curl).

Fix the struct to match the response and add regression tests.
2026-06-10 18:07:24 +02:00
Nicolò Boschi de22b606e7 feat(memory): reversible curation — edit/invalidate/revert memory units (#1976)
Edit (text/context/dates/fact_type/entities), invalidate (move to a separate
invalidated_memory_units archive, reversible), and revert raw memory units via
PATCH /memories/{id}. Tracks user edits with edited_at. Control-plane UI, docs
(Memories API page), and multi-language examples included. RFC #1951.
2026-06-10 17:20:59 +02:00
Ben 51d25d84a3 release(obsidian): v0.1.2 2026-06-10 10:59:20 -04:00
Ben c06e85b64f fix(obsidian): clear community-store review for 0.1.2 + asset attestations (#2107)
Obsidian community-store automated review (v0.1.1) flagged three errors and a
warning; this clears them and adds build-provenance attestations.

Errors:
- Manifest description must not include the word 'Obsidian' → reworded to
  '...cites the source notes. Your vault stays the single source of truth.'
- no-static-styles-assignment (chat-view.ts): el.style.height = ... →
  el.setCssStyles({ height }) per the plugin guidelines.
- no-unsupported-api (main.ts): Workspace.revealLeaf requires Obsidian v1.7.2 →
  bump minAppVersion 1.5.0 → 1.7.2 (matches the obsidian@^1.7.2 types we build
  against). versions.json 0.1.2 → 1.7.2.

Warning:
- builtin-modules dep → Node's built-in module.builtinModules in esbuild config;
  dependency removed.

Recommendation (build-provenance attestations for main.js/styles.css):
- Add actions/attest-build-provenance for the Obsidian assets in
  release-integration.yml (+ attestations: write). Assets release in the
  dedicated repo while the build runs here, so verify at owner scope:
  gh attestation verify main.js --owner vectorize-io.

Bumps manifest to 0.1.2. Verified with the bot's own linter
(eslint-plugin-obsidianmd): both code errors clear. build + tsc + 46 tests pass.
2026-06-10 10:58:13 -04:00
Nicolò Boschi 1d1f718ce5 feat(embed): seed .env configs from bundled .env.example template (#2105)
`hindsight-embed configure` and profile creation wrote a bare four-key
file. Seed them from the same `.env.example` shipped in the repo so users
get the full documented option set as commented references.

- Bundle a copy of the repo-root `.env.example` into the package
  (`hindsight_embed/env.example`) so installed/uvx users have the template
  at runtime; a sync test guards against drift.
- Add `env_template.render_config()`: everything is commented out by
  default — only the keys the user explicitly set are active, replaced in
  place (unknown keys appended). This keeps the active config byte-for-byte
  backwards compatible with the old bare file and prevents the template's
  api-server defaults (PORT=8888, OpenAI base URL, gpt-4o-mini, HOST) from
  leaking in and colliding profile ports / forcing the wrong base URL.
- Wire into both `configure` paths and `create_profile`.

Also document that new config flags must update `.env.example` (and re-sync
the bundled embed copy) in the config-addition checklist (CLAUDE.md) and the
code-review checklist.

The hindsight-api side already seeds `.env` from `.env.example`
(`scripts/dev/setup.sh`), so no change there.
2026-06-10 16:46:28 +02:00
Ben a814b97197 release(obsidian): v0.1.1 2026-06-10 09:38:58 -04:00
Ben 61f980f5a3 chore(obsidian): bump manifest + versions to 0.1.1
The release script bumps package.json but not manifest.json/versions.json, and
the community store requires the release tag to equal manifest.json's version.
Bump both ahead of the v0.1.1 release so the dist-repo mirror + BRAT tag match.
2026-06-10 09:38:27 -04:00
Ben fe404efb20 feat(obsidian): grounded-note citations, collapsed-by-default, persisted layout, inline depth (#2104)
Addresses chat-UX feedback:

1. 'Notes retrieved' showed the agent's whole scratchpad (every note any tool
   call touched — ~10 for a one-fact answer). Now shows only the notes the answer
   is grounded on: join based_on.memories (cited facts, no doc id) to the trace's
   recall results (id + document_id) by fact id, deduped by note in citation
   order. Capped at 3 visible with a 'Show all (N more)' toggle. Falls back to the
   full retrieved list only when nothing resolvable was cited (never empty).
2. Notes disclosure now defaults to collapsed (was always-open and noisy).
3. Both disclosures (notes + reasoning) remember the user's last open/closed
   state across sessions via two new persisted settings.
4. Chat depth (reflect budget) is now settable inline in the chat filter bar and
   written back to the persisted default, so the choice sticks.

No API change — match % is intentionally deferred (the score isn't exposed by the
API today). reflect-util.groundedNotes covered by new unit tests; 46 tests pass.
2026-06-10 09:36:05 -04:00
fb16fc4fdd feat(api): per-bank provider cost attribution via OpenAI user field (#1965)
* feat(api): per-bank provider cost attribution via OpenAI user field

Lets operators attribute Hindsight's provider spend per bank.

- Add a `_current_bank_id` engine ContextVar (mirroring the existing
  `_current_schema` pattern) bound in recall_async, retain_async,
  retain_batch_async, and execute_task, with a `get_current_bank_id()`
  accessor. Bindings use a token + finally reset.
- Add `HINDSIGHT_API_LLM_SEND_BANK_AS_USER` (bool, default off). When on,
  outbound OpenAI-compatible LLM and embedding calls are tagged with
  `user=<bank_id>` so downstream cost gateways (OpenRouter usage
  accounting, LiteLLM, Helicone) can key spend per bank. Injection is
  centralized per call_params construction site and never overrides a
  `user` the caller already set.
- Propagate the bank ContextVar into the embedding executor thread:
  generate_embeddings_batch now copies the current context before the
  run_in_executor offload (run_in_executor does not inherit contextvars),
  preserving the existing exception wrapping and 1:1 length validation.
- Make the OpenRouter reranker base URL configurable via
  `HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL` (default unchanged:
  https://openrouter.ai/api/v1/rerank) so rerank can route through a
  metering gateway. The URL is a credential field (not bank-configurable).

Tests cover ContextVar set/reset including on exception, user injection
gated on flag + bank presence + no caller override (chat and tool-calling
paths plus embeddings), real-executor context propagation, and the
configurable rerank base URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* refactor(engine): bind the bank ContextVar via decorator, not inline try/finally

The inline token/try/finally wraps re-indented the entire bodies of
execute_task, retain_batch_async, and recall_async — ~1,130 lines of
indentation-only churn in the diff for a ~40-line feature.

Replace the four inline bindings with a @_bind_bank_id decorator that
binds _current_bank_id from the method's bank_id argument (or a key in
a dict argument, for execute_task's task_dict) with the same token +
finally-reset semantics. Method bodies return to their original
indentation, shrinking the memory_engine.py diff to +51/-1.

Behavior is unchanged and now directly unit-tested: the decorator gets
its own tests for positional/keyword binding, dict-key extraction,
reset-on-exception, and non-string fallback to None.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* refactor(api): dedupe bank-attribution helper into shared module

Collapse the two identical _apply_bank_attribution copies (embeddings + OpenAI-compatible
LLM) into engine/bank_attribution.apply_bank_attribution. Add a docs note that the bank id
is transmitted to the provider as the end-user identifier, and de-pad the new config rows.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-10 15:10:38 +02:00
Nicolò Boschi 82c7df7266 feat(providers): add native Nous Portal provider (Codex-style OAuth, no hermes_cli dep) (#2102)
* feat(providers): add native Nous Portal provider (codex-style OAuth, no hermes_cli dep)

Adds a 'nous' provider that speaks the OpenAI-compatible wire format (thin
subclass of OpenAICompatibleLLM) and authenticates with the rotating,
inference-scoped JWT from a 'hermes portal' login — read natively from
~/.hermes/auth.json, exactly mirroring the Codex provider. No dependency on
the hermes_cli package.

- nous_auth.py: NousAuthManager reads providers.nous OAuth state, decodes the
  JWT exp for proactive refresh, and refreshes via POST {portal}/api/oauth/token
  (x-nous-refresh-token header). Atomic write-back of rotated tokens. Because
  the Hermes auth store is shared with a possibly-running Hermes agent, refresh
  takes the same ~/.hermes/auth.lock flock Hermes uses and re-reads the latest
  refresh_token from disk before exchange (single-use RT reuse-detection safety).
- nous_llm.py: thin subclass; proactive refresh offloaded to a thread so the
  event loop never blocks; one reactive refresh + retry on a 401.
- llm_wrapper.py: register nous in dispatch, validator, no-key set, base-url default.
- tests: auth-store load/refresh/persist/terminal-error + provider wiring (no
  Hermes install or network needed).

* docs(providers): document nous provider + add default model

- config.py: add nous to PROVIDER_DEFAULT_MODELS (deepseek/deepseek-v4-flash)
  so omitting HINDSIGHT_API_LLM_MODEL doesn't fall back to gpt-4o-mini.
- configuration.md: add nous to the provider list + an env example block.
- models.mdx: add a nous example + a 'Nous Portal Setup (Hermes)' section
  covering the 'hermes portal' login, the no-API-key flow, and automatic
  JWT refresh that coordinates with a running Hermes agent.
- skills/hindsight-docs: regenerated bundle from the docs sources.
2026-06-10 14:27:46 +02:00
Nicolò BoschiandChris Latimer a0e6bedcf1 refactor(memory-defense): per-bank regex defense, webhooks, drop dead surface (#2077)
* Implement Memory Guard Lite for OSS

Allow users to prevent token and secret leakage in agent memory.
feat(memory-defense): reject quarantine action in policy parser

refactor(retain): drop quarantine branch from orchestrator

test(retain): remove quarantine-path tests

refactor(memory-defense): remove DefenseAction.QUARANTINE enum value

refactor(api): remove include_quarantined query parameter

refactor(recall): drop include_quarantined parameter from memory engine

test(memory-defense): replace stale parser-reject test with full-union accept test

The previous parametrized test asserted parse_policy() should 422 on any
detector name other than sensitive_data. That contract was deliberately
widened on 2026-06-07 so cloud-style policies pass through api-slim's
parser unchanged. The test was stale; the runtime is correct.

Replaced with test_parse_policy_accepts_full_detector_union, which proves
the actual contract: all 7 detector names are valid in the parser, with
dispatch and entitlement enforcement deferred to the loaded extension.

Memory defense UI

* i18n labels

* Fix tests

* Client changes to fix breaking tests

* Test fixes

* chore: regenerate API clients via generate-clients.sh

The clients were previously hand-generated in a way that diverged from the
project's tooling — including a non-standard hindsight-clients/typescript/client/
directory the generator never produces (the standard output is typescript/generated/),
plus ~150 spurious files.

Revert the entire hindsight-clients/ tree to main and regenerate from the
OpenAPI spec using ./scripts/generate-clients.sh (Rust via progenitor build.rs,
Python/Go via openapi-generator, TypeScript via @hey-api/openapi-ts). The spec
itself is unchanged (a code-regenerated spec is byte-identical to what was
already committed).

Net result is the real API delta only: the new nullable MemoryItem.receipt_uri
field propagated to the Python, TypeScript and Go models.

* refactor(memory-defense): per-bank regex defense, webhooks, drop dead surface

Review cleanup of the memory-defense feature:

- Rename the OSS extension Lite -> Regex (MemoryDefenseRegexExtension,
  memory_defense_regex.py). It is pure regex redaction now.
- Drop the agent_memory_guard (OWASP) dependency entirely — the
  SensitiveDataDetector fallback and to_owasp_policy are gone; nothing
  cloud-tier remains in api-slim.
- Trim the policy to what OSS enforces: { enabled, rules:[{on:sensitive_data,
  action}] }. Removed default_action, protected/immutable namespaces,
  detector_overrides, min_severity, and the unused
  memory_defense_enabled_default server default. Per-bank override stays
  (memory_defense is a configurable field) and the UI writes the trimmed shape.
- Fire a memory_defense.triggered webhook on every non-allow decision (redact
  and block) when one is configured, via the retain orchestrator. Adds
  WebhookEventType.MEMORY_DEFENSE_TRIGGERED + MemoryDefenseEventData. Replaces
  the no-op record_violation hook.
- Block is now actually enforced (drop item / 422 when all blocked) instead of
  being silently downgraded to redact.
- Remove the unused 'status' lifecycle: the add_status migration + its two
  merge migrations, the recall quarantine filter, the status column reads in
  search, and MemoryFact.status. Branch now adds zero migrations (single head).
- Remove receipt_uri from the API (MemoryItem) and clients — it was always
  None and carried no value.

Tests updated/renamed accordingly; OWASP smoke + enabled-default tests removed.

* fix(memory-defense): address code-review findings

- Delete test_migration_status.py (asserted the removed status column/constraint).
- Remove receipt_uri from the Rust CLI (memory.rs + integration_test.rs) — the
  generated client struct no longer has the field, so it wouldn't compile.
- Type the blocked-violations as a BlockedViolation dataclass instead of raw
  dicts (serialized via asdict() in the 422 body); type the webhook helper's
  decision param as DefenseDecision.
- Add an end-to-end test asserting a redact decision queues a
  memory_defense.triggered webhook delivery.
- Drop the unrelated docs/ entry from .gitignore (local scratch, not this PR).

* test(memory-defense): consolidate into a single test_memory_defense.py

Merge the 10 scattered memory-defense test modules (policy parser, regex
engine/screen, redaction benchmark, extension loader, extension-context
wiring, bank-config validation, and the three retain e2e files) into one
test_memory_defense.py, deduping the overlapping unit screen tests and the
duplicated retain redact e2e. 36 tests, same coverage.

* docs(memory-defense): document memory_defense.triggered webhook + block action

- Add memory_defense.triggered to the control-plane webhook event-type selector
  (it was firing but wasn't selectable in the UI).
- Document the memory_defense.triggered event (payload + data fields) on the
  webhooks API page, and link it from the Memory Defense page.
- Document the block action (the page only described redact) and add a
  Notifications section. Regenerate the docs skill copies.

* docs: remove Memory Defense page from version-0.7 (unreleased feature)

The feature was snapshotted into the 0.7 versioned docs by mistake — 0.7 never
shipped Memory Defense. Remove the page and its (sole) Security sidebar category.

* test(memory-defense): assert webhook payload fields + cover block path

- test_retain_fires_webhook_on_redact now parses the queued delivery and
  asserts the MemoryDefenseEventData payload (action/detector/matched_types/
  message + event status), not just that the event type was queued.
- Add test_retain_fires_webhook_on_block: a block decision fires the webhook
  (before the 422 is raised) with action=block. Confirms the delivery persists
  despite the blocked retain returning 422.
- Factor out _memory_defense_webhook_events() helper.

* fix(control-plane): render structured API error details as a string

A blocked retain returns 422 with detail {violations: [{message, ...}]}; the
proxy forwards it as `details` and the client passed that object straight into
the sonner toast, crashing with "Objects are not valid as a React child".
Add describeErrorDetails() to reduce details to a string — joining violation
messages when present (so a Memory Defense block shows e.g. "Sensitive data
pattern matched: aws_access_key"), else JSON-stringifying.

* docs(webhooks): clarify WebhookEvent.status covers the memory_defense action

* feat(memory-defense): record redact/block actions in the audit log

Emit a fire-and-forget 'memory_defense' audit entry for each non-allow decision
(alongside the webhook), with the action/detector/document_id/matched_types in
metadata. Threads the engine's AuditLogger into retain_batch like the webhook
manager; gated by the existing audit_log_enabled switch (off by default).

- Add the memory_defense option to the audit-logs UI action filter + the
  actionMemoryDefense i18n key across all locales.
- Document it on the Memory Defense page and the audit-logging config section.
- Test: a redact retain writes a memory_defense audit row with the expected
  metadata (audit enabled on the test engine).

---------

Co-authored-by: Chris Latimer <[email protected]>
2026-06-10 14:12:46 +02:00
BenandClaude Opus 4.7 fd848a18c1 blog: add truncate markers to oh-my-pi and 10k-stars posts (#2065)
Silences the Docusaurus build warning about untruncated blog posts.
Marker placed after the lead paragraphs so the blog index shows a
clean preview.

Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-06-10 14:08:50 +02:00
Evo a0d91408cf docs: add gemini embedding 2 models (#2090) 2026-06-10 13:58:20 +02:00
Nicolò Boschi 0d55a9b78d feat(api): per-bank LLM connectivity probe (#2034)
Adds POST /v1/default/banks/{bank_id}/health/llm so operators can verify the LLMs a
bank uses for retain / consolidation / reflect actually connect — instead of
consolidation silently stalling when the LLM is unconfigured or unreachable.

- Deliberate (non-polled) probe: one minimal real call per unique LLM config —
  operations sharing a configuration are probed once and the result fanned out.
- Status only per operation: connected / not_configured / auth_failed (rejected —
  usually a wrong or expired API key, the most common failure) / unreachable / timeout.
  Never returns the provider, model, endpoint, API key, or raw provider error (the
  detailed error is logged server-side; the auth category is derived from a 401/403 or
  known auth markers in the error, and leaks nothing).
- Off by default (it makes a real provider call); enable with
  HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH=true. Exposed as features.bank_llm_health on
  /version so the UI hides the action when disabled.
- Engine returns typed dataclasses; the handler holds no SQL and auth is enforced
  in the engine. The probe reuses the bank's per-operation LLM clients.
- Control plane: a "Health" item in the bank Actions menu opens an "LLM connectivity"
  dialog that probes on open (with a re-test button) and shows per-operation status,
  including a clear "Invalid API key" label for auth failures.
- Regenerated OpenAPI + Python/TS/Go clients; i18n across all 10 locales; tests in
  tests/test_bank_health.py.

A broader per-bank GET /health endpoint (#747) was explored but dropped as redundant
with the existing bank stats; only the connectivity probe is net-new.
2026-06-10 13:57:46 +02:00
Nicolò Boschi 90ee101bec fix(reflect): carry directives + language rule into final synthesis prompt (#2100)
The reflect final answer is a separate LLM call whose system prompt dropped the language rule and the bank's directives (they lived only in the agent/reasoning prompt), so weaker models intermittently drifted to English — the mechanism behind flaky multilingual reflect tests. build_final_system_prompt now re-injects the directives section + reminder and a default language rule; HINDSIGHT_API_LLM_OUTPUT_LANGUAGE stays the hard override. Deterministic prompt tests pin the behaviour; real-LLM language tests pass on gemini-2.5-flash and CI-tier gemini-3.1-flash-lite.
2026-06-10 11:10:05 +02:00
27cb1c6843 Support service_tier selection for Amazon Bedrock (#2098)
* feat: add HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER env var

Adds support for setting Bedrock service tier (flex/priority/reserved)
via environment variable, following the same pattern as the existing
Groq and OpenAI service tier support.

- config.py: env constant, default, dataclass field, os.getenv() load
- llm_wrapper.py: bedrock_service_tier param plumbing
- litellm_llm.py: inject service_tier kwarg for bedrock/ models
- configuration.md: table entry + Bedrock example block
- models.md/mdx: Bedrock tip block update

Closes #2072

* Add validation + tests for HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER

- validate() rejects invalid values (e.g. 'standard') with clear error
- Empty string treated as unset (matching llm_output_language pattern)
- Tests: default, flex, priority, reserved, invalid value, empty string

* fix(api): thread bedrock_service_tier from config into LLM providers

The new HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER flag was plumbed through
LLMProvider/create_llm_provider/LiteLLMLLM but nothing ever constructed
an LLMProvider with the resolved config value, so the env var was inert
(service_tier was never injected into the Bedrock call).

- memory_engine.py: pass bedrock_service_tier=config.llm_bedrock_service_tier
  to all four LLMConfig constructions (default/retain/reflect/consolidation)
- llm_wrapper.py: LLMProvider.from_env() reads the env var too, so ad-hoc
  constructions honor it
- test_bedrock_service_tier.py: plumbing tests asserting the tier reaches
  the LiteLLM call kwargs for bedrock/ models, is omitted otherwise, and
  is guarded off non-Bedrock models

* style(test): ruff format test_config_validation.py (fix verify-generated-files)

---------

Co-authored-by: Hermes Agent (Rob) <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-10 10:41:33 +02:00
Nicolò Boschi a942b1c817 feat(api): add Gemini Batch API support for retain fact extraction (#2089)
Adds Gemini Batch API support for retain fact extraction (50% discount, 24h SLA) via the existing HINDSIGHT_API_RETAIN_BATCH_ENABLED flag. GeminiLLM overrides the 4 LLMInterface batch methods, adapting Gemini's upload->create->poll->download flow to the OpenAI-batch shapes the consumer expects (same pattern as FireworksLLM). Gemini-only (Vertex unsupported). Threads usageMetadata into the result body. Live-verified end-to-end. Gemini portion of #1144; consolidation batch is a follow-up.
2026-06-10 10:18:24 +02:00
Nicolò Boschi 0280b3486f feat(ui): export constellation as a shareable SVG poster (#2099)
Add a Share button to the constellation toolbar that downloads the
whole graph as a self-contained SVG poster: dark night-sky background
with a soft glow and plus-grid, the Hindsight logo inlined top-left,
and the nodes/links drawn with the exact canvas formulas (solid heat
dots + hub halos, thin faint colored links) — no labels. Fits the full
graph independent of the live pan/zoom.

Adds exportSvgTitle/exportSvgLabel to all locale message files.
2026-06-10 09:51:03 +02:00
Ben 96910071c9 fix(ci): npm provenance 409 guard + add Obsidian MIT LICENSE (#2094)
* fix(ci): treat npm provenance 409 (tlog) as already-published in release-integration

When a release tag is re-pointed and the workflow re-runs, npm publish --provenance
fails with TLOG_CREATE_ENTRY_ERROR / (409) 'equivalent entry already exists in the
transparency log' because the identical artifact was already logged by the prior run.
The package is already published, so this is benign — widen the already-published
guard to swallow it (alongside the existing 'cannot publish over').

* chore(obsidian): add MIT LICENSE for community-store submission

The Obsidian community-store review bot requires a LICENSE file at the plugin
repo root. The dist repo (vectorize-io/hindsight-obsidian) is mirrored from this
directory via the release workflow, so adding it here propagates on next release.
2026-06-09 16:26:40 -04:00
Evo 68d947b8db docs(integrations): add Gemini Spark page + grid entry (#1779) (#1943) 2026-06-09 16:22:11 -04:00
Ben c815329c14 chore(cursor): ruff-format drift in cursor integration tests (#2095)
The cursor integration test files were committed without ruff formatting,
causing verify-generated-files to fail on every PR branched off main. Run
the formatter to bring them in sync (no logic changes).
2026-06-09 16:15:04 -04:00
Chris Bartholomew 41b6e5746a blog: Hindsight is the fastest-growing open-source AI memory project ever (#2092)
* blog: Hindsight is the fastest-growing open-source AI memory project ever

Equal-age GitHub star analysis (per-star timestamps) plus third-party
validation from OSSCAR (#10 fastest-growing OSS org, ahead of Mem0) and
dope.security (#1 MCP server in enterprise traffic). Adds cdbartholomew
to blog authors.

* blog: add truncate marker, featured image, fix Slack invite link

- Add <!-- truncate --> after the lead (fixes the build warning addressed
  repo-wide in #2065)
- Add featured/social image and hero image
- Replace workspace login URL with the canonical join.slack.com invite

* blog: clean up featured image (remove curve overlapping the headline)

* blog: add captured star-history chart (Hindsight steepest slope); align featured image to brand palette

- Embed a static capture of the overlaid star-history graph in the
  'still accelerating' section; Hindsight shows the steepest slope of
  any project. Replaces the unreliable live-URL embed (rate-limited).
- Recolor the featured/OG card to the Hindsight brand palette
  (#0074d9 -> #009296 gradient, #09090b background) instead of off-palette mint.

* blog: add star-history chart to featured image (text left, chart right)
2026-06-09 15:47:08 -04:00
Ben 85402035ed fix(ci): override git auth with OBSIDIAN_DIST_TOKEN for obsidian mirror push (#2093)
* fix(ci): override git auth header with OBSIDIAN_DIST_TOKEN for mirror push

The previous fix (unsetting the checkout extraheader) wasn't enough: the runner
also authenticates github.com via a git credential helper, so the subtree push
still ran as github-actions[bot] (403 on the dedicated repo). Override the
Authorization header with the dist token via `git -c` — an explicit header
beats both the checkout header and the helper, and propagates to subtree's
internal push via GIT_CONFIG_PARAMETERS.

* fix(ci): unset bot extraheader before overriding with dist token

http.extraheader is multi-valued, so adding our -c header on top of the
checkout's bot header sent two Authorization headers → GitHub 400. Unset the
checkout header first so only the OBSIDIAN_DIST_TOKEN header is sent.

* fix(ci): reset credential helper so only the dist-token header is sent

The runner's git credential helper was injecting the bot Authorization on top
of our extraheader → 'Duplicate header: Authorization' (400). Reset the helper
chain with -c credential.helper= so only the OBSIDIAN_DIST_TOKEN header remains.

* fix(ci): isolate git context for the obsidian mirror push + diagnostics

Push kept sending a duplicate Authorization from a config scope local --unset
didn't reach. Split locally and push from an isolated context (global/system
config nulled, helper disabled, local extraheader unset) with the token in the
push URL → a single Authorization. Also dump auth-config origins for diagnosis.

* fix(ci): reset the inherited extraheader to push as OBSIDIAN_DIST_TOKEN

Diagnostic showed the runner injects the bot token as an http.extraheader via
an *included* config file (no credential helper), which --unset-all can't touch.
Reset the extraheader list with an empty -c value (read last → clears it at
request time) and auth via the push URL → a single dist-token Authorization.
2026-06-09 15:46:49 -04:00
Ben 3f025c1fc3 release(cursor): v0.2.0 2026-06-09 15:21:22 -04:00
Ben 37a20ec524 fix(ci): use OBSIDIAN_DIST_TOKEN for the obsidian mirror push (#2091)
actions/checkout sets an http extraheader for the default GITHUB_TOKEN that
overrode the token embedded in the subtree-push URL, so the push ran as
github-actions[bot] (no access to the dedicated repo → 403). Unset that header
before the push so OBSIDIAN_DIST_TOKEN is used.
2026-06-09 14:26:57 -04:00
Ben a67a8f774d fix(obsidian): mirror plugin to a dedicated repo instead of releasing in the monorepo (#2078)
* fix(obsidian): stop creating a GitHub Release per plugin version

Per-integration GitHub Releases pollute the repo's release list (meant for
the core Hindsight product) and steal the 'Latest' badge — the obsidian
v0.1.0 release displaced v0.8.0. BRAT / the community store also can't target
a tag inside a multi-release monorepo (they read a repo's *latest* release),
so the step never gave working BRAT distribution anyway.

- Remove the 'Attach Obsidian release assets' step from release-integration.yml
  (replaced with a comment explaining why; npm publish is unchanged).
- Point BRAT install instructions at the dedicated repo
  vectorize-io/hindsight-obsidian in the integration README and docs page.
- Add a 'Distribution & maintainers' section documenting the two-repo setup so
  plugin updates are released to both (monorepo = source of truth + npm,
  dedicated repo = BRAT / community-store releases).

* ci(obsidian): mirror plugin to dedicated repo via subtree on release

Instead of manually maintaining two repos, the release workflow now mirrors
hindsight-integrations/obsidian/ to the root of vectorize-io/hindsight-obsidian
(git subtree push --prefix) and cuts the BRAT / community-store GitHub Release
there — the monorepo stays the single source of truth.

- Add the 'Mirror Obsidian plugin to its dedicated repo' step to
  release-integration.yml (unshallow → subtree push → idempotent release).
  Needs secret OBSIDIAN_DIST_TOKEN (contents:write on the dedicated repo).
- Drop the now-unused 'contents: write' permission (no releases are created in
  this repo anymore).
- Rewrite the README 'Distribution & maintainers' section: the mirror is
  automatic, the dedicated repo is generated, don't edit it directly.
2026-06-09 13:47:50 -04:00
91d767cdcb feat(cursor): add Hindsight memory plugin for Cursor (#866)
* feat(cursor): add Hindsight memory plugin for Cursor

Adds a complete Cursor integration using the plugin architecture
(hooks, skills, rules). Automatically recalls relevant memories
before each prompt and retains conversation transcripts on task
completion. Modeled after the claude-code integration with
Cursor-specific adaptations.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* docs(cursor): add integration docs, blog post, and sidebar entry

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* docs(cursor): clarify plugin vs MCP modes, add hook diagnostics

- Add plugin-vs-MCP comparison table near top of integration doc
- Add "Verifying Plugin Hooks" section with state file commands
- Add troubleshooting note: visible tool calls = MCP, not plugin
- Write last_retain.json state file in retain.py for diagnostics
- Add mode: plugin and query_length to recall state file
- Fix test_settings_file_loaded to isolate from user config

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(cursor): install path, always-write diagnostics, Cloud snippets

- Add mkdir -p before cp -r in all install examples (first-run fix)
- Add "fully quit and reopen Cursor" note to all setup flows
- Recall/retain hooks now write status on every invocation
  (success, empty, skipped, error) not just on success
- Fix docs to show ~/.hindsight/cursor-state/ default path
- Add concrete Hindsight Cloud config snippet to Quick Start
- Add Cloud option to blog post setup section

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(cursor): add session field to dynamic bank IDs, add changelog

- Support "session" in dynamicBankGranularity for per-conversation banks
- Add changelog page for cursor integration
- Add test for session-based dynamic bank ID

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(cursor): sync integration README with cookbook/blog setup guidance

- Add mkdir -p for plugin install path
- Add "fully quit and reopen Cursor" instruction
- Show Cloud as Option A, local as Option B, daemon as Option C
- Match the setup flow documented in the cookbook and blog

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(cursor): add pip/uvx installer, fix review findings

- Add hindsight_cursor package with CLI `init` and `uninstall` commands
- Add pyproject.toml for PyPI publishing via existing release pipeline
- Update README install path: `pip install hindsight-cursor && hindsight-cursor init`
- Fix rule/skill files to describe plugin behavior instead of MCP tools
- Add diagnostics on get_api_url failure paths in both hooks
- Remove missing assets/avatar.png reference from plugin manifest
- Add Cloud token retrieval guidance (Settings > API Keys)
- Add test_cli.py with 8 tests for init/uninstall commands

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(cursor): daemon timeout, config defaults, full config docs

- Set daemonIdleTimeout default to 300s (was 0/infinite with no cleanup hook)
- Fix retainEveryNTurns fallback from 1 to 10 in retain.py
- Fix DEFAULTS: hindsightApiUrl="" and bankId="cursor" to match settings.json
- Document all config settings in README (was missing ~15 entries)
- Fix pytest version discrepancy in pyproject.toml
- Fix plugin.json author to "Vectorize" for consistency

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* docs(cursor): streamline setup with init flags, add Docker instructions

- Restructure Quick Start around Cloud vs Local as two clear paths
- Use hindsight-cursor init --api-url/--api-token for one-command setup
- Add Docker run command for users without a local Hindsight server
- Remove separate "configure" step that contradicted init behavior

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor(cursor): replace beforeSubmitPrompt with sessionStart + MCP

beforeSubmitPrompt does not support additionalContext in Cursor's hook
system — the old recall.py was silently ignored. This rewrites the
architecture to use Cursor's native mechanisms:

- sessionStart hook for ambient project-level recall (supports additionalContext)
- MCP integration for on-demand recall/retain/reflect tools mid-session
- stop hook for auto-retain (unchanged, works correctly)

Also fixes Python floor (3.9 -> 3.10, pytest 9 requires it) and
updates docs/blog to match the new architecture.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(cursor): workaround broken sessionStart additionalContext

Cursor's sessionStart hook accepts additionalContext output but silently
drops it before the agent's composer handle is ready — a race condition
acknowledged by Cursor staff in 2026-04, still present in 3.6.31
(verified 2026-06-02 with a marker-emitting test hook). Without a
workaround the plugin's "auto-recall memories at session start" feature
silently does nothing in every install.

Per Cursor staff guidance (Dean Rie, thread 158452), the documented
escape hatch is to write a workspace .cursor/rules/<file>.mdc with
alwaysApply: true — the rules engine injects those reliably. Plugin-
local rules dirs (~/.cursor/plugins/local/...) are NOT reliable per
thread 159101.

Implementation:

- scripts/lib/rules_file.py (new): owns the workaround. Three helpers:
    * rotate_session_rules() — deletes any prior rules file at the top
      of each sessionStart so an empty recall doesn't leave stale
      memories from a previous session.
    * write_session_rules() — writes the .mdc with alwaysApply: true,
      an HTML comment that explains what the file is and links to the
      Cursor bug, and the recalled memories inside a
      <hindsight_memories> block (same wrapper the broken native path
      used, so the static rules guidance is unchanged).
    * ensure_gitignored() — idempotently appends the file path to
      <workspace>/.gitignore when the workspace is a git repo. No-ops
      otherwise. Matches both /-anchored and bare relative forms so we
      don't double-add against an existing entry.

- scripts/session_start.py: rotates at the top, writes the fallback
  file after recall succeeds, gates both behind config flags
  (useRulesFileFallback, appendToGitignore, both default True). Still
  emits additionalContext to stdout below — when Cursor fixes the
  upstream bug, dropping the workspace write is the only code change
  needed; the same plugin works on the native path with no protocol
  rev.

- scripts/lib/config.py: two new config keys + HINDSIGHT_USE_RULES_
  FILE_FALLBACK / HINDSIGHT_APPEND_TO_GITIGNORE env overrides.

- rules/hindsight-memory.mdc: tells the agent where recalled memories
  now appear (the new .cursor/rules/hindsight-session.mdc file) and
  notes that the file is plugin-generated and safe to delete.

- tests/test_rules_file.py: 18 tests pinning the on-disk shape:
  frontmatter, alwaysApply, bug link, rotation, idempotent gitignore
  with both anchor forms, falsy workspace handling, write-error
  degradation.

Why this design (vs. alternatives):

- Just shipping MCP-only and documenting the limitation would repeat
  the OpenAI Agents notebook-10 Pattern-1 failure mode: the agent has
  to choose to call recall, and small models reliably skip it. Auto-
  inject doesn't depend on tool-call choice.
- Reverting to beforeSubmitPrompt would mean a recall per turn instead
  of per session, and Cursor staff have signalled additional_context
  on that hook is unimplemented (forum 150707).
- The workspace file is the price of Cursor's bug being open with no
  ETA. Mitigations: auto-rotate, auto-gitignore, in-file explanatory
  comment, config opt-outs.

Verification:

- Full suite: 74 passed (56 prior + 18 new).
- Smoke end-to-end against a fresh git repo: rules file written with
  correct frontmatter, .gitignore appended cleanly with both an
  explanatory comment and the path entry, no duplicate-add on re-run.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* test(cursor): adopt requires_real_llm bucketing + live E2E + lockfile + docs

Aligns cursor with the standing test-bucketing convention from PR #1469
("Split test suite into deterministic mock and real LLM buckets") that the
other eight Python integrations already follow.

Changes:

- pyproject.toml: register the `requires_real_llm` marker so the live
  E2E suite is selectable as a discrete bucket (and excluded from the
  deterministic CI path via `pytest -m "not requires_real_llm"`). Add
  hindsight-client as a dev dep — the E2E driver needs it to seed and
  verify banks; the runtime plugin scripts still use stdlib only.

- tests/test_e2e.py (new): four-test gated suite that drives the actual
  hook scripts the way Cursor does — JSON on stdin, env vars for config
  — against a live Hindsight server. Covers:
    1. session_start writes the rules-file workaround with recalled
       content, appends `.gitignore`, and emits the forward-compat
       `additionalContext` to stdout.
    2. empty-bank case: hook succeeds without writing a rules file.
    3. opt-out: `useRulesFileFallback=false` produces no `.cursor/` or
       `.gitignore` mutations even when recall surfaces content.
    4. retain end-to-end: drives `retain.py` with a JSONL transcript
       (the on-disk shape Cursor actually emits, not an inline messages
       array), then verifies the bank holds the fact via direct recall.

  Two non-obvious fixtures the suite needs:
  - `HOME` / `CURSOR_PLUGIN_DATA` redirected to tmp so the test doesn't
    touch the developer's real `~/.hindsight/cursor.json` or state.
  - `HINDSIGHT_BANK_MISSION` overridden to a focused mission that aligns
    with the seeded fixtures — the production default mission is broad
    boilerplate, fine for real users but too diffuse to reliably
    surface targeted test content within a deadline.
  - `HINDSIGHT_RETAIN_EVERY_N_TURNS=1` because retain.py batches every
    N turns (10 by default) and a single-shot test only has one turn.

- uv.lock: committing per the convention every other Python
  integration follows. 258 KB, 29 packages resolved, `uv lock --check`
  clean.

- README.md: new "How session memory reaches the agent" section
  documenting why the plugin writes `<workspace>/.cursor/rules/
  hindsight-session.mdc` (Cursor's native `additionalContext` channel
  is broken, forum thread 158452, still open in 3.6.31). Captures the
  empirically-verified behaviour: Cursor blocks prompt submission
  until sessionStart returns, so every new agent's first prompt has
  memories, the rules file is regenerated each session, and the file
  is auto-gitignored. Two new config knobs (`useRulesFileFallback`,
  `appendToGitignore`) added to the Session Recall table.

Verification:
- Deterministic bucket: 74 pass / 4 deselected (the new gated E2E).
- Live bucket (HINDSIGHT_API_URL=http://127.0.0.1:8888): 4 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(cursor): default to hosted backend + give each retain a distinct document_id

V2 audit (2026-06-02) caught two real bugs in cursor that were missed by
the V1 pass:

1) Goal-5 (Default to Cloud) FAIL — settings.json shipped
   hindsightApiUrl='' and the daemon path treated empty as "fall back to
   local daemon at 127.0.0.1:9077". Users following the docs ("just enable
   the plugin") never reached the hosted backend without explicitly
   passing --api-url. Every other integration's empty-config path lands on
   https://api.hindsight.vectorize.io.

2) The retain path used document_id=session_id in full-session mode,
   which silently upserts the same Hindsight document on every retain.
   The audit's 5-turn distinct-fact driver exposed this as "5-turn cloud
   → 1 topic surfaced" — earlier turns got overwritten because each
   retain rewrote the single per-session document with whatever
   transcript snapshot was current.

Both are addressed below; the live test suite still passes against the
local server and the new deterministic tests pin the cloud-default
resolution + the unique-document-id derivation.

Changes:

- scripts/lib/config.py — add ``DEFAULT_HINDSIGHT_API_URL`` constant
  (``https://api.hindsight.vectorize.io``). Add ``useLocalDaemon`` flag
  (default ``False``) so self-hosters can opt back into the auto-managed
  daemon path. New env override ``HINDSIGHT_USE_LOCAL_DAEMON``.

- scripts/lib/daemon.py — rewrite ``get_api_url`` resolution:
    1. Explicit ``hindsightApiUrl`` wins.
    2. A locally-running server on the configured port is used (preserves
       the "developer already started a daemon" path).
    3. ``useLocalDaemon=True`` AND ``allow_daemon_start=True`` (retain
       path) triggers the auto-managed daemon. Recall path never starts a
       daemon on its own.
    4. Otherwise → ``DEFAULT_HINDSIGHT_API_URL``. A failed daemon-start
       under (3) also falls back here rather than hard-erroring, so the
       plugin keeps working when ``hindsight-embed`` isn't on PATH.

- scripts/retain.py — every retain now derives
  ``document_id = f"{session_id}-{int(time.time() * 1000)}"`` regardless
  of retainMode. The chunked-vs-full-session distinction at the doc-id
  layer was always a misfeature; full-session mode now means "the
  transcript ingested per retain may span the whole session", not "every
  retain writes the same document".

- tests/test_daemon.py (new) — pin the four-tier resolution + env
  override + the source-shape of retain.py's document_id derivation.

Verification:
- Deterministic bucket: 81 pass / 4 deselected (74 prior + 7 new).
- Live bucket: 4 pass / 0 fail against 127.0.0.1:8888.
- Manual smoke for empty-config → returns ``DEFAULT_HINDSIGHT_API_URL``.
- Live server still resolves to ``http://127.0.0.1:8888`` when healthy.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(cursor): parse Cursor 3.x role-nested transcript format

retain.py's read_transcript only recognized two transcript shapes:
- Flat:        {role, content}
- Type-nested: {type: "user"|"assistant", message: {role, content}}

Cursor 3.6.31 writes a third shape to its stop-hook transcript:

  {"role":"user","message":{"content":[
    {"type":"text","text":"..."},
    {"type":"tool_use","name":"...","input":{...}}
  ]}}

Top-level has `role` (not `type`), and `content` lives under `message`
as a list of typed blocks (not at the top level as a string). The old
parser's two branches both missed every line: `entry.get("type")` was
None and `"content" in entry` was False. read_transcript silently
returned [] for every Cursor 3 transcript, and retain.py bailed with
status=skipped reason=empty_transcript on every stop hook.

Visible symptom: auto-retain silently stops working under Cursor 3
even though the stop hook fires correctly and transcript_path points
at a real, populated file (verified by reading
~/Library/Application Support/Cursor/logs/.../cursor.hooks.*.log —
the input JSON includes a valid transcript_path that the parser then
ignores). End users see recall continue to work (sessionStart writes
the rules-file workaround) but new turns never get retained.

Fix:
- Add _normalize_blocks_to_text to flatten typed-block lists to a
  single string, inlining a compact [tool_use:<name>] marker so
  downstream Answer:/Thought: handling still sees coherent structure.
- Recognize the role-nested Cursor 3 shape explicitly.
- Keep flat and type-nested handling intact.

Verified end-to-end against a real Cursor 3.6.31 transcript captured
from ~/.cursor/projects/.../agent-transcripts/<conv>/<conv>.jsonl:
read_transcript now returns the 15 messages it should (1 user + 14
assistant turns) instead of 0.

Regression tests (3 added):
- test_read_transcript_parses_flat_format pins the flat shape.
- test_read_transcript_parses_type_nested_format pins the type-nested
  shape.
- test_read_transcript_parses_cursor3_role_nested_with_block_content
  is the regression: fails on the pre-fix parser (returns []), passes
  now. Also asserts the [tool_use:Shell] marker survives.

14/14 tests in test_hooks.py pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore(docs): drop missing image refs in cursor blog post

The 2026-04-03 cursor-persistent-memory blog references
/img/blog/cursor-persistent-memory.png in both frontmatter and
inline markdown, but the image was never added to the repo. build-docs
fails MDX compilation with "Markdown image with URL
/img/blog/cursor-persistent-memory.png couldn't be resolved to an
existing local image file".

Strip the two references so the post renders. The prose stands on its
own without an illustration; an image can be added in a follow-up PR
if/when one is produced.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore(cursor): sync openapi.json with main

Branch carried an older snapshot of hindsight-docs/static/openapi.json
that pre-dated main's addition of the OperationProgress schema.
check-openapi-compatibility flagged the missing 'progress' field on
GET /v1/default/banks/{bank_id}/operations/{operation_id} as a
backwards-incompatible removal.

Re-checkout main's openapi.json onto the branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore(cursor): drop cursor-persistent-memory blog post

The blog post was added as marketing for the Cursor integration but
the accompanying illustration was never produced. Earlier commit
0e4b2568 stripped the missing image references so build-docs would
pass; user prefers the blog post itself be dropped from the integration
PR and authored separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore(cursor): ruff format scripts + generate docs-skill changelog

verify-generated-files CI flagged drift in three cursor scripts
(scripts/lib/daemon.py, scripts/retain.py, scripts/session_start.py)
and a missing skills/hindsight-docs/.../integrations/cursor.md.

- scripts: applied ruff format/check (3 files reformatted, all checks
  pass).
- generate-docs-skill.sh produced the integrations/cursor.md changelog
  mirror.

Format-only + a generated file regeneration; no behaviour changes.
All cursor tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* ci: re-trigger CI

A previous push to this branch silently did not trigger a pull_request
event in GitHub Actions, leaving the PR without a CI run for the latest
HEAD. Push an empty commit to force a new event.

* ci: empty commit to attach pull_request CI check to the PR head

(Previous pushes did not auto-trigger pull_request workflow events for
reasons internal to GitHub Actions; manual workflow_dispatch runs passed
green but their checks don't roll up onto the PR. Re-poking the head
to surface the green state on the PR.)

* ci: trailing newline to force CI retrigger

* fix(cursor): address review — drop dead code, register changelog + gallery

- Remove compose_recall_query / truncate_recall_query from scripts/lib/content.py
  (ported from openclaw but unused — cursor only recalls at sessionStart) and
  their test; slice_last_turns_by_user_boundary stays (used by retain.py).
- Add cursor to the INTEGRATIONS map in generate_changelog.py so the release
  changelog step resolves the slug.
- Add the integrations.json gallery entry + icon and rely on the existing
  docs-integrations/cursor.md so check-integrations.mjs passes.

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Ben <[email protected]>
Co-authored-by: DK09876 <[email protected]>
2026-06-09 09:50:22 -07:00
Nicolò Boschi 22ae72a907 feat(api): support gemini-embedding-2 family (per-input embedding) (#2087)
Closes #1139. The gemini-embedding-2 multimodal models aggregate a multi-input request into one embedding, breaking 1:1 input->vector alignment. Force batch size 1 (one input per call) for that family, keep batching for gemini-embedding-001, and raise a clear error on misaligned counts. Adds unit tests + a real Vertex integration test that skips when the preview model isn't enabled.
2026-06-09 18:08:14 +02:00
Ben 4cb78173ba release(cline): v0.2.0 2026-06-09 11:55:01 -04:00
Ben 36e31c675c feat(cline): ship as pip-installable hindsight-cline package (#2088)
The docs told users to run `python /path/to/.../install.py` with no way to
obtain that file (no pip package, no clone step) — effectively unusable. Bring
cline in line with roo-code and cursor-cli by shipping it as a pip package.

    pip install hindsight-cline
    hindsight-cline install --api-url ... --api-token ...
    hindsight-cline uninstall

- Move the install logic into a hindsight_cline package with an argparse CLI
  (install/uninstall subcommands) exposed via a console_scripts entry point.
- Bundle the hook payload (4 hook scripts + lib/ + settings.json) as package
  data under hindsight_cline/hooks/, read via importlib.resources.
- Add pyproject.toml (hatchling), LICENSE, py.typed, uv.lock.
- Switch the CI job to uv build + uv sync --frozen + uv run pytest.
- Update README, docs page, and the launch blog post to the pip flow.

The changelog generator already maps cline -> hindsight-cline. Detecting a
pyproject.toml, the release workflow now publishes hindsight-cline to PyPI
(first release needs a PyPI pending publisher).
2026-06-09 11:53:48 -04:00
Ben 1c133dbd6c release(cursor-cli): v0.2.0 2026-06-09 11:18:22 -04:00
Ben c6dd089445 feat(cursor-cli): ship as pip-installable hindsight-cursor-cli package (#2083)
Convert the Cursor CLI integration from a git-clone + ./scripts/install.sh
flow to a pip-installable package with a `hindsight-cursor-cli install` CLI,
matching roo-code and the other Python integrations.

- Add pyproject.toml (name: hindsight-cursor-cli) and console script
- Add hindsight_cursor_cli/ package: cli.py + install.py, a Python port of
  the old install.sh/uninstall.sh
- Bundle the hook payload (scripts/, settings.json, hooks.json) as package
  data under hindsight_cursor_cli/hooks/; the installer deploys it to
  ~/.cursor/hooks/cursor-cli/ and merges the hook registry
- pyproject is the single source of truth for the version; the installer
  stamps it into the deployed settings.json (used by client.py's User-Agent)
- Remove scripts/install.sh and scripts/uninstall.sh
- Add test_install.py + test_cli.py; retarget existing hook tests
- Switch the CI job to the uv build/sync/test flow
- Update README + docs to `pip install hindsight-cursor-cli`
2026-06-09 11:16:44 -04:00
Ben ba44e0205d docs(icons): use real Cline brand mark in place of placeholder (#2086)
The existing cline.svg was a hand-drawn placeholder — dark rounded
box with two blue eyes and an antenna — that doesn't match Cline's
actual logo. Swap it for the official Cline AI mark (black squircle
with two pill cutouts and a small knob on top).

Source: uxwing.com/cline-ai-icon — licensed for commercial use
without attribution.
2026-06-09 11:14:02 -04:00
Ben d5df9ad083 blog: Cline persistent memory (lifecycle hooks, no MCP) (#2085)
* blog: add Cline persistent memory integration post

Walkthrough of the new Hindsight + Cline integration that wires up
persistent memory via Cline's lifecycle hooks (no MCP). Covers the
four hooks, install + config, per-project and team memory patterns,
and tradeoffs.

Cover is a placeholder (Codex art) for now — swap before merging.


* blog(cline): replace em-dashes with contextual punctuation

Targeted sweep replacing 21 em-dashes with the appropriate punctuation
(commas / semicolons / periods / colons) given each surrounding clause.
The table-cell placeholder on the "Model tool-calling needed" row
becomes "n/a" so the column still reads as "not applicable for the
default."

Code blocks, URLs, file paths, and the ASCII flow diagram (which uses
U+2500 box-drawing characters, not em-dashes) are untouched.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* blog(cline): explain why Cloud matters for the Cline workflow

Expand the Cloud-section intro to cover the Cline-specific wins:
multi-machine VS Code sync, no LLM key in the hook environment, and
no local hindsight-api to keep running while doing dev work.


* blog(cline): swap placeholder cover for Hindsight x Cline card
2026-06-09 10:56:16 -04:00
Nicolò Boschi d7ff44b984 chore(integrations): apply CI ruff formatting to haystack + roo-code tests (#2082)
Clears the verify-generated-files drift: CI lints all integrations
(LINT_ALL_INTEGRATIONS) and reformats these recently-added test files,
which were committed without CI-mode formatting and so showed as drift
on every PR.
2026-06-09 16:19:12 +02:00
Nicolò Boschi 3069bb41af docs: changelog and blog post for v0.8.1 (#2080)
* docs: changelog and blog post for v0.8.1

* docs(blog): drop integrations section; add hs-release skill

* docs(hs-release): make changelog worktree a fallback, not a required step

* docs(blog): fix broken 0.8.0 cross-link (date-based blog URL)
2026-06-09 15:33:16 +02:00
Nicolò Boschi 4dc149a1ac Release v0.8.1
- Update version to 0.8.1 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.8
2026-06-09 14:55:08 +02:00
Nicolò Boschi 1296e9fc12 feat(api): config flag to skip storing raw document text (#2061) (#2062)
* feat(api): add HINDSIGHT_API_STORE_DOCUMENT_TEXT flag to skip raw text storage

When set to false, the retain pipeline runs unchanged (chunking, fact
extraction, embedding, entity linking) but drops the raw source text:
documents.original_text is stored as NULL and chunks.chunk_text as empty.
content_hash is still computed from the real text so delta-retain dedup is
unaffected, and recall is unaffected because it reads from memory_units.

Closes #2061

* feat(api): reject append + drop source-text reads in privacy mode

Follow-up to the HINDSIGHT_API_STORE_DOCUMENT_TEXT flag, covering the
features that read raw document/chunk text back:

- retain update_mode='append' is now rejected when text storage is
  disabled (it rebuilds the document from the stored original_text, which
  is NULL, and would silently drop prior content).
- reflect no longer offers the 'expand' tool (get chunk/document source),
  gated via get_reflect_tools(include_expand=...); the hallucination guards
  no longer hardcode 'expand' as always-allowed.
- reflect's recall step no longer attaches empty source chunks.

Other read sites (get-document/list-chunks/get-chunk endpoints + MCP,
export/import, public recall include_chunks) already degrade gracefully to
empty/None and are documented.

* fix(api): get-document 200 with null text + 400 on append in privacy mode

Caught while testing the flag live against a running server:

- DocumentResponse.original_text was a non-optional str, so the GET
  document endpoint raised ResponseValidationError -> HTTP 500 when the
  text is NULL. Made it str | None.
- The retain handler mapped all exceptions (including the append-rejection
  and duplicate-document_id ValueErrors) to HTTP 500. Map ValueError to 400,
  matching the convention used by the other endpoints.

Adds an HTTP-level regression test (the engine-level test passed because
get_document returns a dict, bypassing response-model validation).

* feat(ui): warn when document text storage is disabled

Surface the store_document_text flag so the control plane can warn users
that raw source text isn't persisted:

- /version feature flags now include store_document_text (regenerated
  OpenAPI spec + SDK clients; also picks up the earlier DocumentResponse
  original_text optional change).
- features-context exposes it (defaults true, so the warning only shows
  when the server explicitly reports privacy mode).
- Document detail dialog: the Content tab shows a small notice instead of
  an empty body when text isn't stored.
- Add Document dialog: a small notice that raw text won't be kept.
- i18n strings added across all locales.

Adds an API test asserting /version reports the flag.

* refactor: drop "privacy mode" wording; reposition document-text warnings

- Remove the "privacy mode" phrasing I had introduced from comments,
  docstrings, test names, docs, and UI labels. The flag is described by
  what it does (skip storing raw document text) instead.
- Add Document dialog: move the warning to just above the action buttons.
- Document dialog: also show the warning on the Chunks tab.

* fix(cli): handle optional document original_text

original_text is now Option<String> in the generated client (it can be
null when document text storage is disabled), so the CLI can't print it
with {} directly. Show "(not stored)" when absent.
2026-06-09 14:46:09 +02:00
Nicolò Boschi 109e1bd955 fix(api): stop forcing vchordrq.probes session GUC on listless vchord indexes (#2076)
Hindsight set a session-level vchordrq.probes override (10/30) for the
vchord backend, but VectorChord requires the probes value to match each
index's build.internal.lists hierarchy. Hindsight's built-in vchordrq
index clause does not set lists, so it is listless and expects 0 probes;
the session GUC supplies 1, and every query on that pooled connection
fails with "need 0 probes, but 1 probes provided".

On vchord deployments this rejects retain completions after extraction
succeeds, so the worker retries forever and the queue fills with stuck
retain ops that block consolidation.

Drop the vchord entries from the ANN tuning dispatcher so no session
probe override is applied; deployments that partition vchordrq indexes
should attach probes via index storage fallback parameters (VectorChord
1.1) instead. pgvector hnsw.ef_search tuning is unchanged.

Refs #1667.
2026-06-09 13:54:36 +02:00
Nicolò Boschi c0f0c3a769 fix(control-plane): drop locale slug from URLs (localePrefix never) (#2075)
Bank selection was lost on refresh for non-default locales because the
locale prefix (e.g. /es/banks/x) defeated path parsing in bank-context.
Switch next-intl to localePrefix "never" so the locale is resolved from
the NEXT_LOCALE cookie and never appears in the URL. Paths stay clean
(/banks/x) for every language, so the existing ^/banks/ parsing works.

Also removes the now-dead stripLocalePrefix() helper in middleware.

Supersedes #2070.
2026-06-09 13:29:20 +02:00
Nicolò Boschi 6ba4aeaf03 chore: format test files with ruff (enable formatter on tests/) (#2074)
Tests were excluded from both ruff lint and format via the top-level
[tool.ruff].exclude in hindsight-api-slim, hindsight-embed and the shared
ruff.toml. As a result test files drifted from the formatter's style and
every PR that touched a test (or ran format-on-save) carried large
formatting-only churn.

Move the tests exclude into [tool.ruff.lint].exclude (and [lint].exclude in
ruff.toml) so the formatter now covers tests while lint rules — too noisy for
test code (unused imports/vars, import ordering) — stay excluded. Then run
ruff format across all test directories.

Note: lint.exclude is a post-traversal path filter, so it needs the glob form
'tests/**' rather than the directory form 'tests/' used by top-level exclude.
2026-06-09 13:23:11 +02:00
Ben 9ea1ef164a release(obsidian): v0.1.0 2026-06-08 16:45:22 -04:00
Ben b0f86f9c0d feat(obsidian): Hindsight plugin for Obsidian (#1941)
* feat(obsidian): add Obsidian plugin integration

Sync an Obsidian vault into a shared Hindsight bank and chat with an agent
grounded on your notes (citations link back to the source note). Obsidian
stays the source of truth: one-way sync, conversation memory off by default.

- TS plugin (esbuild → main.js): requestUrl HTTP client, incremental sync
  engine (hash/mtime gate, upsert/delete/rename, reconcile + orphan prune),
  reflect-backed chat view with citations + reasoning, settings + commands.
- One shared bank ("obsidian") across vaults; implicit scoping via auto tags
  (vault:, folder: ancestors, created:/updated: date buckets) so recall can
  scope by any combo from the UI or an automation. document_id is
  vault-prefixed to avoid cross-vault collisions.
- Tests (vitest, mocked obsidian module): sync upsert/delete/rename/hash-gate,
  auto-scope tags, client request shapes, and the §0.5 guard (no conversation
  retain when the toggle is off).
- Wiring: test-obsidian-integration CI job + aggregate gate, VALID_INTEGRATIONS,
  changelog generator, integrations.json + docs page + changelog page + icon.

Out of scope for v1: rename-proof frontmatter identity; BRAT/community-store
release-asset attachment (release-integration.yml only npm-publishes today).

* feat(obsidian): scoped chat filters, retrieved-notes, debug logging, branding

- Chat scope filters (vault + folder dropdowns above the ask bar) build
  tag_groups (all_strict) passed to reflect; folder tags are hierarchical.
- "Notes retrieved" list + per-step reasoning: reflect's based_on omits
  document_ids, so harvest them from the recall/expand tool outputs (incl.
  nested observation source_facts). New reflect-util with a unit test.
- Debug logging toggle: logs the reflect request (with scope) and the
  retrieved note ids to the console for verifying filters.
- "New chat" view action + command to reset the conversation.
- Branding: real Hindsight logo (favicon) embedded as a data URI for the
  ribbon, chat header, empty state, and tab icon (via an SVG <image>).

* feat(obsidian): chat output extras — copy, snippet previews, wikilink resolution

- "Copy" action under each answer.
- "Notes retrieved" now shows the matched text snippet per note (from the
  recall/expand tool outputs + observation source_facts), so you can see why a
  note was pulled without opening it. New retrievedNotesDetailed() + test.
- Answers render with the active note as sourcePath, so [[wikilinks]] resolve.

* feat(obsidian): auto-grow chat composer + frontmatter/client edge tests

The composer textarea now grows with multi-line input up to a 240px cap,
then scrolls. Adds unit coverage for the two previously untested pure
layers: frontmatter.normalizeNote (no/blocklist/inline-flow frontmatter,
created/date precedence, scalar metadata, unterminated block) and client
edge paths (transport rejection, reflect tag_groups-vs-tags branch, retain
tag omission).

* ci(obsidian): attach BRAT install assets to the GitHub release

Obsidian plugins install from GitHub release assets (main.js, manifest.json,
styles.css), not npm. The release-integration workflow only npm-published
the package, leaving the plugin uninstallable. Add an obsidian-only step
that creates/updates the release for the tag and uploads the three files
(idempotent on re-run), and grant the job contents:write.

* chore(obsidian): fix generated-files drift (prettier + docs-skill mirror)

Run prettier over the integration (README.md table/emphasis formatting and
the new frontmatter.spec.ts array wrapping) and regenerate the agent-skill
changelog mirror that generate-docs-skill.sh produces. Resolves the
verify-generated-files CI check.

* feat(obsidian): persistent sync-status indicator in the status bar

Background, edit-triggered sync previously ran silently — only the manual
'Sync vault now' surfaced a Notice. Add an always-visible status-bar item
that shows synced/syncing/error state plus a live 'last synced x ago' time,
notes the pending-edit count, and triggers a sync on click. All sync paths
(reconcile, debounced flush, single-note ingest, delete, rename) route
through it. Pure label/tooltip logic is unit-tested (9 cases).

* feat(obsidian): mirror sync status in the chat header

Surface the same sync state in the chat panel's header (right-aligned),
reusing renderSyncStatus with no brand prefix since the Hindsight wordmark
is already shown. The plugin pushes updates to any open chat view whenever
sync state changes, and clicking the pill triggers a sync.

* feat(obsidian): show note count + pending in the sync indicator

Replace the bare check mark with the tracked-note count and either the
pending-edit count or the last-sync time (e.g. '✓ 412 notes · 2m ago',
'✓ 412 notes · 3 pending'). Tooltip carries the full breakdown. Count comes
from the local sync index; singular/plural handled.

* feat(obsidian): explicit refresh button for sync (spins while syncing)

The sync status was clickable text with no obvious affordance. Split it into
an informational status label plus a dedicated refresh icon button (in both
the chat header and the status bar) that triggers a sync on click and spins
while a sync is in flight.

* docs(obsidian): document the sync-status indicator in the README
2026-06-08 16:43:06 -04:00
Ben 9ca6617813 release(omo): v0.1.0 2026-06-08 16:22:55 -04:00
Derek Bouius 6dc56498ce feat(integrations): add oh-my-openagent (OMO) integration (#2018)
* feat(integrations): add oh-my-openagent (OMO) integration

Cloud-first Hindsight memory integration for the OMO agent harness.
Provides automatic recall/retain via lifecycle hooks with support
for both Hindsight Cloud (api.hindsight.vectorize.io) and self-hosted.

- 5 lifecycle hooks: SessionStart, UserPromptSubmit, Stop, SubagentStop, SessionEnd
- Always-apply rule for memory guidance
- Config hierarchy: settings.json → ~/.hindsight/omo.json → HINDSIGHT_* env vars
- Bearer token auth for cloud mode (hsk_* keys)
- Interactive demo script for local dev testing
- Full test suite (29 tests)

* chore(ci): add OMO integration test job

- Add test-omo-integration job to test.yml (pip + pytest pattern)
- Add detect-changes output and path filter for omo

* fix: apply lint formatting to OMO integration files

* fix: fix demo importlib.util import and add mkdir to setup instructions

- Import importlib.util explicitly (importlib alone doesn't expose .util)
- Add mkdir -p for ~/.omo/hooks and .omo/rules in README copy instructions
- Default demo API URL to localhost:8888 to match Docker compose port

* docs: rewrite OMO README with cloud-first setup as default

Simplify setup to 4 numbered steps with cloud as the primary path.
Move self-hosted to an optional section. Add testing section.
Clarify that rules are per-project while hooks/scripts are global.

* chore: add omo to VALID_INTEGRATIONS in release script

* fix: address release-blocking issues for OMO integration

- Remove pyproject.toml (causes release workflow to mis-classify omo as
  a Python package and fail uv build). Move pytest config to pytest.ini.
- Add IntegrationMeta entry in generate_changelog.py
- Add integrations.json entry with internal doc link
- Add docs page at docs-integrations/omo.md
- Add omo.svg icon
- Add "version": "0.1.0" to settings.json
2026-06-08 16:21:49 -04:00
Ben 9a9aef6225 release(cline): v0.1.0 2026-06-08 16:11:00 -04:00
Ben 66e58a23af feat(cline): Hindsight memory integration via lifecycle hooks (#1956)
* feat(cline): add Hindsight memory integration via lifecycle hooks (no MCP)

Gives Cline persistent long-term memory without MCP, using its lifecycle
hooks. TaskStart/UserPromptSubmit recall relevant memories and inject them
via contextModification; TaskComplete/TaskCancel retain the task transcript.
Cline hands hooks no transcript, so prompts are accumulated per-task in
local state and retained at task end. Reuses the agent-agnostic core from the
Codex integration (HTTP client, config, bank derivation, state, content
helpers). Includes an install.py, 34 tests, CI job, and release/docs wiring.

* refactor(cline): typed HindsightClineConfig instead of raw dict (review)

Address the code-review should-fix: replace the raw `config` dict (known,
enumerated keys) with a HindsightClineConfig dataclass per SKILL §5. load_config
maps the camelCase settings.json/env keys onto snake_case fields; consumers
read typed attributes. Also tighten type hints flagged in the review:
ensure_bank_mission (client: HindsightClient, debug_fn: Callable[..., None] |
None), _cast_env(typ: type) -> Any, debug_log(... ) -> None, parse_hook_input
(raw: dict[str, Any]), and client _headers/_request dict parameterization.
retain_metadata stays a dict (genuinely user-defined dynamic keys).

* refactor(cline): parameterize retain() metadata dict type
2026-06-08 16:09:46 -04:00
Ben e76021add3 blog: How oh-my-pi Built Persistent Codebase Memory on Hindsight (#2017)
* blog: How oh-my-pi Built Persistent Codebase Memory on Hindsight

Adoption case-study post on oh-my-pi (10k-star terminal coding agent
by @can1357) using Hindsight as its memory backend. All technical
details and code snippets pulled verbatim from the public repo at
github.com/can1357/oh-my-pi.

Covers: their three-mode bank-scoping policy (global / per-project /
per-project-tagged with the default being tag-based with `any` match);
the mental-model seed file (user-preferences, project-conventions,
project-decisions, each with delta-mode refresh_after_consolidation);
the debounced retain queue (16-item batch / 5s interval) and the
full-session auto-retain path; the auto-recall pipeline with the
exact preamble they use; and the reason they replaced
@vectorize-io/hindsight-client with a minimal fetch client.

Closes by tying the pattern back to other Hindsight-backed coding
agents (Hermes, Claude Code, OpenClaw) — same shape, different
implementations.

Cover image is a placeholder reusing the Hermes coding-assistant
card; final Hindsight x oh-my-pi art is a follow-up.

* blog(oh-my-pi): drop irrelevant Python-client aside

* blog(oh-my-pi): swap placeholder for omp + Hindsight branded cover

* blog(oh-my-pi): add Can Bölük (can1357) as co-author

* blog(oh-my-pi): apply final-revised draft

* blog(oh-my-pi): swap cover for retain/recall/reflect cycle diagram


* blog(oh-my-pi): bump date to 2026-06-08
2026-06-08 15:24:09 -04:00
Ben ccf0dc8268 release(haystack): v0.1.0 2026-06-08 14:54:36 -04:00
394d66e607 feat(integrations): add Haystack integration (#1256)
* feat(integrations): add Haystack integration for persistent agent memory

Add hindsight-haystack package providing Haystack Tool instances backed
by Hindsight's retain/recall/reflect APIs. Uses async client methods with
event-loop-safe sync wrapper to work correctly inside Haystack's agent
runtime.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(haystack): use persistent event loop for async client calls

aiohttp binds its session to the creating event loop, so asyncio.run()
(which creates/destroys a loop per call) breaks on sequential calls.
Switch to a persistent daemon-thread event loop with
run_coroutine_threadsafe. Also removes unused per-operation timeout
constants and adds _run_sync tests.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(haystack): add HindsightToolset with auto-recall/retain, fix review issues

- Add HindsightToolset(Toolset) with auto_recall and auto_retain flags
  that automatically inject recalled memories into the system prompt
  before each turn and retain user/assistant messages after each turn
- Fix _ensure_bank to retry on transient errors instead of permanently
  disabling bank creation
- Fix reflect_on_memory to return structured_output JSON when
  response_schema is set
- Truncate error messages to avoid dumping raw HTTP responses to agents
- Extract _build_backend_kwargs() and _build_tools() as shared helpers
- Add 20 new tests (60 -> 80 total) covering toolset, auto-recall,
  auto-retain, structured output, and bank creation retry

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(haystack): address review round 2 — max_recall_results, role metadata, _run_sync cleanup

- Add max_recall_results param to HindsightToolset (default 10) to cap
  auto-recall prompt injection size, matching Pydantic AI pattern
- Auto-retain now includes role + source metadata on messages, matching
  LlamaIndex's metadata pattern for distinguishable conversation turns
- _recall_for_prompt now calls the API directly with result cap instead
  of going through the formatted string from recall_memory
- Serialize/deserialize max_recall_results in to_dict/from_dict
- Add tests for max_recall_results and role metadata (82 total)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(haystack): default to Cloud without configure(); add gated E2E + bucketing

- resolve_client() now falls back to DEFAULT_HINDSIGHT_API_URL and the
  HINDSIGHT_API_KEY env var when configure() was never called (it previously
  raised "No Hindsight API URL configured"). Updated the unit test to assert
  the cloud-default + env-key behavior. Satisfies the "default to Cloud" goal.
- Add a gated tests/test_e2e.py (retain/recall/reflect tools against a live
  Hindsight server), marked requires_real_llm; register the marker in
  pyproject; the test-haystack-integration CI job now runs the deterministic
  bucket (-m "not requires_real_llm").

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(haystack): close owned clients at exit; run E2E client I/O on the bridge loop

The tools run async client calls on a persistent background event loop. aiohttp
sessions bound to that loop were never closed, surfacing as "Unclosed client
session/connector" warnings. Track module-owned Hindsight clients (those created
when the caller didn't pass client=) and close them on the loop via an atexit
hook, then stop the loop. The live E2E now performs all client I/O through that
same loop (acreate_bank/adelete_bank/aclose via _run_sync) and logs cleanup
failures instead of swallowing them — zero unclosed-connector warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore(haystack): sync openapi.json with main

Branch carried an older snapshot of hindsight-docs/static/openapi.json
that pre-dated main's addition of OperationProgress. Re-checkout main's
openapi.json so check-openapi-compatibility passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(haystack): strip api_key from to_dict() so it doesn't leak to YAML

_build_backend_kwargs was emitting the api_key in the serializable dict
that to_dict() returns. Haystack pipelines get dumped to YAML for
inspection, checkpointing, and sharing — a serialized key leaks into
every dump. Reviewer (benfrank241) flagged this on #1256.

Drop the api_key from the serialized backend_kwargs. resolve_client()
already reads HINDSIGHT_API_KEY from the env var as a final fallback,
so a redeployed pipeline picks the key back up from the host's
environment rather than from the YAML.

The test_tools_round_trip_serialization_with_client test previously
asserted the leak — flipped it to assert the key is NOT present
and added a json.dumps probe asserting the literal key value also
doesn't appear under any other field name. Pre-fix, the test fails:
  AssertionError: api_key must not appear in serialized backend_kwargs
   — would leak to YAML pipeline dumps
  assert 'api_key' not in {'api_key': 'client-key', ...}

86/86 tests pass post-fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* ci: re-trigger CI

A previous push to this branch silently did not trigger a pull_request
event in GitHub Actions, leaving the PR without a CI run for the latest
HEAD. Push an empty commit to force a new event.

* ci: empty commit to attach pull_request CI check to the PR head

(Previous pushes did not auto-trigger pull_request workflow events for
reasons internal to GitHub Actions; manual workflow_dispatch runs passed
green but their checks don't roll up onto the PR. Re-poking the head
to surface the green state on the PR.)

* ci: trailing newline to force CI retrigger

* fix(haystack): register in changelog/gallery + docs page + tidy tools

Review follow-ups for the Haystack integration:

1. Add haystack to the INTEGRATIONS map in generate_changelog.py so the
   release script's changelog step resolves the slug (was missing, which
   would fail the release).
2. Add the integrations.json gallery entry, a doc page at
   docs-integrations/haystack.md, and an icon — required by
   check-integrations.mjs (forward: entry needs a doc page; reverse: a
   released integration must appear in the gallery).
3. Drop the inaccurate 'Raises: HindsightError' clause from
   create_hindsight_tools — resolution always succeeds (URL defaults to
   Cloud) so it never raises; the error type stays exported as the
   conventional public catch type.
4. Replace the _TOOL_DEFS dict-of-3-tuples with a frozen _ToolDef dataclass
   and drop the redundant method-name field (it equalled the dict key).

* fix(haystack): use official Haystack logo for gallery icon

Replace the placeholder glyph with the real deepset Haystack mark (teal
#0EAF9C rounded square + white symbol), extracted as vector from deepset's
own website source (deepset-ai/haystack-home site-logo partial).

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: DK09876 <[email protected]>
2026-06-08 14:52:38 -04:00
Ben 9891f53177 fix(docs): correct Grok Build icon path in integrations banner (#2063)
The rotating integrations banner referenced /img/icons/grok-build.png,
but the asset is grok-build.svg (the gallery already uses the .svg). The
missing .png rendered as a broken-image placeholder in the marquee. Point
the banner at the existing .svg.
2026-06-08 14:46:49 -04:00
2420 changed files with 314419 additions and 29758 deletions
+6
View File
@@ -1,6 +1,7 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "hindsight",
"version": "0.7.5",
"description": "Official Hindsight integrations for Claude Code",
"owner": {
"name": "vectorize-io"
@@ -10,6 +11,11 @@
"name": "hindsight-memory",
"description": "Automatic long-term memory for Claude Code via Hindsight",
"source": "./hindsight-integrations/claude-code"
},
{
"name": "hindsight-zcode",
"description": "No-MCP long-term memory for ZCode via Hindsight hooks",
"source": "./hindsight-integrations/zcode"
}
]
}
+37
View File
@@ -78,6 +78,11 @@ results = await asyncio.gather(*tasks, return_exceptions=True)
- **Authentication/tenancy is enforced inside each engine method, not assumed by the handler.** Every engine method that touches bank-scoped data must authenticate via `request_context` — typically `await self._authenticate_tenant(request_context)` (often indirectly through `get_bank_profile(...)`) — so the correct tenant schema is resolved before any query runs. Handlers must thread `request_context` through to the engine method; never query a tenant-scoped table assuming the schema is already set.
- Engine methods return typed models (Pydantic/dataclass), not raw dicts (see Type Safety).
### 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.
- Design the concurrency out instead of locking around it: give each process its own object to write (e.g. per-schema DDL rather than a shared `public.` object), make the operation idempotent, or use a real row/table constraint (`INSERT ... ON CONFLICT`, `SELECT ... FOR UPDATE` in a fixed order). See #2690 for a migration that reached for `pg_advisory_xact_lock` and had to be reverted.
### Branch Hygiene
- **Always start new feature branches from `origin/main`** — rebase to ensure a clean base.
- **Only include commits relevant to the PR/branch/feature** — no unrelated changes. If the branch contains commits that don't belong, they must be removed before merging.
@@ -154,6 +159,18 @@ If any files in `hindsight-api-slim/hindsight_api/api/` were changed:
- Were the client SDKs regenerated? (`./scripts/generate-clients.sh`)
- Were the control plane proxy routes updated? (`hindsight-control-plane/src/app/api/`)
### 7a. Check TS/Python wrapper-client parity
Two of the generated SDKs ship a **hand-written, maintained convenience wrapper** on top of the auto-generated low-level client — and *only* these two:
- **TypeScript**: `hindsight-clients/typescript/src/index.ts` (`HindsightClient`)
- **Python**: `hindsight-clients/python/hindsight_client/hindsight_client.py` (`Hindsight`)
(The Rust/Go/etc. clients are generated-only — no wrapper to keep in sync.)
These wrappers are what most third-party consumers actually call, and they must expose the same surface. **If a change touches one wrapper's method — adds/removes a parameter, changes a default, forwards a new query/body field — the equivalent method in the *other* wrapper must get the same change in the same (or an immediately-following) PR.** A parameter that exists in the generated SDK but is dropped by one wrapper silently strips it for every consumer of that language (this is exactly what #2975 / #3042 fixed for `detail`/`tags_match`/`limit`/`offset` on `listMentalModels`/`getMentalModel`). **Should fix** — flag any wrapper method that gains capabilities in one language but not the other, and add a matching mapping regression test on both sides.
Note: the `client-coverage-check` CI tool only validates **request-body** fields, not GET **query** parameters — so query-param parity gaps are *not* caught automatically and must be checked by hand here.
### 7b. Check API-layer data-access boundary
For each changed handler in `hindsight-api-slim/hindsight_api/api/` (e.g. `http.py`, `mcp.py`):
@@ -192,6 +209,26 @@ If a migration adds a new PostgreSQL table (look for `CREATE TABLE` / `op.create
- The guard test `test_backup_tables_covers_entire_schema` in `tests/test_admin_backup_restore.py` enforces this — flag it as a **must fix** if a new table is absent from `BACKUP_TABLES`.
- Oracle-only tables (e.g. `observation_sources`) are intentionally excluded — admin backup/restore is PostgreSQL-only.
### 11b. Check new config flags update the env template
If the diff adds a new configuration field (a new `ENV_*` / `HINDSIGHT_*` env var
in `hindsight-api-slim/hindsight_api/config.py`):
- **`.env.example`** (repo root) — must add the variable (commented if optional)
alongside the docs entry in `hindsight-docs/docs/developer/configuration.md`.
A flag added to `config.py` but absent from `.env.example` is a **should fix**.
- **`hindsight-embed/hindsight_embed/env.example`** — the bundled copy must stay
byte-identical to the repo-root `.env.example` (it seeds embed/profile configs).
The `test_bundled_template_matches_repo_root` sync test fails on drift; if the
root file changed without re-copying, flag it as a **must fix**.
### 11c. Check for advisory locks
Grep the diff for `advisory` (`git diff main...HEAD | grep -in advisory`). Any new
`pg_advisory_lock` / `pg_try_advisory_lock` / `pg_advisory_xact_lock` /
`pg_advisory_unlock` call is a **must fix** — see Database Locking above. Point the
author at the alternatives (per-process objects, idempotent DDL, row-level
constraints) rather than just asking them to drop the lock.
### 12. Review against other coding standards
Check the diff for violations of the standards listed above:
+116
View File
@@ -0,0 +1,116 @@
---
name: hs-release
description: Cut a core Hindsight release (vX.Y.Z) and open the changelog + blog PR. Use when asked to cut/start a release, bump the version, or publish a new Hindsight version.
user_invocable: true
---
# Hindsight Release
Cut a **core** Hindsight release and open the accompanying changelog/blog PR. This is for the core
product version (API, clients, CLI, control plane, Helm). **Integrations are versioned
independently** — use `scripts/release-integration.sh` for those, not this skill.
The release is **irreversible and outward-facing**: it tags a version and pushes it straight to
`main`, which triggers CI that publishes packages to PyPI / npm / Helm. Confirm the version number
and that the intended fixes are already merged to `main` before you start.
## Step 0 — Pre-flight
1. **Decide the base.** A release is cut from the latest `origin/main`, never from a feature
branch. `git fetch origin --tags` first. Confirm the "couple of fixes" the user means are
actually merged to `main` (`git log v<prev>..origin/main --oneline`).
2. **Find where `main` is checked out.** `main` is often already checked out in a sibling worktree
(`git worktree list`). You **cannot** check out `main` in a second worktree — run the release in
the worktree that already holds it. If that worktree is dirty with throwaway cruft
(`.next-*` tsconfig paths, screenshots), `git stash push -u`, fast-forward to `origin/main`,
run the release, then `git stash pop`.
3. **Pitfall:** never pipe the checkout in an `&&` chain like
`git checkout main 2>&1 | tail && git reset --hard ...` — the pipe's exit status is `tail`'s
(always 0), so a failed checkout won't stop the chain and the `reset` fires on the **wrong
branch**. Check out as its own command and verify `git branch --show-current` before resetting.
## Step 1 — Cut the release
Run from the worktree on a clean `main`:
```bash
./scripts/release.sh <version> # e.g. 0.8.1 (no leading v)
```
`release.sh` bumps the version in every component, regenerates the OpenAPI spec + all client SDKs,
updates docs versioning, commits `Release v<version>`, tags `v<version>`, and **pushes the commit
and tag directly to `main`**. The push triggers the `Release` GitHub Actions workflow that builds
and publishes the packages. It is **not** a PR.
Verify after: `gh run list --limit 5` should show the `Release v<version>` workflow running, and
`git ls-remote --tags origin v<version>` should return the tag.
## Step 2 — Changelog + blog PR (separate)
Done **after** the tag exists, as its own PR (precedent: v0.8.0 = #2053, v0.8.1 = #2080). Work on a
branch off the new `main`:
```bash
git checkout -b docs-changelog-<version> origin/main
```
Only spin up a separate worktree (`git worktree add ../hindsight-changelog-<version> -b
docs-changelog-<version> origin/main`) if you can't get a clean checkout otherwise — e.g. `main` is
held in another worktree and the current one has work you don't want to disturb.
**Branch naming:** use the `docs-` (hyphen) convention, e.g. `docs-changelog-0.8.1`. A remote
branch literally named `docs` exists, so any `docs/...` branch is rejected on push with
`directory file conflict`.
### Changelog
```bash
uv run --directory hindsight-dev generate-changelog <version>
```
LLM-summarizes the commits between the previous tag and `v<version>` and prepends an entry to
`hindsight-docs/src/pages/changelog/index.md`. Requires `OPENAI_API_KEY` (already in the repo
`.env`). It excludes `hindsight-integrations/` source, but new integrations whose commits also
touched docs will still appear — that matches precedent, leave them in the **changelog**.
### Blog post
Hand-write `hindsight-docs/blog/YYYY-MM-DD-version-X-Y-Z.md` (mirror an existing one; patch
releases are short — see `2026-06-02-version-0-7-2.md`). Guidance:
- **Explain user impact, not internals/mechanism.** Lead with what the user can now do and what to
set. Config/env-var names are fine (developer-facing), code symbols and internals are not.
- **Do not list integrations in the release blog.** The core blog covers core engine / API /
ops changes; each integration ships its own changelog. (Integrations may still appear in the
generated `changelog/index.md` — that's fine; just keep them out of the blog.)
- Call out an upgrade recommendation when there are operational/data-integrity fixes.
- Validate formatting: `npx prettier --check <blog file>`.
### Sync the docs skill
```bash
./scripts/generate-docs-skill.sh
```
Refreshes `skills/hindsight-docs/references/changelog/index.md`. It will also bump
`skills/hindsight-docs/references/openapi.json` by one version — `release.sh` regenerates the skill
*before* bumping OpenAPI, so the skill copy lags a version in the release commit; this step syncs
it. Expect a one-line `version` diff there; keep it.
### Commit, push, PR
```bash
git add -A
git commit --no-verify -m "docs: changelog and blog post for v<version>"
git push -u origin docs-changelog-<version>
gh pr create --base main --title "docs: changelog and blog post for v<version>" --body "..."
```
Expected files in the PR: the changelog entry, the new blog post, the regenerated skill changelog
mirror, and the skill `openapi.json` version sync.
## Cleanup
If you created a temporary worktree, remove it once the PR is up
(`git worktree remove ../hindsight-changelog-<version>`; the branch stays on origin). Restore any
stash you popped in Step 0.
+212 -1
View File
@@ -2,7 +2,7 @@
# Copy this file to .env and fill in your values
# LLM Configuration (Required)
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai, minimax, deepseek, zai, volcano
# Supported providers: openai, openai-responses, groq, ollama, gemini, anthropic, lmstudio, vertexai, minimax, deepseek, zai, atlas, volcano
HINDSIGHT_API_LLM_PROVIDER=openai
HINDSIGHT_API_LLM_API_KEY=your-api-key-here
HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
@@ -10,6 +10,55 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# Reasoning effort for providers/models that support it. Examples: low, medium, high, xhigh.
# HINDSIGHT_API_LLM_REASONING_EFFORT=low
# Sampling temperature for internal LLM calls. Set a number in [0.0, 2.0], or `none`
# to omit the temperature parameter entirely (required for models that reject explicit
# temperatures, e.g. Azure gpt-5.5). The global override below applies to every operation;
# per-operation overrides (defaults: verification=0.0, retain=0.1, reflect=0.9,
# consolidation=0.0) take precedence.
# HINDSIGHT_API_LLM_TEMPERATURE=none
# HINDSIGHT_API_LLM_TEMPERATURE_VERIFICATION=0.0
# HINDSIGHT_API_LLM_TEMPERATURE_RETAIN=0.1
# HINDSIGHT_API_LLM_TEMPERATURE_REFLECT=0.9
# HINDSIGHT_API_LLM_TEMPERATURE_CONSOLIDATION=0.0
# Grammar-enforce structured output (json_schema strict) instead of the soft
# schema-in-prompt path. Helps weaker self-hosted models that emit prose preambles
# or invalid JSON. The global override below applies to every operation;
# per-operation overrides take precedence, in both directions -- set one to false
# to opt that operation out while the global flag is on.
# HINDSIGHT_API_LLM_STRICT_SCHEMA=false
# HINDSIGHT_API_LLM_STRICT_SCHEMA_RETAIN=true
# HINDSIGHT_API_LLM_STRICT_SCHEMA_REFLECT=true
# HINDSIGHT_API_LLM_STRICT_SCHEMA_CONSOLIDATION=true
# Some backends, including Bedrock Converse, reject JSON Schema maxItems.
# Disable it only for those backends; consolidation still enforces the cap.
# HINDSIGHT_API_LLM_SUPPORTS_MAX_ITEMS=true
# Pin a conversation to one backend prompt cache (OpenAI-compatible providers only).
# Server-side prompt caches are per backend server, so the same conversation has to
# reach the same one to hit. Values: auto (default), xai_conv_id (sends xAI's
# x-grok-conv-id header), openai_prompt_cache_key (sends OpenAI's prompt_cache_key
# field), none (sends nothing). "auto" picks from the base URL host and is an
# allowlist: x.ai / grok.com get the header, native OpenAI / openai.com / Azure
# OpenAI get the field, and every other backend gets nothing. Per-operation
# overrides take precedence. Set to none to disable entirely.
# HINDSIGHT_API_LLM_CACHE_AFFINITY=auto
# HINDSIGHT_API_RETAIN_LLM_CACHE_AFFINITY=none
# HINDSIGHT_API_REFLECT_LLM_CACHE_AFFINITY=xai_conv_id
# HINDSIGHT_API_CONSOLIDATION_LLM_CACHE_AFFINITY=none
# Ask litellm/litellmrouter/bedrock for structured output via a forced tool call
# instead of response_format. Enable it for backends that reject response_format
# outright -- e.g. Bedrock Claude in ap-southeast-2 ("Extra inputs are not permitted");
# the same model in us-east-1 accepts response_format and needs nothing here.
# HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL=false
# Diagnostic: on any LLM 4xx, log the exact assembled request ([LLM_4XX_DUMP]) --
# serialized request config (message bodies stripped) + capped per-message previews.
# For debugging otherwise-unreproducible rejected calls. Off by default.
# HINDSIGHT_API_LLM_DEBUG_DUMP_4XX=false
# Example: Anthropic Claude configuration
# HINDSIGHT_API_LLM_PROVIDER=anthropic
# HINDSIGHT_API_LLM_API_KEY=your-anthropic-api-key
@@ -27,6 +76,12 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# HINDSIGHT_API_LLM_API_KEY=your-minimax-api-key
# HINDSIGHT_API_LLM_MODEL=MiniMax-M3 # or MiniMax-M2.7 for the previous generation
# Example: OpenAI Responses API (/v1/responses) — reasoning + function tools together
# HINDSIGHT_API_LLM_PROVIDER=openai-responses
# HINDSIGHT_API_LLM_API_KEY=your-openai-api-key
# HINDSIGHT_API_LLM_MODEL=gpt-5.6 # reasoning model (gpt-5.x / o-series); e.g. gpt-5.6-terra
# HINDSIGHT_API_LLM_REASONING_EFFORT=high # sent alongside tools, unlike chat/completions
# Example: DeepSeek configuration (https://api.deepseek.com)
# HINDSIGHT_API_LLM_PROVIDER=deepseek
# HINDSIGHT_API_LLM_API_KEY=your-deepseek-api-key
@@ -37,16 +92,60 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# HINDSIGHT_API_LLM_API_KEY=your-zai-api-key
# HINDSIGHT_API_LLM_MODEL=glm-4.5-flash # or glm-4.5-air for the paid tier
# Example: Atlas Cloud configuration (OpenAI-compatible, https://www.atlascloud.ai)
# HINDSIGHT_API_LLM_PROVIDER=atlas
# HINDSIGHT_API_LLM_API_KEY=your-atlascloud-api-key
# HINDSIGHT_API_LLM_MODEL=deepseek-ai/deepseek-v4-pro # reasoning model; also Qwen / GLM / Kimi / MiniMax, etc.
# Example: LM Studio local configuration (Qwen 2.5 32B recommended)
# HINDSIGHT_API_LLM_PROVIDER=lmstudio
# HINDSIGHT_API_LLM_API_KEY=lmstudio
# HINDSIGHT_API_LLM_BASE_URL=http://localhost:1234/v1
# HINDSIGHT_API_LLM_MODEL=qwen2.5-32b-instruct
# Example: Ollama local configuration (native provider)
# HINDSIGHT_API_LLM_PROVIDER=ollama
# HINDSIGHT_API_LLM_BASE_URL=http://localhost:11434/v1
# HINDSIGHT_API_LLM_MODEL=gemma3:12b
# Native Ollama context-window override (num_ctx). Leave unset to let Ollama use
# the model Modelfile / server default; set a positive integer only to force a
# specific context size (e.g. 16384 to keep the previous request behavior).
# HINDSIGHT_API_LLM_OLLAMA_NUM_CTX=16384
# Multi-LLM strategies: configure extra LLMs by index alongside the primary above,
# then pick a routing strategy. Unset = single primary LLM (default). Members are
# numbered from 1; indices must be contiguous. Each operation can override with a
# RETAIN_/REFLECT_/CONSOLIDATION_ prefix (e.g. HINDSIGHT_API_RETAIN_LLM_1_PROVIDER).
# HINDSIGHT_API_LLM_1_PROVIDER=groq
# HINDSIGHT_API_LLM_1_API_KEY=your-groq-api-key
# HINDSIGHT_API_LLM_1_MODEL=openai/gpt-oss-120b
# HINDSIGHT_API_LLM_2_PROVIDER=anthropic
# HINDSIGHT_API_LLM_2_API_KEY=your-anthropic-api-key
# Strategy JSON: {"mode": "failover"} or {"mode": "round-robin"}.
# Round-robin accepts optional positive-int "weights" (one per member, primary first).
# HINDSIGHT_API_LLM_STRATEGY={"mode": "failover"}
# API Configuration (Optional)
HINDSIGHT_API_HOST=0.0.0.0
HINDSIGHT_API_PORT=8888
HINDSIGHT_API_LOG_LEVEL=info
# Optional retain chunking override for structured logs/transcripts.
# Unset uses HINDSIGHT_API_RETAIN_CHUNK_SIZE as the structured-chunk limit.
# HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE=
# When true, a retain operation that hit any fact-extraction errors is marked
# 'failed' (not 'completed'), surfacing silently-dropped facts. Default false.
# HINDSIGHT_API_FAIL_ON_EXTRACTION_ERRORS=false
# Wall-clock ceiling (seconds) for one retain task in the worker. A retain that
# blocks indefinitely is cancelled and marked 'failed' — and so becomes
# retryable — instead of holding its worker slot until the process restarts.
# Set well above your slowest healthy retain; 0 disables. Default 3600.
# HINDSIGHT_API_RETAIN_WALL_TIMEOUT=3600
# Dry-run extraction preview endpoint (POST /memories/dry-run-extract). Enabled by default; it makes
# a real LLM call but stores nothing. Set to false to remove the endpoint (returns 404).
# HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=true
# Base Path / Reverse Proxy Support (Optional)
# Set these when deploying behind a reverse proxy with path-based routing
@@ -59,6 +158,13 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_READ_DATABASE_URL= # Optional read-replica URL. When set, recall queries (semantic, BM25, graph, temporal) flow through a separate pool against this URL, offloading the primary. Typically points to a read-only endpoint (CNPG's <cluster>-ro service or Aurora reader endpoint).
# HINDSIGHT_API_MIGRATION_DATABASE_URL= # Direct PostgreSQL URL for migrations (bypasses PgBouncer). Falls back to DATABASE_URL.
# HINDSIGHT_API_DATABASE_SCHEMA=public # PostgreSQL schema name (default: public)
# HINDSIGHT_API_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_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.
# Vector Extension (Optional - uses pgvector by default)
# Options: "pgvector" (default), "vchord", "pgvectorscale" (DiskANN)
@@ -78,12 +184,36 @@ HINDSIGHT_API_LOG_LEVEL=info
# chinese_lindera/lindera(chinese), japanese_lindera/lindera(japanese),
# korean_lindera/lindera(korean), ngram(min,max), edge_ngram(min,max)
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER=
# Optional cap on the number of terms in the native PostgreSQL BM25 tsquery.
# Long queries OR-join every normalized token, which can match too much of a
# large bank. 0 (default) keeps the historical uncapped behavior; a positive
# value bounds only the native backend (other BM25 backends get the raw query).
# HINDSIGHT_API_BM25_MAX_QUERY_TERMS=0
# File Parser (Optional - uses markitdown by default)
# HINDSIGHT_API_FILE_PARSER=markitdown
# Enable image OCR for MarkItDown using an OpenAI-compatible OCR/vision endpoint.
# These OCR settings are independent from HINDSIGHT_API_LLM_* because MarkItDown
# uses the OpenAI SDK directly and requires Chat Completions image input support.
# When OCR is enabled, API_KEY, BASE_URL, and MODEL are required.
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED=false
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY=
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL=
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL=
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_PROMPT=
# Optional JSON dict of custom headers for the OCR OpenAI client (e.g. proxies / request tracing).
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_DEFAULT_HEADERS=
# Embeddings Configuration (Optional - uses local by default)
# Provider: "local" (default), "onnx", "tei", "openai", "cohere", "google", "openrouter", "zeroentropy", "litellm", or "litellm-sdk"
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
# For local provider:
# HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5
# Force CPU if local embeddings hit MPS/XPC instability on macOS:
# HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU=false
# Opt in to the Apple Silicon MPS GPU (off by default: MPS leaks memory under
# variable-length workloads). CUDA/XPU still auto-select regardless:
# HINDSIGHT_API_EMBEDDINGS_LOCAL_ALLOW_MPS=false
# For ONNX provider (local CPU embeddings without an Ollama/TEI sidecar):
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=onnx
# HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_ID=intfloat/multilingual-e5-small
@@ -99,6 +229,11 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH=/models/multilingual-e5-small
# Optional for China network / restricted HF access:
# HF_ENDPOINT=https://hf-mirror.com
# Applies to any provider: cap each input at this many tiktoken tokens before
# embedding, so oversized content is truncated instead of failing the embed call
# permanently (e.g. Bedrock Titan V2's 8192, or a llama.cpp server's context). Off
# by default. (Deprecated alias: HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS)
# HINDSIGHT_API_EMBEDDINGS_MAX_INPUT_TOKENS=8192
# For TEI provider:
# HINDSIGHT_API_EMBEDDINGS_TEI_URL=http://localhost:8080
# For OpenAI-compatible embeddings:
@@ -120,13 +255,58 @@ HINDSIGHT_API_LOG_LEVEL=info
# DeepSeek note: DeepSeek is supported for LLM calls, but not for embeddings.
# If using DeepSeek as LLM provider, keep embeddings on local/openai/cohere/google/etc.
# Embedding similarity thresholds. These defaults preserve the behavior calibrated
# for BAAI/bge-small-en-v1.5. Recalibrate each threshold independently when changing
# embedding models because cosine-similarity distributions are model-dependent.
# HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY=0.3
# HINDSIGHT_API_GRAPH_SEED_MIN_SIMILARITY=0.3
# HINDSIGHT_API_TEMPORAL_SEMANTIC_MIN_SIMILARITY=0.1
# HINDSIGHT_API_SEMANTIC_LINK_MIN_SIMILARITY=0.7
# HINDSIGHT_API_CONSOLIDATION_DEDUP_THRESHOLD=0.97
# Recall pipeline stages (all on by default). Each is hierarchical, so a single
# bank can switch a stage off via the config API without changing the server
# default. Turning all three off leaves semantic + BM25 fused by RRF, the
# lowest-latency recall path.
# Temporal retrieval arm, plus the date-aware query analysis that feeds it:
# HINDSIGHT_API_ENABLE_TEMPORAL_RETRIEVAL=true
# Entity/link graph traversal arm:
# HINDSIGHT_API_ENABLE_GRAPH_RETRIEVAL=true
# Cross-encoder rerank of the fused candidates (false = use the RRF order):
# HINDSIGHT_API_ENABLE_RERANKING=true
# Reranker Configuration (Optional - uses local by default)
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
# HINDSIGHT_API_RERANKER_PROVIDER=local
# Trusted gateway attribution (disabled by default). When enabled, remote
# reranker requests include X-Hindsight-Bank-Id with the current bank ID.
# HINDSIGHT_API_RERANKER_SEND_BANK_AS_HEADER=false
# For local provider:
# HINDSIGHT_API_RERANKER_LOCAL_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
# Force CPU if the local reranker hits MPS/XPC instability on macOS:
# HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=false
# Opt in to the Apple Silicon MPS GPU (off by default: MPS leaks memory under
# variable-length workloads). CUDA/XPU still auto-select regardless:
# HINDSIGHT_API_RERANKER_LOCAL_ALLOW_MPS=false
# For TEI provider:
# HINDSIGHT_API_RERANKER_TEI_URL=http://localhost:8081
# Max candidates the cross-encoder reranks per recall (RRF pre-filters the rest):
# HINDSIGHT_API_RERANKER_MAX_CANDIDATES=300
# Optionally scale that cap by the recall budget level (the cross-encoder dominates
# a large recall's latency). 0 = fall back to the flat cap above; fully backwards-compatible.
# HINDSIGHT_API_RERANKER_MAX_CANDIDATES_LOW=0
# HINDSIGHT_API_RERANKER_MAX_CANDIDATES_MID=0
# HINDSIGHT_API_RERANKER_MAX_CANDIDATES_HIGH=0
# Reranker failover chain: extra rerankers tried, in order, when the one above
# fails. Members are numbered from 1 (indices must be contiguous) and every
# setting of member n carries the same index. A member inherits nothing from the
# primary, so spell out everything it needs. Unset = no fallback (default): a
# failing reranker fails the recall. End the chain with "rrf" to fail open and
# keep the retrieval order instead.
# HINDSIGHT_API_RERANKER_1_PROVIDER=cohere
# HINDSIGHT_API_RERANKER_1_COHERE_API_KEY=your-cohere-api-key
# HINDSIGHT_API_RERANKER_2_PROVIDER=rrf
# Observability & Tracing (Optional - disabled by default)
# Enable OpenTelemetry tracing for LLM calls (GenAI semantic conventions)
@@ -142,6 +322,37 @@ HINDSIGHT_API_LOG_LEVEL=info
# Custom service name and environment (optional, defaults: hindsight-api, development)
# HINDSIGHT_API_OTEL_SERVICE_NAME=hindsight-production
# HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=production
#
# Expose async-operation queue + consolidation-backlog gauges on /metrics.
# Runs periodic per-schema COUNT queries on a background task (disabled by default).
# HINDSIGHT_API_METRICS_BACKLOG_ENABLED=true
#
# Runtime-stall observability (enabled by default). When a liveness probe fails,
# these tell you WHY: a blocked event loop vs DB connection-pool exhaustion.
# The loop watchdog logs the offending stack when the loop is unresponsive; the
# DB-pool acquire timing logs (and exposes hindsight.db.pool.waiting) when
# callers queue for a connection. Both are cheap; tune or disable if needed.
# HINDSIGHT_API_LOOP_WATCHDOG_ENABLED=false
# HINDSIGHT_API_LOOP_WATCHDOG_STALL_THRESHOLD_MS=1000
# HINDSIGHT_API_LOOP_WATCHDOG_POLL_INTERVAL_MS=250
# HINDSIGHT_API_DB_ACQUIRE_WARN_THRESHOLD_MS=1000
# -----------------------------------------------------------------------------
# Webhooks (Optional)
# -----------------------------------------------------------------------------
# Outbound webhook delivery targets caller-supplied URLs. To prevent SSRF, the
# delivery worker blocks private, loopback, and link-local destinations
# (including the cloud metadata address 169.254.169.254) by default. List hosts
# or IP/CIDR ranges here (comma-separated) to re-permit specific internal
# destinations — e.g. 127.0.0.1 for local testing, or an internal receiver.
# HINDSIGHT_API_WEBHOOK_ALLOWED_HOSTS=127.0.0.1,internal-receiver.svc,10.0.0.0/8
# Whether the webhook delivery-history API returns the raw upstream response
# body. Off by default: returning arbitrary response bodies to callers is an
# information-exfiltration primitive. The delivery status code is always
# returned regardless. Enable only if you trust your webhook destinations.
# HINDSIGHT_API_WEBHOOK_EXPOSE_RESPONSE_BODY=false
# -----------------------------------------------------------------------------
# Control Plane (Optional)
-6
View File
@@ -1,6 +0,0 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 40 KiB

File diff suppressed because it is too large Load Diff
+77 -3
View File
@@ -9,7 +9,11 @@ jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write # for PyPI trusted publishing
id-token: write # for PyPI trusted publishing + build-provenance attestations
attestations: write # for actions/attest-build-provenance (Obsidian assets)
# No `contents: write`: we never create releases in this repo. The Obsidian
# plugin's distribution release is pushed to its dedicated repo using
# OBSIDIAN_DIST_TOKEN (see the "Mirror Obsidian plugin" step below).
steps:
- uses: actions/checkout@v6
@@ -71,7 +75,7 @@ jobs:
if: steps.type.outputs.type == 'plugin'
run: |
echo "Plugin integration ${{ steps.info.outputs.integration }} v${{ steps.info.outputs.version }} — no package to publish."
echo "Users install via: claude plugin marketplace add vectorize-io/hindsight --sparse hindsight-integrations"
echo "Users install via: claude plugin marketplace add vectorize-io/hindsight"
# ── TypeScript integrations (ai-sdk, chat, openclaw) ────────────────────
@@ -112,6 +116,71 @@ jobs:
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: npm run build
# Build-provenance attestations for the Obsidian release assets (community-store
# recommendation). Runs after the build so main.js exists. The assets are
# released in the dedicated repo while the build runs here, so users verify at
# owner scope: `gh attestation verify main.js --owner vectorize-io`.
- name: Attest Obsidian plugin build provenance
if: steps.type.outputs.type == 'typescript' && steps.info.outputs.integration == 'obsidian'
uses: actions/attest-build-provenance@v2
with:
subject-path: |
hindsight-integrations/obsidian/main.js
hindsight-integrations/obsidian/styles.css
# ── Obsidian plugin — mirror to its dedicated repo + cut the BRAT release ──
# We do NOT create a GitHub Release in this monorepo: per-integration
# releases pollute the repo's release list (it's for the core product) and
# steal the "Latest" badge, and BRAT / the community store read a repo's
# *latest* release — not a tag — so they can't target a tag in a monorepo.
#
# Instead this monorepo stays the source of truth, and on each obsidian
# release we mirror hindsight-integrations/obsidian/ → the *root* of
# github.com/vectorize-io/hindsight-obsidian (git subtree, history
# preserved) and cut the BRAT / community-store release *there*.
#
# Requires secret OBSIDIAN_DIST_TOKEN — a token with `contents: write` on
# vectorize-io/hindsight-obsidian (fine-grained PAT or app installation
# token). The dedicated repo is generated; never edit it directly.
- name: Mirror Obsidian plugin to its dedicated repo
if: steps.type.outputs.type == 'typescript' && steps.info.outputs.integration == 'obsidian'
env:
DIST_TOKEN: ${{ secrets.OBSIDIAN_DIST_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ steps.info.outputs.version }}"
DIST_REPO="vectorize-io/hindsight-obsidian"
OBS_DIR="hindsight-integrations/obsidian"
# `git subtree split` needs full history; the default checkout is shallow.
git fetch --unshallow 2>/dev/null || true
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# The runner injects the default GITHUB_TOKEN as an http.extraheader via
# an *included* config file (/home/runner/work/_temp/git-credentials-*.config),
# so `git config --local --unset-all` can't remove it and it authenticates
# the push as github-actions[bot] (no access to the dedicated repo → 403).
# The documented way to drop an inherited extraheader is to RESET the list
# with an empty value: since command-line `-c` is read last, the empty
# value clears the accumulated headers (including the included one) at
# request-build time. The dist token then comes from the push URL → a
# single Authorization header.
git subtree split --prefix="$OBS_DIR" -b _obs_dist
git -c "http.https://github.com/.extraheader=" \
push "https://x-access-token:${DIST_TOKEN}@github.com/${DIST_REPO}.git" _obs_dist:main
# Cut the BRAT / community-store release. Bare version tag (e.g. 0.1.0)
# to match manifest.json — idempotent so re-runs just refresh the assets.
export GH_TOKEN="$DIST_TOKEN"
ASSETS="$OBS_DIR/main.js $OBS_DIR/manifest.json $OBS_DIR/styles.css"
NOTES="Hindsight for Obsidian v${VERSION}. Install via BRAT (add ${DIST_REPO}) or copy main.js/manifest.json/styles.css into <vault>/.obsidian/plugins/hindsight/."
if gh release view "$VERSION" --repo "$DIST_REPO" >/dev/null 2>&1; then
gh release upload "$VERSION" $ASSETS --repo "$DIST_REPO" --clobber
else
gh release create "$VERSION" $ASSETS --repo "$DIST_REPO" --title "$VERSION" --notes "$NOTES"
fi
- name: Publish TypeScript package to npm
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
@@ -121,7 +190,12 @@ jobs:
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
# Treat "already published" as success so re-pointed-tag re-runs stay green.
# "cannot publish over" = the version exists. TLOG_CREATE_ENTRY_ERROR / 409
# "equivalent entry already exists in the transparency log" = the identical
# --provenance artifact was already logged on a prior run (Sigstore tlog is
# idempotent); the package is published, so this is benign.
if echo "$OUTPUT" | grep -qE "cannot publish over|TLOG_CREATE_ENTRY_ERROR|already exists in the transparency log"; then
echo "Package version already published, skipping..."
exit 0
fi
+34 -2
View File
@@ -25,6 +25,20 @@ jobs:
with:
python-version-file: ".python-version"
# Each package is built from its own directory, so stage the repository's
# canonical license inside each isolated build context.
- name: Stage Python package licenses
run: |
for package in \
hindsight-clients/python \
hindsight-api-slim \
hindsight-api \
hindsight-all \
hindsight-all-slim \
hindsight-embed; do
cp LICENSE "$package/LICENSE"
done
# Build all packages
- name: Build hindsight-client
working-directory: ./hindsight-clients/python
@@ -50,6 +64,24 @@ jobs:
working-directory: ./hindsight-embed
run: uv build --out-dir dist
- name: Verify Python package licenses
run: |
for package in \
hindsight-clients/python \
hindsight-api-slim \
hindsight-api \
hindsight-all \
hindsight-all-slim \
hindsight-embed; do
wheel=$(find "$package/dist" -maxdepth 1 -name '*.whl' -print -quit)
sdist=$(find "$package/dist" -maxdepth 1 -name '*.tar.gz' -print -quit)
unzip -Z1 "$wheel" | grep -Eq '\.dist-info/licenses/LICENSE$'
unzip -p "$wheel" '*/METADATA' | grep -Fxq 'License-Expression: MIT'
unzip -p "$wheel" '*/METADATA' | grep -Fxq 'License-File: LICENSE'
tar -tzf "$sdist" | grep -Eq '/LICENSE$'
done
# Publish in order (client and api-slim first, then api/all wrappers which depend on them)
- name: Publish hindsight-client to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
@@ -266,7 +298,7 @@ jobs:
strategy:
matrix:
include:
- os: ubuntu-latest
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
artifact_name: hindsight
asset_name: hindsight-linux-amd64
@@ -278,7 +310,7 @@ jobs:
target: aarch64-apple-darwin
artifact_name: hindsight
asset_name: hindsight-darwin-arm64
- os: ubuntu-24.04-arm
- os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
artifact_name: hindsight
asset_name: hindsight-linux-arm64
+29
View File
@@ -0,0 +1,29 @@
name: Update star history
on:
schedule:
- cron: '17 3 * * *'
workflow_dispatch:
permissions:
contents: write
jobs:
update:
concurrency:
group: star-history
cancel-in-progress: false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: nicoloboschi/gh-stars@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
line-color: '#14b8a6'
- name: Commit chart
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add .github/star-history/data.json .github/star-history/chart.svg
git diff --cached --quiet || git commit -m 'chore: update star history'
git push
+1062 -22
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -5,7 +5,15 @@ build/
dist/
wheels/
*.egg-info
# Release builds stage the canonical root license in each package context.
/hindsight-clients/python/LICENSE
/hindsight-api-slim/LICENSE
/hindsight-api/LICENSE
/hindsight-all/LICENSE
/hindsight-all-slim/LICENSE
/hindsight-embed/LICENSE
.mcp.json
.playwright-mcp/
.osgrep
# Virtual environments
.venv
@@ -15,6 +23,8 @@ node_modules/
# Environment variables and local config
.env
.env.bak*
.env.*.bak
docker-compose.yml
docker-compose.override.yml
@@ -38,6 +48,7 @@ nltk_data/
logs/
.DS_Store
.sesskey
# Generated docs files
hindsight-docs/static/llms-full.txt
-1
View File
@@ -1 +0,0 @@
fcac2839-1db5-432f-91e1-c5dac07d7290
+59 -5
View File
@@ -216,6 +216,18 @@ migration file dispatches through `run_for_dialect`, which calls either
./scripts/hooks/lint.sh
```
Dead-code detection runs in CI (the `check-unused-code` job) at two levels:
- **Blocking:** unused imports (ruff `F401`) and variables (`F841`) — `lint.sh` auto-removes
them and `verify-generated-files` fails on any leftover diff; and **knip** for orphaned
control-plane files / unused (or unlisted) `package.json` dependencies.
- **Advisory:** whole unused Python functions (vulture) and unused control-plane *exports*
(the shadcn/ui surface is kept on purpose) — surfaced, not gated.
Run both locally with:
```bash
./scripts/hooks/check-unused.sh
```
**After completing any implementation work, run `/code-review`** to verify your changes against project standards (missing tests, dead code, type safety, etc.). Fix any "must fix" issues before considering the task done.
**MANDATORY: Run `/code-review` before pushing code or creating a pull request.** Do not push or create a PR until all "must fix" issues are resolved.
@@ -274,6 +286,35 @@ When adding or modifying parameters in the dataplane API (hindsight-api), you mu
- Update the client type definition in `lib/api.ts`
- Update any UI components that need to use the new parameter
### Harness Attribution (which coding agent wrote a document)
`hindsight-integrations/hindsight-coding-agents/` stamps the coding agent on every
document it retains, so the control plane can show its logo instead of another
`key=value` chip:
- `metadata.harness = "<id>"` — the authoritative field
- tag `harness:<id>` — the same value, so the documents list can filter on it
The ids are defined by that integration's HookSpecs
(`src/harness/hook-lifecycle.ts`) plus the persistent-plugin entrypoints
registered in `src/harness/registry.ts`, whose id is their
`createPluginEntry(...)` argument — currently `antigravity-cli`, `claude-code`,
`cline-cli`, `codex`, `copilot-cli`, `cursor-cli`, `devin-cli`, `grok-build`,
`kilo`, `opencode`.
The control plane resolves the value in
`hindsight-control-plane/src/lib/harness-logo.ts` (metadata wins over the tag) and
renders it with `components/ui/harness-logo.tsx` in the documents table and the
document detail dialog. **Adding a harness to the integration means adding it to
that registry in the same change**: copy its icon from
`hindsight-docs/static/img/icons/` (or take it from the agent's own brand assets
when the docs site carries none) into
`hindsight-control-plane/public/img/harness/` and add one entry. Don't register
ids nothing writes — a test asserts the registry matches the emitted set, plus an
explicit list of retired ids kept so already-retained documents keep their logo.
An unregistered harness is not an error: it renders no logo and still shows as
ordinary metadata.
### Adding New Integrations
Every new integration in `hindsight-integrations/` must satisfy all of the following before it can be merged:
@@ -315,7 +356,10 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
```
2. **main.py** (`hindsight-api-slim/hindsight_api/main.py`):
- Add field to the manual `HindsightConfig()` constructor call (search for "CLI override")
- No change is needed for ordinary environment-backed config fields. The CLI starts from `_get_raw_config()`,
so new `HindsightConfig` fields are carried through automatically.
- If the new field should be overridable by a CLI flag, add the argparse option in `_parse_cli_args()` and include
that field in the `dataclasses.replace(config, ...)` call near the "CLI override" comment.
3. **Use hierarchical config in MemoryEngine**:
```python
@@ -335,6 +379,16 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
- Add to appropriate section table with Variable, Description, Default
- Mark if it's hierarchical (can be overridden per-bank)
6. **Env template** (`.env.example`):
- Add the variable to the appropriate section, commented if optional, with a
short inline comment describing it (mirror the documentation entry).
- This file is the single source of truth for the env template:
`scripts/dev/setup.sh` copies it to `.env`, and `hindsight-embed` ships a
bundled copy (`hindsight-embed/hindsight_embed/env.example`) that seeds
embed/profile configs. After editing `.env.example`, re-copy it to the
embed package (`cp .env.example hindsight-embed/hindsight_embed/env.example`)
or the `test_bundled_template_matches_repo_root` sync test will fail.
#### Hierarchical vs Static Guidelines
**Hierarchical** (per-bank overridable):
@@ -351,7 +405,7 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
```bash
cp .env.example .env
# Edit .env with LLM API key
# Edit .env with the LLM provider/model and credentials for your setup
# Python deps
uv sync --directory hindsight-api-slim/
@@ -360,10 +414,10 @@ uv sync --directory hindsight-api-slim/
npm install
```
Required env vars:
Common LLM settings:
- `HINDSIGHT_API_LLM_PROVIDER`: openai, anthropic, gemini, groq, minimax, ollama, lmstudio
- `HINDSIGHT_API_LLM_API_KEY`: Your API key
- `HINDSIGHT_API_LLM_MODEL`: Model name (e.g., gpt-4o-mini, claude-sonnet-4-20250514)
- `HINDSIGHT_API_LLM_API_KEY`: API key for providers that require one
- `HINDSIGHT_API_LLM_MODEL`: Model name (defaults are provider-specific)
Optional (uses local models by default):
- `HINDSIGHT_API_EMBEDDINGS_PROVIDER`: local (default) or tei
+19 -5
View File
@@ -7,7 +7,6 @@
[![CI](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml/badge.svg)](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml)
[![Slack Community](https://img.shields.io/badge/Slack-Join%20Community-4A154B?logo=slack)](https://join.slack.com/t/hindsight-space/shared_invite/zt-3nhbm4w29-LeSJ5Ixi6j8PdiYOCPlOgg)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![gitcgr](https://gitcgr.com/badge/vectorize-io/hindsight.svg)](https://gitcgr.com/vectorize-io/hindsight)
![PyPI - Downloads](https://img.shields.io/pypi/dm/hindsight-api?label=PyPI)
![NPM Downloads](https://img.shields.io/npm/dm/%40vectorize-io%2Fhindsight-client?logoColor=orange&label=NPM&color=blue&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40vectorize-io%2Fhindsight-client)
<br/>
@@ -71,7 +70,7 @@ docker run -it --pull always --name hindsight --restart unless-stopped -p 8888:8
>API: http://localhost:8888
>UI: http://localhost:9999
You can modify the LLM provider by setting `HINDSIGHT_API_LLM_PROVIDER`. Valid options are `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `lmstudio`, and `minimax`. The documentation provides more details on [supported models](https://hindsight.vectorize.io/developer/models).
You can modify the LLM provider by setting `HINDSIGHT_API_LLM_PROVIDER`. Valid options are `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `lmstudio`, `minimax`, and `atlas` ([Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=hindsight)). The documentation provides more details on [supported models](https://hindsight.vectorize.io/developer/models).
@@ -143,6 +142,8 @@ main();
pip install hindsight-all -U
```
On Intel (x86_64) Macs, install `hindsight-all-slim` instead — see [Supported Platforms](#supported-platforms).
```python
import os
from hindsight import HindsightServer, HindsightClient
@@ -249,7 +250,7 @@ Recall performs 4 retrieval strategies in parallel:
- Graph: Entity/temporal/causal links
- Temporal: Time range filtering
![Retain Operation](hindsight-docs/static/img/recall-operation.webp)
![Recall Operation](hindsight-docs/static/img/recall-operation.webp)
The individual results from the retrievals are merged, then ordered by relevance using reciprocal rank fusion and a cross-encoder reranking model.
@@ -275,7 +276,7 @@ client = Hindsight(base_url="http://localhost:8888")
client.reflect(bank_id="my-bank", query="What should I know about Alice?")
```
![Retain Operation](hindsight-docs/static/img/reflect-operation.webp)
![Reflect Operation](hindsight-docs/static/img/reflect-operation.webp)
---
@@ -297,7 +298,20 @@ client.reflect(bank_id="my-bank", query="What should I know about Alice?")
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=vectorize-io/hindsight&type=date&legend=top-left)](https://www.star-history.com/#vectorize-io/hindsight&type=date&legend=top-left)
[![Star history](https://raw.githubusercontent.com/vectorize-io/hindsight/main/.github/star-history/chart.svg)](https://github.com/vectorize-io/hindsight/stargazers)
---
## Supported Platforms
| Platform | Docker | Bare Metal (pip) | Embedded DB (pg0) |
|----------|--------|------------------|--------------------|
| **Linux** (x86_64, ARM64) | ✅ | ✅ | ✅ |
| **macOS** (Apple Silicon / arm64) | ✅ | ✅ | ✅ |
| **macOS** (Intel / x86_64) | ✅ | ⚠️ | ✅ |
| **Windows** (x86_64) | ✅ | ✅ | ✅ |
⚠️ Intel Macs: use `hindsight-all-slim` — see the [installation guide](https://hindsight.vectorize.io/developer/installation#supported-platforms) for details.
---
## Contributing
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Generated
-1
View File
@@ -77,7 +77,6 @@
"npm:@radix-ui/react-dropdown-menu@^2.1.16",
"npm:@radix-ui/react-label@^2.1.8",
"npm:@radix-ui/react-popover@^1.1.15",
"npm:@radix-ui/react-radio-group@^1.3.8",
"npm:@radix-ui/react-select@^2.2.6",
"npm:@radix-ui/react-slider@^1.3.6",
"npm:@radix-ui/react-slot@^1.2.4",
@@ -17,7 +17,7 @@ FROM ghcr.io/vectorize-io/hindsight:latest-slim
# `pip install` would fall back to user site-packages and not be visible
# to the runtime python.
RUN uv pip install --python /app/api/.venv/bin/python --no-cache \
'sentence-transformers>=3.3.0' \
'sentence-transformers>=5.0.0' \
'transformers>=4.53.0' \
'torch>=2.6.0'
+4 -14
View File
@@ -3,21 +3,11 @@
# pgroonga is a multilingual full-text search extension built on Groonga.
# It works out of the box for CJK (Chinese, Japanese, Korean) and other
# non-whitespace-segmented languages via the TokenBigram tokenizer.
FROM groonga/pgroonga:latest-debian-pg17
FROM groonga/pgroonga:4.0.8-debian-17
# Install pgvector on top of the pgroonga base image (which already provides
# pgroonga and the Groonga library).
# pgroonga, the Groonga library, and the PostgreSQL PGDG package repository).
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
git \
postgresql-server-dev-17 \
postgresql-17-pgvector=0.8.6-1.pgdg13+1 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN cd /tmp && \
git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git && \
cd pgvector && \
make && \
make install
RUN rm -rf /tmp/pgvector && \
apt-get purge -y --auto-remove build-essential git postgresql-server-dev-17
@@ -1,6 +1,6 @@
name: hindsight
# Docker Compose file for Hindsight with PostgreSQL and vectorchord
# docker compose -f docker/docker-compose/docker-compose.yaml down && sleep 2 && docker compose -f docker/docker-compose/docker-compose.yaml up -d
# docker compose -f docker/docker-compose/vchord/docker-compose.yaml down && sleep 2 && docker compose -f docker/docker-compose/vchord/docker-compose.yaml up -d
# Make sure to set the required environment variables before running:
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
# - Configure LLM provider variables as needed (see below in the hindsight service)
+30 -9
View File
@@ -41,28 +41,43 @@ RUN apt-get update && apt-get install -y \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir uv
# Copy dependency files and README (required by pyproject.toml)
# Copy the workspace lock and member metadata before source code so dependency
# installation stays cacheable while matching the versions tested in CI.
COPY pyproject.toml uv.lock ./
COPY hindsight-all/pyproject.toml ./hindsight-all/
COPY hindsight-api/pyproject.toml ./hindsight-api/
COPY hindsight-api-slim/pyproject.toml ./api/
COPY hindsight-api-slim/README.md ./api/
WORKDIR /app/api
COPY hindsight-all-slim/pyproject.toml ./hindsight-all-slim/
COPY hindsight-dev/pyproject.toml ./hindsight-dev/
COPY hindsight-clients/python/pyproject.toml ./hindsight-clients/python/
COPY hindsight-embed/pyproject.toml ./hindsight-embed/
RUN ln -s api hindsight-api-slim
# Sync dependencies using appropriate extras based on INCLUDE_LOCAL_MODELS
# local-ml: torch, sentence-transformers, transformers, einops, flashrank, mlx (optional)
# embedded-db: pg0-embedded (always included for embedded PostgreSQL support)
# ONNX Runtime embeddings are intentionally not bundled into the official
# standalone image; install the local-onnx extra in custom images when needed.
ENV UV_PROJECT_ENVIRONMENT=/app/api/.venv
RUN if [ "$INCLUDE_LOCAL_MODELS" = "true" ]; then \
uv sync --extra local-ml --extra embedded-db; \
uv sync --locked --package hindsight-api-slim --no-install-package hindsight-api-slim --extra local-ml --extra embedded-db; \
else \
uv sync --extra embedded-db; \
uv sync --locked --package hindsight-api-slim --no-install-package hindsight-api-slim --extra embedded-db; \
fi
# Copy source code (alembic migrations are inside hindsight_api/)
WORKDIR /app/api
COPY hindsight-api-slim/hindsight_api ./hindsight_api
# Install the local package (uv sync only installed dependencies, not the package itself)
RUN uv pip install -e .
# Install the local package from the same validated lock after source is present.
WORKDIR /app
RUN if [ "$INCLUDE_LOCAL_MODELS" = "true" ]; then \
uv sync --locked --package hindsight-api-slim --extra local-ml --extra embedded-db; \
else \
uv sync --locked --package hindsight-api-slim --extra embedded-db; \
fi \
&& uv pip check --python /app/api/.venv/bin/python
# =============================================================================
# Stage: SDK Builder (needed for Control Plane)
@@ -145,6 +160,8 @@ FROM python:3.11-slim AS api-only
WORKDIR /app
# Note: libicu version varies by Debian version - try common versions in order
# Runtime images use uv directly; remove pip build tooling after installation so
# vulnerable setuptools-vendored packages and wheel are not shipped in production.
RUN apt-get update && apt-get install -y \
curl \
procps \
@@ -154,7 +171,8 @@ RUN apt-get update && apt-get install -y \
libossp-uuid16 \
&& (apt-get install -y libicu72 2>/dev/null || apt-get install -y libicu74 2>/dev/null || apt-get install -y libicu76 2>/dev/null || true) \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir uv
&& pip install --no-cache-dir uv \
&& pip uninstall --yes setuptools wheel
RUN useradd -m -s /bin/bash hindsight
@@ -292,6 +310,8 @@ WORKDIR /app
# Install Node.js, curl, uv, and system dependencies
# Note: libicu version varies by Debian version - try common versions in order
# Runtime images use uv directly; remove pip build tooling after installation so
# vulnerable setuptools-vendored packages and wheel are not shipped in production.
RUN apt-get update && apt-get install -y \
curl \
procps \
@@ -303,7 +323,8 @@ RUN apt-get update && apt-get install -y \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir uv
&& pip install --no-cache-dir uv \
&& pip uninstall --yes setuptools wheel
RUN useradd -m -s /bin/bash hindsight
@@ -0,0 +1,341 @@
# v2 Knowledge Pages — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make v2 knowledge pages a reliable, cleanly-tiered "wiki" surface: passive `entity_labels` tier-tagging + tag-scoped seeded pages, a `hindsight_*` MCP surface with one active `capture_initiative` verb that creates per-initiative pages linked by tag, and SessionStart/UserPromptSubmit page-roster injection.
**Architecture:** Shared TS core (`hindsight-integrations/hindsight-coding-agents`). Extraction stays blind to "pages"; classification is intrinsic (`knowledge:<tier>` tags), pages are tag-scoped saved views. Per-initiative navigation via a `relatedPageId:<id>` tag the synthesizer renders into `[[page:<id>]]`, with the Initiatives folder/roster as the guaranteed fallback.
**Tech Stack:** TypeScript, vitest, tsup bundling. Hindsight REST API (`/knowledge-base/*`, `/mental-models`, `/memories`, bank `/config`).
**Spec:** `docs/superpowers/specs/2026-07-25-v2-knowledge-pages-design.md`
**Working dir for all commands:** `hindsight-integrations/hindsight-coding-agents`
**Test command:** `npx vitest run <file>` (fast suite; excludes `*.live.test.ts`). Full check: `npx vitest run && npx tsc --noEmit`.
**Conventions to follow (existing patterns):**
- `HindsightClient` HTTP via `this.req("METHOD", this.bankUrl(path), body?)`; JSON via `await r.json()`.
- MCP tools are SDK-free `ToolSpec { name, description, inputSchema (ZodRawShape), handler }`; wrap handler bodies in `guarded(...)`; `ok(value)` / `err(e)` result helpers.
- Fail-open everywhere in hooks; pure logic separated from stdin/stdout plumbing.
- Do NOT add a Claude co-author trailer to any commit.
---
## Task 1: Config field `pageRefreshEveryTurns`
**Files:**
- Modify: `src/core/config.ts`
- Test: `src/core/config.test.ts`
- [ ] **Step 1: Write failing test** — assert the default resolves to 10 and an override wins.
```ts
it("pageRefreshEveryTurns defaults to 10 and is overridable", () => {
expect(loadConfig({ harness: "claude-code", projectDir: process.cwd() }).pageRefreshEveryTurns).toBe(10);
});
```
(Add an override case mirroring the existing override tests in this file.)
- [ ] **Step 2: Run** `npx vitest run src/core/config.test.ts` → FAIL (property missing).
- [ ] **Step 3: Implement** — add `pageRefreshEveryTurns: number` to the `Config` type and default `10` in the same place `recallMaxTokens`/`reflectTimeoutMs` are defined/merged. Follow the exact merge/layering pattern already used for numeric fields.
- [ ] **Step 4: Run** the test → PASS.
- [ ] **Step 5: Commit** `git add src/core/config.ts src/core/config.test.ts && git commit -m "feat(core): add pageRefreshEveryTurns config (default 10)"`
---
## Task 2: `knowledge-injection.ts` — roster/preamble formatting (pure, new)
**Files:**
- Create: `src/core/knowledge-injection.ts`
- Test: `src/core/knowledge-injection.test.ts`
Pure, SDK-free, no network. Parses the `listPages()` payload and formats the two injections.
- [ ] **Step 1: Write failing tests**
```ts
import { describe, expect, it } from "vitest";
import { parsePageList, buildKnowledgePreamble, buildRosterRefresh } from "./knowledge-injection";
describe("parsePageList", () => {
it("extracts {id,title} from the mental-model list shape, tolerating junk", () => {
const raw = { items: [{ id: "p1", name: "Component map" }, { id: "p2", name: "Core concepts" }, { nope: 1 }] };
expect(parsePageList(raw)).toEqual([{ id: "p1", title: "Component map" }, { id: "p2", title: "Core concepts" }]);
});
it("returns [] for null/garbage", () => {
expect(parsePageList(null)).toEqual([]);
expect(parsePageList(42 as unknown)).toEqual([]);
});
});
describe("buildKnowledgePreamble", () => {
it("includes guidance, a roster of pages, and a refresh note", () => {
const out = buildKnowledgePreamble([{ id: "p1", title: "Component map" }]);
expect(out).toContain("<hindsight_knowledge>");
expect(out).toContain("Component map");
expect(out).toContain("p1");
expect(out).toMatch(/hindsight_read_knowledge_page/);
});
it("has an empty-state line when there are no pages", () => {
const out = buildKnowledgePreamble([]);
expect(out).toMatch(/no knowledge pages yet|still learning/i);
});
});
describe("buildRosterRefresh", () => {
it("is a compact 'current pages' block listing ids+titles", () => {
const out = buildRosterRefresh([{ id: "p1", title: "Component map" }]);
expect(out).toContain("Component map");
expect(out).toContain("p1");
});
it("returns undefined when there are no pages (nothing to refresh)", () => {
expect(buildRosterRefresh([])).toBeUndefined();
});
});
```
- [ ] **Step 2: Run** `npx vitest run src/core/knowledge-injection.test.ts` → FAIL.
- [ ] **Step 3: Implement**
```ts
export interface PageRef { id: string; title: string; }
/** Defensive parse of HindsightClient.listPages() (GET /mental-models?detail=metadata → {items:[{id,name}]}). */
export function parsePageList(raw: unknown): PageRef[] {
const items = (raw as { items?: unknown })?.items;
if (!Array.isArray(items)) return [];
const out: PageRef[] = [];
for (const it of items) {
const id = (it as { id?: unknown })?.id;
const name = (it as { name?: unknown })?.name;
if (typeof id === "string" && typeof name === "string") out.push({ id, title: name });
}
return out;
}
function roster(pages: PageRef[]): string {
return pages.map((p) => `- ${p.title} (${p.id})`).join("\n");
}
/** SessionStart: teach when/why to use pages + list what exists. Empty-state aware. */
export function buildKnowledgePreamble(pages: PageRef[]): string {
const body = pages.length
? `Knowledge pages available in this repository:\n${roster(pages)}`
: "No knowledge pages yet — Hindsight is still learning this repo; they'll appear as it processes.";
return (
"<hindsight_knowledge>\n" +
"This repository has a Hindsight knowledge base: curated, continuously-updated pages summarizing its " +
"durable engineering knowledge (architecture, components, conventions, key decisions, and in-flight initiatives).\n" +
"Before substantial work, consult the relevant pages instead of re-deriving understanding from the code: read " +
"Conventions before writing new code, the Component map before changing a subsystem, and an initiative's page " +
"before continuing that feature.\n" +
`${body}\n` +
"Read one with hindsight_read_knowledge_page(page_id). Follow any [[page:<id>]] links you see. The list is " +
"re-injected for you periodically as it changes.\n" +
"</hindsight_knowledge>"
);
}
/** Periodic UserPromptSubmit refresh — compact, or undefined when there's nothing to show. */
export function buildRosterRefresh(pages: PageRef[]): string | undefined {
if (!pages.length) return undefined;
return (
"<hindsight_knowledge_refresh>\n" +
`Current Hindsight knowledge pages (may have changed):\n${roster(pages)}\n` +
"Read any with hindsight_read_knowledge_page(page_id).\n" +
"</hindsight_knowledge_refresh>"
);
}
```
- [ ] **Step 4: Run** the test → PASS.
- [ ] **Step 5: Commit** `git add src/core/knowledge-injection.ts src/core/knowledge-injection.test.ts && git commit -m "feat(core): knowledge-injection roster/preamble formatting"`
---
## Task 3: `entity_labels` tier vocabulary + configureBank wiring
**Files:**
- Modify: `src/core/missions.ts` (add `KNOWLEDGE_LABELS`)
- Modify: `src/core/hindsight.ts` (`configureBank` PATCH sets `entity_labels`)
- Test: `src/core/hindsight.*.test.ts` (add/extend a config test with a mock client)
- [ ] **Step 1: Write failing test** — assert `configureBank` PATCHes `/config` with `entity_labels` containing the `knowledge` group and its five values, and `entities_allow_free_form: true`. Use the existing fetch/req mock pattern from `hindsight.*.test.ts`; capture the PATCH body to `/config` and assert on it.
- [ ] **Step 2: Run** → FAIL.
- [ ] **Step 3: Implement**
- In `missions.ts`, export `KNOWLEDGE_LABELS` — the exact object from the spec §4 (`key:"knowledge"`, `type:"multi-values"`, `optional:true`, `tag:true`, the verbose group `description`, and the five value `{value,description}` entries: feature-work, decision, convention, component, concept).
- In `hindsight.ts::configureBank`, extend the existing `PATCH .../config` `updates` object to include `entity_labels: [KNOWLEDGE_LABELS]` and `entities_allow_free_form: true`. Import `KNOWLEDGE_LABELS`.
- Update the `[bank] configured …` log to mention `entity_labels`.
- [ ] **Step 4: Run** → PASS.
- [ ] **Step 5: Commit** `git add src/core/missions.ts src/core/hindsight.ts src/core/hindsight.*.test.ts && git commit -m "feat(core): passive knowledge entity_labels tier vocabulary + configureBank wiring"`
---
## Task 4: Tag-scoped seeded pages + Initiatives folder + link source_query
**Files:**
- Modify: `src/core/missions.ts` (`PAGES` gain `tags`; Initiatives `source_query` link instruction)
- Modify: `src/core/hindsight.ts` (`ensureFolder`, `createPages` sets page `tags` + parents Initiatives under the folder)
- Test: `src/core/hindsight.pages.test.ts`
- [ ] **Step 1: Write failing tests** (mock client `req`):
- Each seeded page POST to `/knowledge-base/pages` includes `tags: ["knowledge:<tier>"]` mapped per the spec §5 table.
- The Initiatives page is created with `parent_id` equal to the id returned by an Initiatives folder POST to `/knowledge-base/folders`.
- `ensureFolder("Initiatives")` returns an existing root folder's id when the tree already contains it (GET `/knowledge-base/tree`) and does NOT POST a duplicate.
- [ ] **Step 2: Run** → FAIL.
- [ ] **Step 3: Implement**
- `missions.ts`: add `tags: string[]` to each `PAGES` entry (feature-work/decision/convention/component/concept mapping). Append to the Initiatives `source_query`: *"When a source memory carries a tag of the form `relatedPageId:<id>`, include a Markdown link `[[page:<id>]]` to that page in the summary, so each initiative links to its detailed page."*
- `hindsight.ts`: add
```ts
/** Find a root folder by name (case-insensitive) or create it; returns its id. */
async ensureFolder(name: string): Promise<string | undefined> {
try {
const tree = (await (await this.req("GET", this.bankUrl("/knowledge-base/tree"))).json()) as
{ roots?: { id?: string; kind?: string; name?: string }[] };
const hit = (tree.roots || []).find((n) => n.kind === "folder" && (n.name || "").toLowerCase() === name.toLowerCase());
if (hit?.id) return hit.id;
} catch { /* fall through to create */ }
try {
const r = await this.req("POST", this.bankUrl("/knowledge-base/folders"), { name });
return ((await r.json()) as { id?: string }).id;
} catch { return undefined; }
}
```
- In `createPages()`: before the loop, `const initiativesFolderId = await this.ensureFolder("Initiatives");`. For each page, build body `{ name, source_query, tags: p.tags, parent_id: <initiativesFolderId if this is the Initiatives page else undefined>, trigger: { fact_types:[...], refresh_after_consolidation:true } }`. (Page-level `tags` drives synthesis scoping via `RefreshTagFiltering`; `tags_match` defaults to `all_strict` when tags present.)
- [ ] **Step 4: Run** → PASS.
- [ ] **Step 5: Commit** `git add src/core/missions.ts src/core/hindsight.ts src/core/hindsight.pages.test.ts && git commit -m "feat(core): tag-scope seeded pages, Initiatives folder, relatedPageId link source_query"`
---
## Task 5: Client helpers — per-initiative page + marker retain
**Files:**
- Modify: `src/core/hindsight.ts` (`captureInitiative`)
- Test: `src/core/hindsight.pages.test.ts`
- [ ] **Step 1: Write failing tests** (mock `req`):
- `captureInitiative({title:"Retry backoff for the uploader", summary:"…"})` → derives slug `retry-backoff-for-the-uploader`, POSTs a page id `initiative-<slug>` to `/knowledge-base/pages` with `parent_id` = the Initiatives folder and `tags: ["knowledge:feature-work"]`, AND POSTs a marker to `/memories` (via `retain`) tagged `["knowledge:feature-work","relatedPageId:initiative-<slug>"]`, strategy `session` or `document` (pick `document`), `async:true`. Returns `{ page_id: "initiative-<slug>" }`.
- Slug is deterministic and identical between the page id and the `relatedPageId:` tag value.
- Enhancement path: `captureInitiative({title, summary, relatesToPageId:"initiative-x"})` POSTs NO new page; marker tagged `relatedPageId:initiative-x`; returns `{ page_id: "initiative-x" }`.
- [ ] **Step 2: Run** → FAIL.
- [ ] **Step 3: Implement**
```ts
private slugify(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "initiative";
}
/** Active-path capture: register a major feature as a per-initiative page + a tagged marker memory. */
async captureInitiative(args: { title: string; summary: string; relatesToPageId?: string }): Promise<{ page_id: string }> {
const pageId = args.relatesToPageId ?? `initiative-${this.slugify(args.title)}`;
if (!args.relatesToPageId) {
const folderId = await this.ensureFolder("Initiatives");
await this.req("POST", this.bankUrl("/knowledge-base/pages"), {
name: args.title,
source_query: `Summarize the "${args.title}" initiative: what is being built or changed and why, and its current state — drawn from the project's memory.`,
parent_id: folderId,
tags: ["knowledge:feature-work", `relatedPageId:${pageId}`],
trigger: { fact_types: ["world", "experience", "observation"], refresh_after_consolidation: true },
});
}
const verb = args.relatesToPageId ? "Enhancement to an existing initiative" : "New initiative";
const content = `${verb}: ${args.title}. ${args.summary}`;
await this.retain(content, "initiative marker", pageId /* not a stable doc id requirement; see note */,
["knowledge:feature-work", `relatedPageId:${pageId}`], "document", { async: true });
return { page_id: pageId };
}
```
- NOTE: use a UNIQUE document id per marker (e.g. `initiative-marker-<slug>-<n>`), NOT `pageId`, so repeated enhancement captures accrue instead of replacing. Since `Date.now()` is fine here (runtime, not a workflow script), suffix with a timestamp: `initiative-marker-${this.slugify(args.title)}-${Date.now()}`. Keep the `relatedPageId` tag equal to `pageId`.
- Confirm `retain(content, context, documentId, tags, strategy, opts)` signature matches current `HindsightClient.retain`.
- [ ] **Step 4: Run** → PASS.
- [ ] **Step 5: Commit** `git add src/core/hindsight.ts src/core/hindsight.pages.test.ts && git commit -m "feat(core): captureInitiative — per-initiative page + relatedPageId marker"`
---
## Task 6: MCP surface — `hindsight_*` grounding + `capture_initiative`; drop page CRUD
**Files:**
- Modify: `src/core/knowledge-tools.ts`
- Modify: `src/mcp-server.ts` (only if it references removed tool names)
- Test: `src/core/knowledge-tools.test.ts`, `src/mcp-server.test.ts` (tool-count assertions)
- [ ] **Step 1: Write failing tests**
- `buildKnowledgeTools(client, bankId)` returns exactly these tool names: `hindsight_get_current_bank`, `hindsight_list_knowledge_pages`, `hindsight_read_knowledge_page`, `hindsight_search_memory`, `hindsight_capture_initiative`, `hindsight_ingest_document`. (Assert the set; update any count assertion.)
- `hindsight_capture_initiative` handler calls `client.captureInitiative` with `{title, summary, relatesToPageId?}` and returns the page id (mock client).
- No `create_page` / `update_page` / `delete_page` tools are present.
- Each tool still fails closed via `guarded` (a thrown client error → `isError:true`, no throw).
- [ ] **Step 2: Run** → FAIL.
- [ ] **Step 3: Implement**
- Rebuild the `buildKnowledgeTools` list: rename read/recall/ingest/bank tools to the `hindsight_*` names; drop `create_page`/`update_page`/`delete_page`; add `hindsight_capture_initiative` with `inputSchema { title: z.string(), summary: z.string(), relates_to_page_id: z.string().optional() }` calling `client.captureInitiative({ title, summary, relatesToPageId: relates_to_page_id })`.
- Use the **verbatim agent-facing `description` strings** from the spec §6 / the brainstorm (grounding tools + the explicit WHEN/WHEN-NOT `capture_initiative` description).
- Update `mcp-server.ts` only if it enumerates tool names; otherwise it consumes `buildKnowledgeTools` generically and needs no change.
- [ ] **Step 4: Run** `npx vitest run src/core/knowledge-tools.test.ts src/mcp-server.test.ts` → PASS.
- [ ] **Step 5: Commit** `git add src/core/knowledge-tools.ts src/mcp-server.ts src/core/knowledge-tools.test.ts src/mcp-server.test.ts && git commit -m "feat(mcp): hindsight_* grounding tools + capture_initiative; remove raw page CRUD from agent"`
---
## Task 7: SessionStart — preamble + roster
**Files:**
- Modify: `src/core/session-start.ts`
- Test: `src/core/session-start.test.ts`
- [ ] **Step 1: Write failing tests**
- `buildSessionStartContext` now fetches pages via the client and injects `buildKnowledgePreamble(...)` instead of the static `KNOWLEDGE_MISSION`. Extend the `SeedContextClient` interface with `listPages(): Promise<unknown>`; the mock returns `{items:[{id:"p1",name:"Component map"}]}` and the output contains "Component map".
- listPages failure is fail-open: the preamble still renders (empty-state) and the seed logic is unaffected.
- [ ] **Step 2: Run** → FAIL.
- [ ] **Step 3: Implement**
- Add `listPages` to `SeedContextClient`.
- Replace the `parts.push(KNOWLEDGE_MISSION)` line with: fetch `const pages = parsePageList(await client.listPages().catch(() => null));` then `parts.push(buildKnowledgePreamble(pages));`. Import from `./knowledge-injection`.
- Remove the now-unused `KNOWLEDGE_MISSION` export if nothing else references it (grep first; keep if referenced).
- [ ] **Step 4: Run** → PASS.
- [ ] **Step 5: Commit** `git add src/core/session-start.ts src/core/session-start.test.ts && git commit -m "feat(core): SessionStart injects page roster + guidance preamble"`
---
## Task 8: UserPromptSubmit — hook-counted periodic roster refresh
**Files:**
- Modify: `src/core/hook.ts`
- Test: `src/core/hook.test.ts`
- [ ] **Step 1: Write failing tests**
- The session cache round-trips `{answer, turns}`; each `buildHookOutput` call increments `turns`.
- Add `listPages` to the `HookClient` interface. On a turn where `turns % cfg.pageRefreshEveryTurns === 0`, the output includes `buildRosterRefresh(...)` content (assert "Component map" appears); on other turns it does not.
- Refresh is fail-open (a `listPages` rejection doesn't break recall/injection).
- First-turn behavior (reflect) unchanged.
- [ ] **Step 2: Run** → FAIL.
- [ ] **Step 3: Implement**
- Extend the cache read/write to `{ answer?: string; turns?: number }`. Compute `const turns = (cached.turns ?? 0) + 1;` and persist it alongside `answer`.
- Add `listPages(): Promise<unknown>` to `HookClient`.
- After computing `memBlock`, if `cfg.pageRefreshEveryTurns > 0 && turns % cfg.pageRefreshEveryTurns === 0`, `try { const refresh = buildRosterRefresh(parsePageList(await client.listPages())); if (refresh) blocks.push(refresh); } catch { /* fail-open */ }`. Kick the `listPages` call off concurrently with recall to avoid added latency.
- Import from `./knowledge-injection`.
- [ ] **Step 4: Run** `npx vitest run src/core/hook.test.ts` → PASS.
- [ ] **Step 5: Commit** `git add src/core/hook.ts src/core/hook.test.ts && git commit -m "feat(core): UserPromptSubmit hook-counted periodic page-roster refresh"`
---
## Task 9: Full check + LLM behavior (live) verification
**Files:**
- Modify: `src/system.live.test.ts` (add coverage; runs only under `HINDSIGHT_LIVE_E2E=1`)
- [ ] **Step 1: Full fast suite + types** — `npx vitest run && npx tsc --noEmit` → all green.
- [ ] **Step 2: Add a live assertion** (guarded by the existing live env flag) that after seeding a small repo + one `captureInitiative`, the Initiatives page content contains a `[[page:initiative-…]]` link (verifies the `relatedPageId` → link rendering end-to-end). Keep it in the live suite; do not run in the fast job.
- [ ] **Step 3: Manual/live run** (optional, operator): `HINDSIGHT_API_URL=http://localhost:8888 npm run test:live`.
- [ ] **Step 4: Commit** `git add src/system.live.test.ts && git commit -m "test(live): initiative page renders relatedPageId link end-to-end"`
---
## Final review
- [ ] Dispatch a final code-reviewer over the whole change set against the spec (`docs/superpowers/specs/2026-07-25-v2-knowledge-pages-design.md`).
- [ ] Rebuild + dev-install the `claude-code-v2` bundle so the running plugin picks up the new hooks/MCP (`bash scripts/dev-install.sh`); do not push/PR without explicit consent.
- [ ] Note deferred follow-ups: session drill-down tag, `capture_decision`, `gotcha` tier, older-bank reseed requirement.
@@ -0,0 +1,135 @@
# v2 Knowledge Pages — Design Spec
**Status:** approved in brainstorm (2026-07-25), pending implementation plan
**Scope:** `hindsight-integrations/hindsight-coding-agents` (shared TS core) + `claude-code-v2` wrapper
**Motivation:** make knowledge pages a real, trustworthy "wiki" surface for the vectorize-crm demo (the `knowledge-pages-as-trust-surface` principle) — the agent reliably knows what pages exist, pages are cleanly tiered instead of blended, and major initiatives become first-class, linkable pages.
---
## 1. Problem
Three gaps in the current v2 branch:
1. **Page discovery is a blind fetch.** SessionStart injects a static `KNOWLEDGE_MISSION` telling the agent to call `agent_knowledge_list_pages`, but hands it **no roster** — the agent never learns a page exists unless it independently decides to call the tool. Per-turn recall injects facts, not pages.
2. **Pages are blended.** Neither the seeded `PAGES` nor the agent's `create_page` tool scope synthesis by tag, so every page synthesizes from the whole bank filtered only by `fact_type`. Git-log, session, and survey memories all bleed into every page.
3. **No first-class initiative tracking / linking.** Hindsight has no native page-to-page links. A "major feature" leaves no durable, navigable page a future session can pick up.
## 2. Principles applied
- Automatic/visible value; zero out-of-band CLI; memory beats code search; knowledge pages as a trust surface; minimal post-setup burden.
- Modular units, small files, follow existing patterns (per-hook specs, fail-open, unit-testable pure cores).
- The **memory extractor never knows what a "page" is.** Classification is by the fact's *intrinsic* nature; pages are application-side saved views. No abstraction leak into extraction.
## 3. Architecture overview
Two complementary curation paths + a discovery layer:
- **Passive (automatic):** `entity_labels` schema-forces the extractor to tag qualifying facts `knowledge:<tier>`. Seeded **tier pages** each filter on one tier tag. No agent effort.
- **Active (high-signal):** one intent-named MCP verb, `hindsight_capture_initiative`, lets the agent register a major feature as a **per-initiative page** with a tag-based link back from the aggregate Initiatives page.
- **Discovery:** SessionStart injects guidance + the page roster; the UserPromptSubmit hook re-injects a fresh roster on a fixed cadence (hook-counted, not model-counted).
## 4. `entity_labels` — passive tier tagging
One hierarchical bank config group, set by `configureBank` at seed time:
```jsonc
{
"key": "knowledge",
"type": "multi-values", // 0, 1, or several — empty is normal
"optional": true,
"tag": true, // emits knowledge:<value> onto the fact's tags
"description": "Routing labels for this project's Hindsight KNOWLEDGE PAGES — curated, human-readable summaries of the repo's DURABLE engineering knowledge (architecture, key decisions, conventions, ongoing initiatives), each page rebuilt automatically from the facts labeled for it. Mark a fact only when it is durable, reusable knowledge a developer would still want surfaced in future sessions. IMPORTANT: leave this EMPTY for routine, transient, or operational facts — a passing test, a one-off command, a status update, a debugging dead-end. MOST facts should get no label here. Assign more than one value only when the fact genuinely fits several.",
"values": [
{ "value": "feature-work", "description": "A new feature, initiative, or enhancement being planned or built — the capability being added and the intent behind it. Not routine bug-fixes or chores." },
{ "value": "decision", "description": "A technical decision that will constrain future work, with its rationale — why this approach was chosen over alternatives, or a rule deliberately adopted." },
{ "value": "convention", "description": "An established way this project does things — naming, structure, testing, error handling, or another recurring pattern a contributor is expected to follow." },
{ "value": "component", "description": "What a specific module, file, service, or subsystem is responsible for, or how components depend on and connect to one another." },
{ "value": "concept", "description": "A domain concept, key abstraction, or piece of project vocabulary a new contributor must understand to work effectively." }
]
}
```
Notes:
- `tag: true``_inject_label_tags` copies each `knowledge:<value>` onto the fact's `tags` (no extra query infra).
- Selectivity (multi-values + "mostly empty" instruction) prevents force-fitting routine facts into a tier.
## 5. Seeded tier pages (tag-scoped)
Created via `/knowledge-base/pages` (supports `tags`, `trigger`, `parent_id`) — **not** `/mental-models`. Each `PAGES` entry gains a `trigger.tags` pin:
| Page | `trigger.tags` |
| --- | --- |
| Initiatives and enhancements | `["knowledge:feature-work"]` |
| Key decisions and rationale | `["knowledge:decision"]` |
| Conventions and patterns | `["knowledge:convention"]` |
| Component map | `["knowledge:component"]` |
| Core concepts | `["knowledge:concept"]` |
`tags_match` strict enough to exclude untagged facts (`all_strict`/`any_strict`). Tag matching is exact set-ops (no wildcards) — this is *why* the vocabulary is fixed, not per-feature.
## 6. MCP surface
Raw page CRUD (`create_page`/`update_page`/`delete_page`) is **removed** from the agent. The agent sees grounding tools + one capture verb. Naming convention: `hindsight_*`.
**Grounding**
- `hindsight_list_knowledge_pages` `{}` — roster: id, title, one-line coverage. (agent-facing description as drafted in brainstorm)
- `hindsight_read_knowledge_page` `{ page_id }` — full page content; follow `[[page:<id>]]` links by re-calling.
- `hindsight_search_memory` `{ query, max_tokens? }` — raw fact recall for specifics pages don't cover.
- `hindsight_get_current_bank` `{}` — minor introspection (kept).
**Capture**
- `hindsight_capture_initiative` `{ title, summary, relates_to_page_id? }` — the one active verb. Explicit WHEN / WHEN-NOT description (as drafted). Returns the initiative page id.
- `hindsight_ingest_document` `{ title, content }` — existing `agent_knowledge_ingest`, reframed.
(Full agent-facing descriptions are captured verbatim in the brainstorm thread and will be reproduced in the implementation plan.)
## 7. `hindsight_capture_initiative` mechanism
- Derive one slug `S` from `title`. Page id = `initiative-<S>`. **The slug in the tag and the page id are the same token, derived once** (cannot drift).
- **New initiative** (`relates_to_page_id` omitted):
1. Create page `initiative-<S>` (title from `title`, `source_query` about that initiative) under an **"Initiatives" folder** (tag-scoped).
2. Retain a marker memory (text = title + summary) tagged `["knowledge:feature-work", "relatedPageId:initiative-<S>"]`. **No session tag** (decided — the MCP server has no Claude session id; faking one wouldn't link to the Stop write-back's `conversation:<sessionId>` doc anyway).
- **Enhancement** (`relates_to_page_id` given): marker only, `relatedPageId = relates_to_page_id`; no new page. Re-invoking for the same initiative accrues markers → the page re-synthesizes with progress.
### Link survival (why `relatedPageId` as a tag, not in prose)
A tag is set directly via the retain `tags` param — it **bypasses LLM extraction entirely**, so it's guaranteed present verbatim (no REF-ID-style preservation needed at extraction). Verified: the reflect/synthesis path SELECTs `tags` and serializes facts via `_prune_nulls(model_dump())`, which keeps non-empty tags → **the synthesis LLM sees the tag.** The **Initiatives page `source_query`** instructs: *"when a memory carries a `relatedPageId:<id>` tag, emit a `[[page:<id>]]` link to it."* The link id is generated from the tag value at synthesis time, so it always matches the created page id.
- Only **Stage 2 (synthesis)** is probabilistic now (bounded token budget may omit some entries when there are many).
- **Guaranteed fallback:** the per-initiative page always exists (created via API, independent of any LLM stage) and appears in the **Initiatives folder / injected roster**, so navigation works even if a synthesized inline link drops.
## 8. Page-access injection
- **SessionStart** (`session-start.ts`): replace static `KNOWLEDGE_MISSION` with a preamble = (a) guidance on *when/why* to consult pages, (b) the roster fetched via `client.listPages()` (`- <title> (<id>)`, empty-state aware), (c) a note that the list refreshes periodically. Cold repo → empty roster line; roster comes alive mid-session as seeding/survey complete.
- **UserPromptSubmit** (`hook.ts`): extend the per-session cache (`{answer}``{answer, turns}`); the **hook** counts user turns and, roughly every `pageRefreshEveryTurns` (default 10, approximate), calls `listPages()` and injects a compact roster refresh. Runs concurrently with recall; **fail-open** (a refresh error never blocks the turn).
- **Shared formatting** (new `core/knowledge-injection.ts`, SDK-free/unit-testable): `parsePageList(raw) -> {id,title}[]`, `buildKnowledgePreamble(pages)`, `buildRosterRefresh(pages)`.
- **Config:** `pageRefreshEveryTurns` (default 10).
## 9. Non-goals / deferred
- Session drill-down tag on captured markers (dropped — see §7).
- `hindsight_capture_decision` and other capture verbs (passive path covers those tiers; revisit if the aggregate pages aren't sharp enough).
- A `gotcha`/`pitfall` tier (five tiers for now).
- Native page-to-page links / backlinks (Hindsight has none; we approximate via folder tree + `relatedPageId`-driven `[[page:<id>]]`).
## 10. Risks / migration
- **Older banks** need re-seeding to pick up the new `entity_labels`, the `session` retain strategy, and the tag-scoped page triggers (`configureBank` sets them). User is starting fresh with v2 banks, so acceptable; live retain fails open otherwise.
- **Stage-2 synthesis omission** for large initiative counts — mitigated by the folder/roster fallback.
- **Instruction adherence** for the `source_query` link-rendering and the label selectivity — both are LLM-following behaviors; cover with an `hs_llm_core` judge test, and the deterministic mechanics (tag injection, roster formatting, slug/id equality, hook turn-counting) with fast unit tests.
## 11. Testing
- **Deterministic unit tests:** `knowledge-injection` formatting + empty-state; hook turn-counter + cadence; `capture_initiative` slug→id→tag equality and request shape (mock client); tag-scoped page request bodies; entity_labels config emitted by `configureBank`.
- **LLM judge test (`hs_llm_core`):** label selectivity (routine facts get no `knowledge:*`), and `relatedPageId``[[page:<id>]]` rendering in a synthesized Initiatives page.
## 12. File map (anticipated)
- `src/core/knowledge-injection.ts` (new) — roster/preamble formatting.
- `src/core/session-start.ts` — preamble + roster.
- `src/core/hook.ts` — cache `{answer,turns}` + periodic roster refresh.
- `src/core/config.ts``pageRefreshEveryTurns`.
- `src/core/missions.ts``entity_labels` group; tag-scoped `PAGES`; Initiatives `source_query` link instruction.
- `src/core/hindsight.ts``configureBank` sets `entity_labels`; `createPages` pins `trigger.tags` + Initiatives folder; new `createInitiativePage`/marker retain helpers.
- `src/core/knowledge-tools.ts` — new `hindsight_*` grounding + `capture_initiative` tools; remove raw page CRUD from agent surface.
- Tests alongside each.
@@ -0,0 +1,139 @@
# Reflect + Pages Runtime — Design Spec
**Status:** decided (2026-07-27), reconciles the earlier reflect-based runtime with the recall-based v2 into one opinionated path
**Scope:** `hindsight-integrations/hindsight-coding-agents` (shared TS core) + `claude-code-v2` wrapper
**Motivation:** the 33-task coding benchmark showed the v2 recall-per-prompt runtime *underperforms no memory* (35.0 mean corrections vs 32.0 baseline), while the earlier reflect-injection runtime beats baseline by 22% (25.0). This spec restores reflect as the only deep-memory path and replaces raw per-turn recall with lightweight injection from knowledge pages — "fast like recall, organized like reflect" — keeping v2's page/curation machinery where it earned its place and deleting it where it didn't.
---
## 1. Problem
Two prior iterations, each half right:
1. **Reflect runtime (v1):** one agentic REFLECT over the bank at session start, cached and re-injected every turn. Benchmark-proven (25.0 mean corrections) — but nothing surfaced mid-session; a task that drifted away from the first message got stale context.
2. **Recall runtime (v2):** per-prompt recall injection for turn-by-turn visibility, plus knowledge pages as a trust surface. But raw recall injects unsynthesized fact fragments — noise that *hurt*: 35.0 mean corrections, worse than running with no memory at all.
| Runtime | Mean corrections (33-task benchmark) | vs no-memory (32.0) |
| --- | --- | --- |
| Reflect-injection (v1) | **25.0** | **22%** |
| Recall-per-prompt (v2) | 35.0 | +9% (regression) |
| No memory | 32.0 | baseline |
The reconciliation: keep reflect's synthesis quality as the deep path, keep v2's per-turn visibility principle, but source the per-turn material from the already-synthesized knowledge pages instead of raw recall.
## 2. Decisions
Explicit, decided — not options:
1. **Reflect restored** as the only deep-memory path (session-start, agentic synthesis, cached + re-injected every turn).
2. **Recall removed from the runtime** entirely. No per-prompt `recall` call.
3. **No `memoryMode` flag.** One opinionated path; config is for environment, naming, and harness wiring only — never behavior selection.
4. **Sections, not pages, are the per-turn injection unit** — locally matched, budget-trimmed, provenance-labeled.
5. **JSON turn transcripts** replace the markdown tool-call transcript in the Stop-hook write-back, with compact action entries.
6. **No tags / no `entity_labels`.** The server re-synthesizes pages after consolidation; "living pages" needs no client-side tagging machinery.
## 3. Runtime path — session start
Three steps, in order, all inside existing hooks (no out-of-band CLI):
### 3a. Cold-repo bootstrap (kept from v2)
On a bank with no prior memories: automatic shallow gitlog seed + codebase survey, exactly as v2 does it. The user never runs a setup command; the first session self-seeds. (Deep ingestion of that history is §7 — the seed here stays instant.)
### 3b. REFLECT once, on the first task message
The benchmark-proven core:
- On the first user prompt of the session, run one **REFLECT** — agentic synthesis over the whole bank, prompted to return the *root-cause decision with exact values* (concrete file paths, config values, version numbers — not summaries of summaries).
- Cache the result per session; **re-inject it every turn**. It is the session's durable deep context.
- One LLM-backed call per session, on the message that actually states the task — not on session-open, where there is nothing to reflect about.
### 3c. Page index build
Fetch all knowledge pages once (existing `listPages` + page reads), split each page at headings into **sections**, and build a **local section index** in the hook process. This index is what every subsequent turn matches against (§4) — no further server calls on the hot path.
## 4. Runtime path — every turn
Per-turn visibility, satisfied at ~zero latency and ~zero cost. Injection sources from **knowledge pages, not raw recall** — the material is already synthesized and organized; the turn hook only *selects* from it.
Mechanism (local, deterministic — no server call, no LLM call):
| Aspect | Design |
| --- | --- |
| Unit | Page **sections** (pages split at headings at index-build time) |
| Matching | Lexical: prompt scored against each section by weighted term overlap; **heading hits weighted higher** than body hits |
| Selection | Top 23 sections |
| Budget | Trimmed to a **~700-token total** |
| Provenance | Each snippet labeled `From <page> <section>` + a tool pointer to read the full page |
| Floor | A minimum-score threshold below which **nothing is injected** — silence over noise |
| Refresh | Section index rebuilt on the existing 10-turn roster cadence (`pageRefreshEveryTurns`) |
The score floor is load-bearing: the benchmark showed that injecting weak matches is worse than injecting nothing (v2's regression). An empty injection is a correct outcome, not a failure mode.
## 5. Write-back
The Stop-hook session retain is **kept** — same trigger, same fail-open behavior. What changes is the transcript format handed to extraction:
- **JSON turns**, not markdown: an array of `{ "role": "user" | "assistant", "text": ... }` entries for the conversational content.
- Tool calls collapse to **compact one-line action entries**: `{ "role": "action", "text": "Edit boltons/strutils.py" }` — tool name + primary target only, **no arguments, no outputs**.
Rationale: extraction keeps the concrete artifacts (which files were touched, what actions occurred) without the transcript noise of full tool payloads — the markdown tool-call dumps were volume without signal.
## 6. Knowledge pages
Simplified from the v2 spec:
- **Dropped: tags and `entity_labels`** (v2 spec §45). The server already re-synthesizes pages after consolidation, so pages stay "living" with no client-side routing machinery. The extractor-never-knows-about-pages principle now holds trivially — there is nothing to route.
- **Creation paths:**
1. **Seeded taxonomy** at bank creation (the fixed page set, as today, minus tag triggers).
2. **Agent-driven `capture_initiative`** at plan approval — the one active capture verb survives from v2.
3. **Organic splitting** of pages that outgrow their scope is a **server/curator concern**, not a client feature.
## 7. Ingestion — progressive background deepening
*Status: design accepted, implementation phased separately.*
Replaces the manual backfill CLI as the user-facing path (the CLI was out-of-band burden; nobody runs it). The principle: converge to full-depth history through normal usage, with zero user action.
1. **Instant shallow seed** — the gitlog seed from §3a; the session is useful immediately.
2. **Background deepening** — a background worker deep-ingests **per-commit-with-diffs, incrementally**, never blocking a turn.
3. **Working-set prioritization** — commits are ingested in order of relevance to what the agent is actually doing: files the agent reads/edits get their commit histories ingested **first**. Depth arrives where it pays off.
4. **Checkpointing** — progress persists across sessions; each session resumes deepening where the last left off, converging to full depth over normal usage.
The **backfill CLI survives as an internal tool** (benchmark setup, CI bank preparation) — it is no longer a documented user path.
## 8. Gap analysis — v2 principles under this design
| v2 principle | How this design satisfies it |
| --- | --- |
| See-it-working (automatic, visible value) | Reflect answer visible from turn 1; page-section snippets appear with explicit `From <page> <section>` provenance, so the user sees memory working — and the score floor keeps it from visibly misfiring. |
| No out-of-band CLI | Cold-repo auto-seed kept (§3a); backfill CLI demoted to internal-only, replaced by background deepening (§7). Nothing requires a terminal command. |
| Reuse-over-reinvent | Reflect, `listPages`, Stop-hook retain, `capture_initiative`, and the 10-turn refresh cadence are all existing machinery recombined; the only new code is the local section index and matcher — deliberately dumb (lexical, no LLM). |
| Preserve-intent | Reflect is prompted for root-cause decisions with exact values; JSON transcripts keep concrete action artifacts; per-commit-with-diffs deepening captures *why* the code changed, not just that it did. |
| Near-zero-burden | No config flags to choose, no CLI to run, no tags to maintain; one LLM call per session start, everything else local. |
## 9. Verification gates
Ship gates, in order:
1. **Reflect-restored benchmark:** the restored runtime must recover **~25 mean corrections at n=2 on identical banks** to the original reflect run. This proves the restoration is faithful before anything is layered on.
2. **Reflect+pages benchmark:** with per-turn section injection enabled, the score **must not regress** vs reflect-alone. Section injection earns its place by not hurting; any regression points at the floor/budget tuning.
3. **Live system suite:** existing hook/integration suite updated for the new path — reflect caching + per-turn re-injection, section index build/refresh, score-floor silence, JSON transcript shape, action-entry compaction. Deterministic pieces (matcher scoring, budget trim, provenance formatting, transcript serialization) as fast unit tests.
## 10. Non-goals / deferred
- Any per-turn LLM or server call for injection (explicitly excluded — the local matcher is the whole point).
- Semantic/embedding-based section matching (revisit only if lexical matching demonstrably misses; start dumb).
- Client-side page splitting or curation (server/curator concern, §6).
- Progressive-deepening implementation details (worker scheduling, checkpoint format) — phased separately per §7.
## 11. File map (anticipated)
- `src/core/reflect.ts` (restored) — session reflect call + per-session cache.
- `src/core/section-index.ts` (new) — page → sections split, lexical scorer, budget trim, provenance formatting; pure/unit-testable.
- `src/core/hook.ts` — drop recall; inject cached reflect + matched sections; index refresh on roster cadence.
- `src/core/session-start.ts` — cold-repo seed (unchanged) + reflect trigger wiring + initial index build.
- `src/core/transcript.ts` (new or reworked) — JSON turn serialization + action-entry compaction for the Stop hook.
- `src/core/missions.ts` / `src/core/hindsight.ts` — remove `entity_labels` and tag-scoped page triggers; keep seeded taxonomy + `capture_initiative`.
- `src/core/config.ts` — remove any behavior flags; keep env/naming/harness + `pageRefreshEveryTurns`.
- Tests alongside each.
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.8.0
appVersion: "0.8.0"
version: 0.9.0
appVersion: "0.9.0"
keywords:
- ai
- memory
+3 -3
View File
@@ -66,13 +66,13 @@ helm install hindsight ./helm/hindsight -n hindsight --create-namespace -f value
| Parameter | Description | Default |
|-----------|-------------|---------|
| `version` | Default image tag for all components | `0.1.0` |
| `version` | Default image tag for all components | Chart `appVersion` |
| `api.enabled` | Enable the API component | `true` |
| `api.image.repository` | API image repository | `hindsight/api` |
| `api.image.repository` | API image repository | `ghcr.io/vectorize-io/hindsight-api` |
| `api.image.tag` | API image tag (defaults to `version`) | - |
| `api.service.port` | API service port | `8888` |
| `controlPlane.enabled` | Enable the control plane | `true` |
| `controlPlane.image.repository` | Control plane image repository | `hindsight/control-plane` |
| `controlPlane.image.repository` | Control plane image repository | `ghcr.io/vectorize-io/hindsight-control-plane` |
| `controlPlane.image.tag` | Control plane image tag (defaults to `version`) | - |
| `controlPlane.service.port` | Control plane service port | `3000` |
| `postgresql.enabled` | Deploy PostgreSQL as subchart | `true` |
@@ -60,13 +60,13 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.name
{{- /* Inherit LLM config from api.env */}}
{{- range $key, $value := .Values.api.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- /* Worker-specific env vars */}}
{{- range $key, $value := .Values.worker.env }}
{{- /* Explicitly set port to override K8s service discovery env var (HINDSIGHT_API_PORT) */}}
- name: HINDSIGHT_API_PORT
value: {{ .Values.worker.service.targetPort | quote }}
{{- /* Inherit LLM config from api.env, then apply worker-specific env.
Merge (worker.env wins) so a key set in both does not emit a
duplicate env entry, which server-side apply rejects. */}}
{{- range $key, $value := merge (deepCopy (.Values.worker.env | default dict)) (.Values.api.env | default dict) }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
+15 -7
View File
@@ -13,9 +13,6 @@
# - Any other env vars you want to inject
# existingSecret: "my-hindsight-secret"
# Global settings
replicaCount: 1
# Image settings for api
api:
enabled: true
@@ -39,16 +36,22 @@ api:
cpu: 500m
memory: 1Gi
# Liveness and readiness probes
# Liveness and readiness probes.
# Liveness uses /health/live, which performs no database access: a slow or
# unreachable database must gate traffic (readiness), never restart pods.
# Needs an image from this chart's appVersion or newer — older ones serve
# /health only, and would fail this probe with a 404.
livenessProbe:
httpGet:
path: /health
path: /health/live
port: 8888
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# Readiness checks the database, so a pod that cannot reach it is pulled out
# of the Service and put back once the database recovers.
readinessProbe:
httpGet:
path: /health
@@ -134,10 +137,15 @@ worker:
cpu: 500m
memory: 1Gi
# Liveness and readiness probes
# Liveness and readiness probes.
# Liveness uses /health/live, which performs no database access. Restarting a
# worker whose database is merely slow requeues its claimed operations with
# retry_count incremented, so DB checks must stay out of liveness.
# Needs an image from this chart's appVersion or newer — older ones serve
# /health only, and would fail this probe with a 404.
livenessProbe:
httpGet:
path: /health
path: /health/live
port: 8889
initialDelaySeconds: 30
periodSeconds: 10
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.8.0",
"version": "0.9.0",
"description": "Node.js programmatic lifecycle manager for Hindsight — embeds a local hindsight daemon in a Node application. Pair with @vectorize-io/hindsight-client for memory operations.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+5 -4
View File
@@ -1,17 +1,18 @@
[build-system]
requires = ["setuptools>=61"]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "hindsight-all-slim"
version = "0.8.0"
version = "0.9.0"
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
license = "MIT"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim==0.8.0",
"hindsight-api-slim==0.9.0",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
"hindsight-embed==0.9.0",
]
[tool.uv.sources]
+6 -5
View File
@@ -1,17 +1,18 @@
[build-system]
requires = ["hatchling"]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.8.0"
version = "0.9.0"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
license = "MIT"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim[all]==0.8.0",
"hindsight-api-slim[all]==0.9.0",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
"hindsight-embed==0.9.0",
]
[tool.uv.sources]
@@ -21,7 +22,7 @@ hindsight-embed = { workspace = true }
[project.optional-dependencies]
local-llm = [
"hindsight-api-slim[local-llm]==0.8.0",
"hindsight-api-slim[local-llm]==0.9.0",
]
test = [
"pytest>=7.0.0",
+1 -1
View File
@@ -121,7 +121,7 @@ This runs a stdio-based MCP server that can be used directly with MCP-compatible
- **Entity Graph** — Automatic entity extraction and relationship tracking
- **Temporal Reasoning** — Native support for time-based queries
- **Disposition Traits** — Configurable skepticism, literalism, and empathy influence opinion formation
- **Three Memory Types** — World facts, bank actions, and formed opinions with confidence scores
- **Three Memory Types** — World facts, experience facts (the bank's own actions), and observations
## Documentation
+1 -1
View File
@@ -53,4 +53,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.8.0"
__version__ = "0.9.0"
@@ -0,0 +1,23 @@
"""Text-search SQL shapes shared by index DDL and the queries that must hit it.
A PostgreSQL expression index is only selectable when the query repeats the
indexed expression verbatim, so the DDL (``migrations.py`` and the Alembic
versions) and the read arms (``engine/sql/postgresql.py``) cannot be allowed to
drift. Both sides call the helpers here — same idea as
``_pg_search.pg_search_bm25_columns``.
"""
def mental_models_text_document(alias: str | None = None) -> str:
"""The ``mental_models`` full-text document: model/page name + content.
Mirrors the generating expression of the native tsvector column created by
the ``n9i0j1k2l3m4`` (learnings / pinned_reflections) migration, so every
backend indexes and queries the exact same document. ``content`` is NOT NULL,
hence the deliberate lack of a ``COALESCE`` around it.
``alias`` qualifies the columns for queries that join the table (``mm``);
leave it unset for DDL, where the expression is already table-scoped.
"""
prefix = f"{alias}." if alias else ""
return f"(COALESCE({prefix}name, '') || ' ' || {prefix}content)"
@@ -54,23 +54,20 @@ _INDEX_TYPE_KEYWORDS = {
# pre-dispatcher code (internal benchmarks tuned around our embedding count
# and recall floor; see the link_utils / pool init call sites for the
# latency-vs-recall framing).
# - vchord exposes vchordrq.probes (no default; see VectorChord issue #392)
# and vchordrq.epsilon (default 1.9). probes = 10 / 30 are starting
# defaults pending a workload-specific sweep — vchordrq's recall curve
# shape differs from HNSW's, so the pgvector numbers don't translate
# directly. Revisit with a per-cluster benchmark once we have production
# recall data; until then these are deliberately conservative on the
# high-recall path. We leave epsilon at its default; tightening it is a
# separate trade-off.
# - vchord exposes vchordrq.probes, but its shape must match the index's
# build.internal.lists hierarchy. VectorChord 1.1 added per-index fallback
# parameters for this reason: a session GUC overrides every vchordrq index,
# and a single value can be invalid for listless or mixed-layout indexes.
# Hindsight's built-in vchord clause does not set lists, so the safe default
# is no session-level probe override; deployments that partition vchordrq
# indexes should attach probes to the index storage parameters instead.
# - pgvectorscale / pg_diskann / scann do not expose an equivalent per-statement
# knob in the engine today, so the dispatcher returns no statements for them.
_ANN_TUNING_LOW_LATENCY: dict[str, tuple[tuple[str, str], ...]] = {
"pgvector": (("hnsw.ef_search", "60"),),
"vchord": (("vchordrq.probes", "10"),),
}
_ANN_TUNING_HIGH_RECALL: dict[str, tuple[tuple[str, str], ...]] = {
"pgvector": (("hnsw.ef_search", "200"),),
"vchord": (("vchordrq.probes", "30"),),
}
_EXTENSION_INSTALL_SQL = {
+451 -63
View File
@@ -8,7 +8,9 @@ import asyncio
import io
import json
import logging
import struct
import zipfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -16,10 +18,12 @@ from typing import Any
import asyncpg
import typer
from ..config import DEFAULT_DATABASE_SCHEMA, HindsightConfig
from ..config import DEFAULT_DATABASE_SCHEMA, HindsightConfig, load_dotenv_for_entrypoint
from ..engine.memory_engine import _current_schema
from ..engine.retain.bank_utils import _vector_index_clause
from ..engine.schema import fq_table_explicit as _fq_table
from ..engine.transfer import export_bank
from ..engine.vector_index_health import SchemaVectorIndexResult, repair_vector_indexes
from ..extensions import TenantExtension, load_extension
from ..pg0 import parse_pg0_url, resolve_database_url
@@ -49,12 +53,14 @@ BACKUP_TABLES = [
"entities",
"chunks",
"memory_units",
"invalidated_memory_units",
"unit_entities",
"entity_cooccurrences",
"memory_links",
"observation_history",
"mental_models",
"mental_model_history",
"knowledge_pages",
"directives",
"async_operations",
"webhooks",
@@ -64,7 +70,173 @@ BACKUP_TABLES = [
"graph_maintenance_queue",
]
MANIFEST_VERSION = "1"
MANIFEST_VERSION = "2"
@dataclass(frozen=True)
class BackupColumn:
"""A PostgreSQL column shape required to decode a binary COPY stream."""
name: str
type_name: str
@dataclass(frozen=True)
class TableRestorePlan:
"""How one table's backed-up binary COPY stream is replayed onto the target.
``columns`` is the target column list handed to ``copy_to_table``, in stream
order. When the target no longer has a backed-up column, its field is stripped
from every tuple (``dropped_field_indices``) before the stream is replayed —
binary COPY is positional, so the column list and the tuple fields must agree.
"""
columns: list[str]
dropped_field_indices: tuple[int, ...]
source_field_count: int
# Header of a PostgreSQL binary COPY stream: an 11-byte signature, an int32 flags
# field, and an int32 header-extension length followed by that many bytes.
_COPY_BINARY_SIGNATURE = b"PGCOPY\n\xff\r\n\x00"
_COPY_BINARY_HEADER_LEN = len(_COPY_BINARY_SIGNATURE) + 8
def _strip_binary_copy_fields(data: bytes, plan: TableRestorePlan) -> bytes:
"""Drop `plan.dropped_field_indices` from every tuple of a binary COPY stream.
Restore used to reject a backup whose columns the target no longer had — the
preflight raised "target is missing backup columns …", which made any backup
taken before a column-dropping migration unrestorable afterwards. Those columns
are now ignored instead, but they cannot simply be left out of the
``copy_to_table`` column list: binary COPY carries no column identities, so each
tuple's fields are matched to the column list purely by position and an unedited
stream would desynchronise (or, worse, land values in the wrong columns). So the
stream itself is rewritten here.
Tuple format: int16 field count, then per field an int32 length (-1 for NULL)
followed by that many bytes. An int16 of -1 is the end-of-data trailer.
"""
if not plan.dropped_field_indices:
return data
if not data.startswith(_COPY_BINARY_SIGNATURE):
raise ValueError("Backup stream is not in PostgreSQL binary COPY format")
(extension_len,) = struct.unpack_from("!i", data, len(_COPY_BINARY_SIGNATURE) + 4)
pos = _COPY_BINARY_HEADER_LEN + extension_len
out = bytearray(data[:pos])
dropped = set(plan.dropped_field_indices)
kept_count = plan.source_field_count - len(dropped)
while True:
(field_count,) = struct.unpack_from("!h", data, pos)
pos += 2
if field_count == -1: # end-of-data trailer
out += struct.pack("!h", -1)
break
if field_count != plan.source_field_count:
raise ValueError(
f"Backup stream tuple has {field_count} fields, manifest declares {plan.source_field_count}"
)
out += struct.pack("!h", kept_count)
for index in range(field_count):
(length,) = struct.unpack_from("!i", data, pos)
pos += 4
payload = b"" if length == -1 else data[pos : pos + length]
pos += max(length, 0)
if index in dropped:
continue
out += struct.pack("!i", length)
out += payload
return bytes(out)
async def _table_columns(conn: asyncpg.Connection, schema: str, table: str) -> list[BackupColumn]:
rows = await conn.fetch(
"""
SELECT a.attname AS name, pg_catalog.format_type(a.atttypid, a.atttypmod) AS type_name
FROM pg_catalog.pg_attribute AS a
JOIN pg_catalog.pg_class AS c ON c.oid = a.attrelid
JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname = $1 AND c.relname = $2 AND a.attnum > 0 AND NOT a.attisdropped
AND a.attgenerated = ''
ORDER BY a.attnum
""",
schema,
table,
)
return [BackupColumn(name=row["name"], type_name=row["type_name"]) for row in rows]
async def _validate_restore_schema(
conn: asyncpg.Connection, manifest: dict[str, Any], schema: str
) -> dict[str, TableRestorePlan]:
"""Validate every COPY stream against the target before destructive work starts.
A column the target no longer has is **not** an error: a migration that drops a
column would otherwise make every backup taken before it permanently
unrestorable. Such columns are skipped (their fields are stripped from the
stream by ``_strip_binary_copy_fields``) and reported, so the operator sees what
was discarded instead of the restore failing outright.
Type mismatches remain fatal. Type equality is an exact ``format_type`` string
match. This is deliberately stricter than binary-COPY wire compatibility (e.g.
``varchar`` and ``text`` share a binary format yet compare unequal here): we
would rather fail a genuinely-restorable backup with a clear, actionable error
than silently risk a subtle binary mismatch. Restores blocked this way can be
recovered by aligning the target schema.
"""
plans: dict[str, TableRestorePlan] = {}
errors: list[str] = []
for table, table_manifest in manifest["tables"].items():
source_columns = [BackupColumn(**column) for column in table_manifest["columns"]]
target_by_name = {column.name: column for column in await _table_columns(conn, schema, table)}
unknown = [
(index, column.name) for index, column in enumerate(source_columns) if column.name not in target_by_name
]
mismatched = [
f"{column.name} ({column.type_name} in backup, {target_by_name[column.name].type_name} in target)"
for column in source_columns
if column.name in target_by_name and target_by_name[column.name].type_name != column.type_name
]
if mismatched:
errors.append(f"{table}: incompatible column types: {', '.join(mismatched)}")
if unknown:
typer.echo(
f" {table}: ignoring {len(unknown)} backup column(s) absent from the target schema: "
f"{', '.join(name for _, name in unknown)}"
)
plans[table] = TableRestorePlan(
columns=[column.name for column in source_columns if column.name in target_by_name],
dropped_field_indices=tuple(index for index, _ in unknown),
source_field_count=len(source_columns),
)
if errors:
details = "; ".join(errors)
raise ValueError(f"Backup schema is incompatible with target schema '{schema}': {details}")
return plans
def _effective_backup_tables() -> list[str]:
"""Core backup tables plus any bank-scoped tables a loaded extension declares.
``BACKUP_TABLES`` covers only the tables core owns. An extension that
provisions its own bank-scoped tables (via ``TenantExtension``) declares
them through ``extra_bank_tables()`` so they aren't dropped on restore.
Extension tables are appended *after* the core set so restore's forward
COPY inserts them after their FK parents (e.g. ``banks``) and the reversed
TRUNCATE clears them before those parents.
"""
tables = list(BACKUP_TABLES)
tenant_extension = load_extension("TENANT", TenantExtension)
if tenant_extension is not None:
seen = set(tables)
for spec in tenant_extension.extra_bank_tables():
if spec.include_in_backup and spec.name not in seen:
tables.append(spec.name)
seen.add(spec.name)
return tables
async def _admin_connect(db_url: str) -> asyncpg.Connection:
@@ -75,7 +247,8 @@ async def _admin_connect(db_url: str) -> asyncpg.Connection:
is the only step needed to connect. JSON codecs are registered so ``jsonb``
columns decode to Python objects (used by the export row dumps).
"""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
_pg0 = parse_pg0_url(db_url)
is_pg0, instance_name = _pg0.is_pg0, _pg0.instance_name
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
conn = await asyncpg.connect(await resolve_database_url(db_url))
@@ -84,8 +257,18 @@ async def _admin_connect(db_url: str) -> asyncpg.Connection:
return conn
async def _backup(database_url: str, output_path: Path, schema: str = "public") -> dict[str, Any]:
"""Backup all tables to a zip file using binary COPY protocol."""
async def _backup(
database_url: str,
output_path: Path,
schema: str = "public",
backup_tables: list[str] | None = None,
) -> dict[str, Any]:
"""Backup all tables to a zip file using binary COPY protocol.
``backup_tables`` defaults to the core ``BACKUP_TABLES``; callers pass the
extension-augmented list from ``_effective_backup_tables()``.
"""
backup_tables = backup_tables if backup_tables is not None else BACKUP_TABLES
conn = await asyncpg.connect(database_url)
try:
tables: dict[str, Any] = {}
@@ -102,14 +285,24 @@ async def _backup(database_url: str, output_path: Path, schema: str = "public")
# entities table was backed up.
async with conn.transaction(isolation="repeatable_read"):
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
for i, table in enumerate(BACKUP_TABLES, 1):
typer.echo(f" [{i}/{len(BACKUP_TABLES)}] Backing up {table}...", nl=False)
for i, table in enumerate(backup_tables, 1):
typer.echo(f" [{i}/{len(backup_tables)}] Backing up {table}...", nl=False)
buffer = io.BytesIO()
# Use binary COPY for exact type preservation
columns = await _table_columns(conn, schema, table)
# Pin the ordered columns into both the stream and manifest.
# PostgreSQL binary COPY does not encode column identities, so
# restore must validate this shape before truncating any data.
# asyncpg requires schema_name as separate parameter
await conn.copy_from_table(table, schema_name=schema, output=buffer, format="binary")
await conn.copy_from_table(
table,
schema_name=schema,
columns=[column.name for column in columns],
output=buffer,
format="binary",
)
data = buffer.getvalue()
zf.writestr(f"{table}.bin", data)
@@ -120,6 +313,7 @@ async def _backup(database_url: str, output_path: Path, schema: str = "public")
tables[table] = {
"rows": row_count,
"size_bytes": len(data),
"columns": [{"name": column.name, "type_name": column.type_name} for column in columns],
}
typer.echo(f" {row_count} rows")
@@ -131,8 +325,20 @@ async def _backup(database_url: str, output_path: Path, schema: str = "public")
await conn.close()
async def _restore(database_url: str, input_path: Path, schema: str = "public") -> dict[str, Any]:
"""Restore all tables from a zip file using binary COPY protocol."""
async def _restore(
database_url: str,
input_path: Path,
schema: str = "public",
backup_tables: list[str] | None = None,
) -> dict[str, Any]:
"""Restore all tables from a zip file using binary COPY protocol.
``backup_tables`` defaults to the core ``BACKUP_TABLES``; callers pass the
extension-augmented list from ``_effective_backup_tables()``. Tables named
here but absent from the archive are truncated then skipped for restore, so
a stale extension registration never leaves pre-restore rows behind.
"""
backup_tables = backup_tables if backup_tables is not None else BACKUP_TABLES
conn = await asyncpg.connect(database_url)
try:
with zipfile.ZipFile(input_path, "r") as zf:
@@ -141,29 +347,42 @@ async def _restore(database_url: str, input_path: Path, schema: str = "public")
if manifest.get("version") != MANIFEST_VERSION:
raise ValueError(f"Unsupported backup version: {manifest.get('version')}")
# Complete the compatibility check before entering the transaction
# that truncates tables. This turns historical schema drift into an
# actionable error without risking the target's existing data.
restore_plans = await _validate_restore_schema(conn, manifest, schema)
# Use a transaction for atomic restore - either all tables are
# restored or none are, preventing partial/inconsistent state.
async with conn.transaction():
typer.echo(" Clearing existing data...")
# Truncate tables in reverse order (respects FK constraints)
for table in reversed(BACKUP_TABLES):
for table in reversed(backup_tables):
qualified_table = _fq_table(table, schema)
await conn.execute(f"TRUNCATE TABLE {qualified_table} CASCADE")
# Restore tables in forward order
for i, table in enumerate(BACKUP_TABLES, 1):
for i, table in enumerate(backup_tables, 1):
filename = f"{table}.bin"
if filename not in zf.namelist():
typer.echo(f" [{i}/{len(BACKUP_TABLES)}] {table}: skipped (not in backup)")
typer.echo(f" [{i}/{len(backup_tables)}] {table}: skipped (not in backup)")
continue
expected_rows = manifest["tables"].get(table, {}).get("rows", "?")
typer.echo(f" [{i}/{len(BACKUP_TABLES)}] Restoring {table}... {expected_rows} rows")
typer.echo(f" [{i}/{len(backup_tables)}] Restoring {table}... {expected_rows} rows")
data = zf.read(filename)
buffer = io.BytesIO(data)
plan = restore_plans[table]
# Strips the fields of any column the target no longer has;
# a no-op when the schemas still line up.
buffer = io.BytesIO(_strip_binary_copy_fields(zf.read(filename), plan))
# asyncpg requires schema_name as separate parameter
await conn.copy_to_table(table, schema_name=schema, source=buffer, format="binary")
await conn.copy_to_table(
table,
schema_name=schema,
columns=plan.columns,
source=buffer,
format="binary",
)
# Refresh materialized view
typer.echo(" Refreshing materialized views...")
@@ -176,20 +395,22 @@ async def _restore(database_url: str, input_path: Path, schema: str = "public")
async def _run_backup(db_url: str, output: Path, schema: str = "public") -> dict[str, Any]:
"""Resolve database URL and run backup."""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
_pg0 = parse_pg0_url(db_url)
is_pg0, instance_name = _pg0.is_pg0, _pg0.instance_name
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
return await _backup(resolved_url, output, schema)
return await _backup(resolved_url, output, schema, backup_tables=_effective_backup_tables())
async def _run_restore(db_url: str, input_file: Path, schema: str = "public") -> dict[str, Any]:
"""Resolve database URL and run restore."""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
_pg0 = parse_pg0_url(db_url)
is_pg0, instance_name = _pg0.is_pg0, _pg0.instance_name
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
return await _restore(resolved_url, input_file, schema)
return await _restore(resolved_url, input_file, schema, backup_tables=_effective_backup_tables())
@app.command()
@@ -213,7 +434,7 @@ def backup(
manifest = asyncio.run(_run_backup(config.database_url, output, schema))
total_rows = sum(t["rows"] for t in manifest["tables"].values())
typer.echo(f"Backed up {total_rows} rows across {len(BACKUP_TABLES)} tables")
typer.echo(f"Backed up {total_rows} rows across {len(manifest['tables'])} tables")
typer.echo(f"Backup saved to {output}")
@@ -246,7 +467,7 @@ def restore(
manifest = asyncio.run(_run_restore(config.database_url, input_file, schema))
total_rows = sum(t["rows"] for t in manifest["tables"].values())
typer.echo(f"Restored {total_rows} rows across {len(BACKUP_TABLES)} tables")
typer.echo(f"Restored {total_rows} rows across {len(manifest['tables'])} tables")
typer.echo("Restore complete")
@@ -255,26 +476,22 @@ async def _run_migration(
schema: str | None = None,
base_schema: str = DEFAULT_DATABASE_SCHEMA,
embedding_dimension: int | None = None,
ensure_extensions: bool = True,
) -> list[str]:
"""Resolve database URL and run migrations for one schema or all discovered schemas."""
from ..migrations import (
ensure_embedding_dimension,
ensure_text_search_extension,
ensure_vector_extension,
run_migrations,
)
from ..migrations import run_migrations_for_schemas
is_pg0, instance_name, _ = parse_pg0_url(db_url)
_pg0 = parse_pg0_url(db_url)
is_pg0, instance_name = _pg0.is_pg0, _pg0.instance_name
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
config = HindsightConfig.from_env()
tenant_extension = load_extension("TENANT", TenantExtension)
if schema:
schemas = [schema]
else:
tenant_extension = load_extension("TENANT", TenantExtension)
schemas = [base_schema or DEFAULT_DATABASE_SCHEMA]
if tenant_extension:
tenants = await tenant_extension.list_tenants()
@@ -283,36 +500,52 @@ async def _run_migration(
# Preserve order while removing duplicates.
schemas = list(dict.fromkeys(schemas))
for schema in schemas:
run_migrations(resolved_url, schema=schema, migration_database_url=config.migration_database_url)
# Migrate up to `migration_concurrency` schemas at once (each in its own
# process); within a schema the work stays sequential. Run off the event
# loop so the process pool's blocking joins don't stall it.
await asyncio.to_thread(
run_migrations_for_schemas,
resolved_url,
schemas,
concurrency=config.migration_concurrency,
migration_database_url=config.migration_database_url,
embedding_dimension=embedding_dimension,
vector_extension=config.vector_extension,
text_search_extension=config.text_search_extension,
pg_search_tokenizer=config.text_search_extension_pg_search_tokenizer,
ensure_extensions=ensure_extensions,
)
if embedding_dimension is not None:
for schema in schemas:
ensure_embedding_dimension(
resolved_url,
embedding_dimension,
schema=schema,
vector_extension=config.vector_extension,
)
for schema in schemas:
ensure_vector_extension(
resolved_url,
vector_extension=config.vector_extension,
schema=schema,
)
for schema in schemas:
ensure_text_search_extension(
resolved_url,
text_search_extension=config.text_search_extension,
pg_search_tokenizer=config.text_search_extension_pg_search_tokenizer,
schema=schema,
)
# After core migrations, provision any extension-owned bank-scoped tables
# per schema so extension schema evolves on the same lifecycle as core
# schema (rather than via a lazy first-request path).
if tenant_extension is not None:
await _provision_extra_bank_tables(resolved_url, schemas, tenant_extension)
return schemas
async def _provision_extra_bank_tables(
resolved_url: str, schemas: list[str], tenant_extension: TenantExtension
) -> None:
"""Run the tenant extension's table provisioner for each migrated schema.
Fires after core migrations complete so extension-owned bank tables are
created/evolved on the same lifecycle as core schema. A failure aborts the
migration command (and names the offending schema) rather than being
swallowed — provisioning is idempotent, so the operator can fix and re-run.
"""
for schema in schemas:
conn = await asyncpg.connect(resolved_url)
try:
await tenant_extension.provision_bank_tables(conn, schema)
except Exception as e:
typer.echo(f" Failed to provision extension tables for schema '{schema}': {e}", err=True)
raise
finally:
await conn.close()
@app.command(name="run-db-migration")
def run_db_migration(
schema: str | None = typer.Option(
@@ -326,6 +559,18 @@ def run_db_migration(
"--embedding-dimension",
help="Expected embedding dimension to enforce after migrations. Omit to skip dimension sync.",
),
skip_extension_reconcile: bool = typer.Option(
False,
"--skip-extension-reconcile",
help=(
"Skip the post-migration vector / text-search index reconcile. This step only does "
"work when the configured backend (HINDSIGHT_API_VECTOR_EXTENSION / "
"HINDSIGHT_API_TEXT_SEARCH_EXTENSION) differs from a schema's existing indexes — a "
"rare, operator-driven change. Skipping it makes a no-change re-migration over many "
"tenant schemas much faster. Only use when you have NOT changed the backend; a "
"backend change still needs a normal run to reshape the indexes."
),
),
):
"""Run database migrations to the latest version."""
config = HindsightConfig.from_env()
@@ -339,6 +584,8 @@ def run_db_migration(
typer.echo(f"Running database migrations for schema: {schema}...")
else:
typer.echo("Running database migrations for base schema and all discovered tenant schemas...")
if skip_extension_reconcile:
typer.echo("Skipping post-migration extension reconcile (--skip-extension-reconcile).")
schemas = asyncio.run(
_run_migration(
@@ -346,12 +593,141 @@ def run_db_migration(
schema=schema,
base_schema=config.database_schema,
embedding_dimension=embedding_dimension,
ensure_extensions=not skip_extension_reconcile,
)
)
typer.echo(f"Database migrations completed successfully for {len(schemas)} schema(s)")
async def _resolve_schemas(base_schema: str | None) -> list[str]:
"""Base schema plus every discovered tenant schema, de-duplicated in order."""
schemas = [base_schema or DEFAULT_DATABASE_SCHEMA]
tenant_extension = load_extension("TENANT", TenantExtension)
if tenant_extension:
tenants = await tenant_extension.list_tenants()
schemas.extend(tenant.schema for tenant in tenants if tenant.schema)
return list(dict.fromkeys(schemas))
async def _run_repair_bank(
db_url: str,
*,
base_schema: str,
schema: str | None,
bank_id: str | None,
dry_run: bool,
) -> list[SchemaVectorIndexResult]:
"""Reconcile per-(bank, fact_type) vector index coverage over a raw connection.
A single autocommit connection is used because ``CREATE INDEX CONCURRENTLY``
(used by ``repair_vector_indexes``) cannot run inside a transaction block.
"""
schemas = [schema] if schema else await _resolve_schemas(base_schema)
index_clause = _vector_index_clause()
# Guarded by the command, but assert so this helper is never called for a
# backend without per-bank indexes.
assert index_clause is not None
conn = await _admin_connect(db_url)
try:
results = await repair_vector_indexes(conn, schemas, index_clause, dry_run=dry_run, bank_id=bank_id)
for result in results:
typer.echo(
f" schema '{result.schema}': {result.banks_scanned} bank(s) scanned, "
f"{result.already_present} present, {result.created} created, "
f"{result.skipped} to-create (dry-run), {result.failed} failed"
)
return results
finally:
await conn.close()
@app.command(name="repair-bank")
def repair_bank(
bank_id: str | None = typer.Option(
None,
"--bank",
"-b",
help="Bank id to repair. Mutually exclusive with --all.",
),
all_banks: bool = typer.Option(
False,
"--all",
help="Repair every bank in the base schema and all discovered tenant schemas.",
),
schema: str | None = typer.Option(
None,
"--schema",
"-s",
help="Limit to a single schema. Defaults to the base schema plus discovered tenant schemas.",
),
dry_run: bool = typer.Option(
False,
"--dry-run",
help="Report what would be repaired without creating or dropping any index.",
),
):
"""Verify and repair a bank's per-(bank, fact_type) vector index coverage.
Per-bank partial vector indexes are created when a bank is first created
(instant on an empty bank). Banks that arrive populated — via logical
restore, a cross-version upgrade, or a vector-extension switch — never hit
that path, so their recall silently falls back to a global index +
post-filter (slower, under-returning). This command detects missing OR
invalid coverage (an INVALID leftover or an index whose access method
drifted counts as missing) and rebuilds it with CREATE INDEX CONCURRENTLY,
so it never blocks the live fleet. Idempotent and safe to re-run — the
escape hatch after a restore, upgrade, or backend switch.
"""
if bool(bank_id) == all_banks:
typer.echo("Error: pass exactly one of --bank <id> or --all.", err=True)
raise typer.Exit(2)
config = HindsightConfig.from_env()
if not config.database_url:
typer.echo("Error: Database URL not configured.", err=True)
typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True)
raise typer.Exit(1)
# Backend guard: backends with a single global vector index (AlloyDB ScaNN,
# Oracle) have no per-bank indexes to repair.
if _vector_index_clause() is None:
typer.echo("Configured vector backend does not use per-bank vector indexes — nothing to repair.")
return
target = f"bank '{bank_id}'" if bank_id else "all banks"
scope = f"schema '{schema}'" if schema else "base schema and all discovered tenant schemas"
typer.echo(f"Repairing per-bank vector indexes for {target} across {scope}...")
if dry_run:
typer.echo("Dry run: no indexes will be created or dropped.")
results = asyncio.run(
_run_repair_bank(
config.database_url,
base_schema=config.database_schema,
schema=schema,
bank_id=bank_id,
dry_run=dry_run,
)
)
total_banks = sum(r.banks_scanned for r in results)
total_present = sum(r.already_present for r in results)
total_created = sum(r.created for r in results)
total_skipped = sum(r.skipped for r in results)
total_failed = sum(r.failed for r in results)
typer.echo(
f"Done: {len(results)} schema(s), {total_banks} bank(s) scanned, "
f"{total_present} already present, {total_created} created, "
f"{total_skipped} to-create (dry-run), {total_failed} failed"
)
if total_failed:
failed_names = [name for r in results for name in r.failed_indexes]
typer.echo(f"Failed indexes (dropped, retry with a re-run): {', '.join(failed_names)}", err=True)
raise typer.Exit(1)
async def _run_export_bank(db_url: str, bank_id: str, output: Path, schema: str, include_history: bool) -> int:
"""Export a whole bank to a ZIP archive."""
conn = await _admin_connect(db_url)
@@ -359,7 +735,14 @@ async def _run_export_bank(db_url: str, bank_id: str, output: Path, schema: str,
# export_bank resolves table names via fq_table (the _current_schema
# contextvar); set it so the raw connection targets the right schema.
_current_schema.set(schema)
data = await export_bank(conn, bank_id, include_history=include_history)
# _admin_connect registers JSON codecs, so row dumps already contain
# decoded Python values (including JSON scalar strings).
data = await export_bank(
conn,
bank_id,
include_history=include_history,
bank_rows_json_encoding="decoded",
)
finally:
await conn.close()
@@ -464,14 +847,16 @@ def import_bank_command(
f"Imported bank '{result.bank_id}': {result.documents_imported} doc(s), "
f"{result.facts_imported} fact(s), {result.observations_imported} observation(s), "
f"{result.mental_models_imported} mental model(s), "
f"{result.mental_model_history_imported} mm-history row(s), {result.directives_imported} directive(s), "
f"{result.mental_model_history_imported} mm-history row(s), "
f"{result.knowledge_pages_imported} knowledge page(s), {result.directives_imported} directive(s), "
f"{result.webhooks_imported} webhook(s), {result.history_rows_imported} history row(s)"
)
async def _decommission_worker(db_url: str, worker_id: str, schema: str = "public") -> int:
"""Release all tasks owned by a worker, setting them back to pending status."""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
_pg0 = parse_pg0_url(db_url)
is_pg0, instance_name = _pg0.is_pg0, _pg0.instance_name
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
@@ -530,7 +915,8 @@ def decommission_worker(
async def _decommission_all_workers(db_url: str, schema: str = "public") -> list[dict[str, Any]]:
"""Release all processing tasks from all workers, setting them back to pending status."""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
_pg0 = parse_pg0_url(db_url)
is_pg0, instance_name = _pg0.is_pg0, _pg0.instance_name
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
@@ -595,7 +981,8 @@ def decommission_workers(
async def _worker_status(db_url: str, schema: str = "public") -> list[dict[str, Any]]:
"""Get all processing tasks grouped by worker with their last update time."""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
_pg0 = parse_pg0_url(db_url)
is_pg0, instance_name = _pg0.is_pg0, _pg0.instance_name
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
@@ -663,6 +1050,7 @@ def worker_status(
def main():
load_dotenv_for_entrypoint()
app()
@@ -96,7 +96,8 @@ def get_database_url() -> str:
# for the sync engine used during migrations.
database_url = to_libpq_url(database_url)
config.set_main_option("sqlalchemy.url", database_url)
# Alembic stores options through ConfigParser, where '%' is interpolation.
config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
return database_url
@@ -0,0 +1,105 @@
"""Add a composite index on memory_links(bank_id, link_type) (PostgreSQL).
``bank_id`` was added to ``memory_links`` in ``c5d6e7f8a9b0`` precisely so that
bank-scoped reads (e.g. the stats endpoint) could filter on the link table
directly instead of joining ``memory_units`` — that JOIN took 18+ seconds on
banks with millions of links. The column landed without an index, so every
``bank_id = $1`` predicate still falls back to a sequential scan over the whole
table.
This adds the missing btree. It is composite on ``(bank_id, link_type)`` rather
than ``bank_id`` alone because the hot query is the stats endpoint's
``SELECT link_type, COUNT(*) ... WHERE bank_id = $1 GROUP BY link_type``: a
``(bank_id, link_type)`` index serves that filter, grouping and count as an
index-only scan, never touching the heap, whereas a ``bank_id``-only index would
still have to read every matching row to recover ``link_type``. ``link_type`` is
low-cardinality (only ``temporal``/``semantic``/``caused_by`` are written —
entity edges were dropped in ``e9b2c7d1f3a4``), so the trailing column adds
little to the index size while removing the heap fetch.
The Oracle baseline (``o1a2b3c4d5e6``) already creates ``idx_ml_bank_id`` on
``memory_links(bank_id)``; that single-column index already covers Oracle's
bank-scoped filter, so the Oracle slot here is intentionally absent and only the
PostgreSQL dialect gets the composite index.
``memory_links`` can hold tens of millions of rows, so the index is built
CONCURRENTLY to avoid taking a write lock on the table. CONCURRENTLY cannot run
inside a transaction block, so the statement runs in an ``autocommit_block()``;
``IF NOT EXISTS`` keeps it idempotent across retries and re-migrated tenant
schemas. A CONCURRENTLY build interrupted partway (lock conflict, disk
pressure, signal) leaves the index behind as *invalid*; ``IF NOT EXISTS`` would
then skip over it forever, so the upgrade first drops any invalid leftover of
this name before (re)creating it.
Revision ID: 2071c7518f88
Revises: a1d3f5b7c9e2
Create Date: 2026-06-16
"""
from collections.abc import Sequence
from alembic import context, op
from sqlalchemy import text
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "2071c7518f88"
down_revision: str | Sequence[str] | None = "a1d3f5b7c9e2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_INDEX_NAME = "idx_memory_links_bank_id_link_type"
def _get_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
bind = op.get_bind()
# `or None` collapses an unset option and an explicit empty string into NULL
# so the COALESCE below falls back to current_schema() in both cases.
target_schema = context.config.get_main_option("target_schema") or None
schema = _get_schema_prefix()
# CREATE INDEX CONCURRENTLY cannot run inside a transaction block; the
# autocommit_block runs each statement outside Alembic's migration
# transaction.
with op.get_context().autocommit_block():
# A CONCURRENTLY build that errored on a previous run leaves an INVALID
# index of this name behind. `CREATE INDEX ... IF NOT EXISTS` would see
# that relation and skip, so bank_id queries would keep seq-scanning.
# Drop only the invalid leftover — never a healthy index — so the retry
# actually rebuilds a usable one.
leftover_invalid = bind.execute(
text(
"SELECT NOT i.indisvalid "
"FROM pg_class c "
"JOIN pg_index i ON c.oid = i.indexrelid "
"JOIN pg_namespace n ON c.relnamespace = n.oid "
"WHERE c.relname = :index_name "
" AND n.nspname = COALESCE(:target_schema, current_schema())"
),
{"index_name": _INDEX_NAME, "target_schema": target_schema},
).scalar()
if leftover_invalid:
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}{_INDEX_NAME}")
# IF NOT EXISTS keeps the create idempotent across retries and schemas.
op.execute(f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {_INDEX_NAME} ON {schema}memory_links(bank_id, link_type)")
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
with op.get_context().autocommit_block():
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}{_INDEX_NAME}")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,67 @@
"""Drop observation_history's FK to memory_units.
The history table records one snapshot per observation change, keyed by
``(bank_id, observation_id)``. Its foreign key to ``memory_units`` existed only to
cascade-delete history when the observation row went away.
That assumes every observation *is* a ``memory_units`` row, which is true only
while Postgres is the memories store. When another store owns the memories the
observation lives there and Postgres holds no row for it, so every history insert
raises a foreign-key violation — swallowed by the writer as "a race with parallel
consolidation" and logged at warning level. The audit trail goes silently empty.
Dropping the constraint lets history be recorded wherever the observation is
stored. The cleanup the cascade used to do is now explicit, in the paths that
delete observations (``_execute_delete_action``, ``clear_observations``,
``delete_bank``). Rows orphaned by a path that misses — a document delete
cascading through ``memory_units``, for instance — are invisible to readers,
which always filter by ``(bank_id, observation_id)``, and are reclaimed when the
bank is deleted.
Oracle builds this schema through its own DDL runner and never had the
constraint, so the Oracle slot is a deliberate no-op.
Revision ID: a1c9e7f3b2d8
Revises: c7d1e9a4b3f2
"""
from collections.abc import Sequence
from alembic import op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a1c9e7f3b2d8"
down_revision: str | Sequence[str] | None = "c7d1e9a4b3f2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_CONSTRAINT = "observation_history_observation_id_fkey"
def _pg_upgrade() -> None:
op.execute(f"ALTER TABLE observation_history DROP CONSTRAINT IF EXISTS {_CONSTRAINT}")
def _pg_downgrade() -> None:
# Re-adding the FK requires every row to reference a live memory_unit, so
# clear any history whose observation is not a Postgres row first — those are
# exactly the rows this migration made possible.
op.execute(
"DELETE FROM observation_history h "
"WHERE NOT EXISTS (SELECT 1 FROM memory_units m WHERE m.id = h.observation_id)"
)
op.execute(
f"ALTER TABLE observation_history ADD CONSTRAINT {_CONSTRAINT} "
"FOREIGN KEY (observation_id) REFERENCES memory_units(id) ON DELETE CASCADE"
)
def upgrade() -> None:
# Oracle never had the constraint (its schema is built by a separate DDL
# runner), so only Postgres has anything to drop.
run_for_dialect(pg=_pg_upgrade, oracle=None)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=None)
@@ -0,0 +1,85 @@
"""Repair: widen the remaining live ``bank_id`` columns from VARCHAR(64) to TEXT on PostgreSQL.
Follow-up to ``c3e5a7b9d1f4`` (issue #2106), which widened the two *history*
tables (``observation_history``, ``mental_model_history``) to ``TEXT`` after the
narrow ``VARCHAR(64)`` declaration bricked startup. The same VARCHAR(64) / TEXT
inconsistency still affects the live tables that store a user-supplied
``bank_id``:
* ``directives`` -- created VARCHAR(64) in ``p1k2l3m4n5o6``
* ``mental_models`` -- VARCHAR(64) (origin ``pinned_reflections`` in
``n9i0j1k2l3m4``; recreated in ``h3c4d5e6f7g8``)
``mental_model_versions`` is intentionally *not* widened here: it is created in
``j5e6f7g8h9i0`` but dropped (``DROP TABLE ... CASCADE``) in ``o0j1k2l3m4n5`` and
never recreated on the upgrade path, so it does not exist at head. Issuing
``ALTER TABLE mental_model_versions ...`` would raise ``UndefinedTable`` and --
because migrations run inside the lifespan-startup transaction -- roll the whole
migration back, bricking the API. (It is unrelated to the live
``mental_model_history`` table widened by ``c3e5a7b9d1f4``.)
``banks.bank_id`` is ``TEXT`` (unbounded), so a deployment can create a bank
whose id exceeds 64 chars -- the 78-char hierarchical org-unit shape reported in
issue #2106 -- and the bank insert succeeds. The next write that propagates that
id (``create_directive``, ``create_mental_model`` / consolidation, or
mental-model versioning) then aborts with::
psycopg2.errors.StringDataRightTruncation: value too long for type
character varying(64)
i.e. a 500 on core write endpoints, instead of the startup brick that
``c3e5a7b9d1f4`` already repaired.
``ALTER COLUMN ... TYPE TEXT`` is a no-op on a column that is already ``TEXT``,
so every upgrade path converges on ``TEXT``. These tables are per-tenant (they
live in each tenant schema, not ``public``), so this runs for every migrated
schema via the search-path-aware prefix -- the same mechanism as
``c3e5a7b9d1f4``.
PostgreSQL only: these tables are created by PostgreSQL-only migrations
(``run_for_dialect(pg=...)``); on Oracle they are absent or already
``VARCHAR2(256)`` (consistent, never truncates), so the Oracle slot is
intentionally absent -- mirroring ``c3e5a7b9d1f4``.
Revision ID: a1d3f5b7c9e2
Revises: c3e5a7b9d1f4
Create Date: 2026-06-13
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a1d3f5b7c9e2"
down_revision: str | Sequence[str] | None = "c3e5a7b9d1f4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}directives ALTER COLUMN bank_id TYPE TEXT")
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN bank_id TYPE TEXT")
def _pg_downgrade() -> None:
# No-op: narrowing back to VARCHAR(64) could truncate real data and would
# re-introduce the bug this migration repairs. The column types are owned by
# the migrations that created the tables.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -62,7 +62,7 @@ def _pg_upgrade() -> None:
CREATE TABLE IF NOT EXISTS {schema}mental_model_history (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
mental_model_id VARCHAR(64) NOT NULL,
bank_id VARCHAR(64) NOT NULL,
bank_id TEXT NOT NULL,
content JSONB NOT NULL,
changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
FOREIGN KEY (mental_model_id, bank_id)
@@ -83,7 +83,7 @@ def _pg_upgrade() -> None:
CREATE TABLE IF NOT EXISTS {schema}observation_history (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
observation_id UUID NOT NULL,
bank_id VARCHAR(64) NOT NULL,
bank_id TEXT NOT NULL,
content JSONB NOT NULL,
changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
FOREIGN KEY (observation_id)
@@ -0,0 +1,82 @@
"""Add indexes for terminal cleanup and newest-first operation listing.
Revision ID: a8c1e4f7b0d3
Revises: e7c3a9f1b2d5
Create Date: 2026-07-14
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a8c1e4f7b0d3"
down_revision: str | Sequence[str] | None = "e7c3a9f1b2d5"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for PostgreSQL multi-tenant migration runs."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# These can be large tables in long-running installations. Concurrent DDL
# keeps operation submission, polling, and status reads available.
with op.get_context().autocommit_block():
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_async_operations_terminal_cleanup "
f"ON {schema}async_operations (updated_at, operation_id) "
"WHERE status IN ('completed', 'failed', 'cancelled')"
)
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_async_operations_bank_created_desc "
f"ON {schema}async_operations (bank_id, created_at DESC)"
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
with op.get_context().autocommit_block():
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_async_operations_bank_created_desc")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_async_operations_terminal_cleanup")
def _oracle_create_index(sql: str) -> None:
"""Create an index idempotently for rerun-safe Oracle migrations."""
block = (
"BEGIN "
"EXECUTE IMMEDIATE :stmt; "
"EXCEPTION WHEN OTHERS THEN "
"IF SQLCODE = -955 THEN NULL; ELSE RAISE; END IF; "
"END;"
)
op.get_bind().exec_driver_sql(block, {"stmt": sql})
def _oracle_upgrade() -> None:
# Oracle migrations run with CURRENT_SCHEMA set to each tenant, so table
# and index names intentionally remain unqualified here.
_oracle_create_index(
"CREATE INDEX idx_async_operations_terminal_cleanup ON async_operations (updated_at, operation_id, status)"
)
_oracle_create_index(
"CREATE INDEX idx_async_operations_bank_created_desc ON async_operations (bank_id, created_at DESC)"
)
def _oracle_downgrade() -> None:
op.execute("DROP INDEX idx_async_operations_bank_created_desc")
op.execute("DROP INDEX idx_async_operations_terminal_cleanup")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -0,0 +1,126 @@
"""Add knowledge_pages table (knowledge-base hierarchy).
The knowledge base organizes synthesized mental models into a navigable tree of
**folders** and **pages**. A page references the mental model that holds its
content (``mental_model_id``); a folder is a pure container (``mental_model_id``
NULL). Hierarchy is a single self-referential ``parent_id`` so folders can nest
arbitrarily. Content stays in ``mental_models`` — this table is metadata + tree
structure only.
``managed`` lets a client tag a node as system-owned vs. hand-authored; it
carries no server-side behaviour. A partial unique index keeps page names unique
within a folder (case-insensitive; root pages compared under an empty parent).
Revision ID: a9b8c7d6e5f4
Revises: a1c9e7f3b2d8
Create Date: 2026-06-25
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a9b8c7d6e5f4"
down_revision: str | Sequence[str] | None = "a1c9e7f3b2d8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# parent_id self-FK cascades so deleting a folder row removes its whole
# subtree of rows in one shot. The mental_model FK is composite (matches the
# mental_models (id, bank_id) PK) and cascades too, so deleting a page's
# mental model removes the page row — folders skip the FK because a NULL
# column in a composite FK is not enforced (MATCH SIMPLE).
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}knowledge_pages (
id VARCHAR(64) NOT NULL,
bank_id TEXT NOT NULL,
parent_id VARCHAR(64),
kind VARCHAR(16) NOT NULL,
name TEXT NOT NULL,
mental_model_id VARCHAR(64),
sort_order INTEGER NOT NULL DEFAULT 0,
managed BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT pk_knowledge_pages PRIMARY KEY (id),
CONSTRAINT ck_knowledge_pages_kind CHECK (kind IN ('folder', 'page')),
CONSTRAINT fk_kp_bank FOREIGN KEY (bank_id)
REFERENCES {schema}banks(bank_id) ON DELETE CASCADE,
CONSTRAINT fk_kp_parent FOREIGN KEY (parent_id)
REFERENCES {schema}knowledge_pages(id) ON DELETE CASCADE,
CONSTRAINT fk_kp_mm FOREIGN KEY (mental_model_id, bank_id)
REFERENCES {schema}mental_models(id, bank_id) ON DELETE CASCADE
)
"""
)
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_kp_bank_parent ON {schema}knowledge_pages (bank_id, parent_id, sort_order)"
)
# COALESCE(parent_id, '') so root-level pages (NULL parent) are also unique by
# name — NULLs would otherwise compare distinct and allow duplicates.
op.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_kp_folder_pagename "
f"ON {schema}knowledge_pages (bank_id, COALESCE(parent_id, ''), lower(name)) "
"WHERE kind = 'page'"
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}uq_kp_folder_pagename")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_kp_bank_parent")
op.execute(f"DROP TABLE IF EXISTS {schema}knowledge_pages")
def _oracle_upgrade() -> None:
# No case-insensitive unique index on Oracle: `name` is a CLOB and cannot be
# indexed with lower(); page-name uniqueness is enforced on PG only.
op.execute(
"""
CREATE TABLE IF NOT EXISTS knowledge_pages (
id VARCHAR2(64) NOT NULL,
bank_id VARCHAR2(256) NOT NULL,
parent_id VARCHAR2(64),
kind VARCHAR2(16) NOT NULL,
name CLOB NOT NULL,
mental_model_id VARCHAR2(64),
sort_order NUMBER DEFAULT 0 NOT NULL,
managed NUMBER(1) DEFAULT 0 NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_knowledge_pages PRIMARY KEY (id),
CONSTRAINT ck_knowledge_pages_kind CHECK (kind IN ('folder', 'page')),
CONSTRAINT fk_kp_bank FOREIGN KEY (bank_id)
REFERENCES banks(bank_id) ON DELETE CASCADE,
CONSTRAINT fk_kp_parent FOREIGN KEY (parent_id)
REFERENCES knowledge_pages(id) ON DELETE CASCADE,
CONSTRAINT fk_kp_mm FOREIGN KEY (mental_model_id, bank_id)
REFERENCES mental_models(id, bank_id) ON DELETE CASCADE
)
"""
)
op.execute("CREATE INDEX idx_kp_bank_parent ON knowledge_pages (bank_id, parent_id, sort_order)")
def _oracle_downgrade() -> None:
op.execute("DROP TABLE knowledge_pages CASCADE CONSTRAINTS")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -0,0 +1,213 @@
"""Add entities.entity_kind and exclude label entities from the trigram index.
Label entities (values of ``entity_labels`` config groups, stored as
``key:value`` canonical names) resolve by exact match only — fuzzy resolution
must never merge distinct label values (#1558), and since #3187 they are looked
up via the exact-match unique index rather than probed through pg_trgm. Their
rows were still covered by the shared trigram index, so every fuzzy probe for a
*regular* entity name pulled them into its candidate set only to discard them
in the bitmap recheck. On banks where a free-text label group accumulated tens
of thousands of mutually-similar values this recheck-discard overhead dominated
database CPU under ingest bursts (#3208).
"Is this row a label" was previously derived at runtime from the bank's
``entity_labels`` config, which an index predicate cannot reference — so the
classification is now materialised on the row:
1. Add ``entity_kind`` ("regular"/"label", CHECK-constrained) on both dialects.
A kind column rather than a boolean so future entity kinds don't need
another column.
2. Backfill per bank by classifying ``canonical_name`` against the bank's
``entity_labels`` config with the same ``is_label_entity()`` the resolver
uses at insert time — a SQL reimplementation would be a second source of
truth (and the map-group recursion doesn't translate). Banks hold at most
tens of thousands of entities, so the synchronous per-bank backfill is fine.
Label configs supplied only by a tenant extension (not stored in
``banks.config``) can't be seen here; their rows stay "regular", which
costs index size but never correctness — label *texts* still resolve via
the exact-match unique index.
3. Rebuild the PG trigram index as a partial index excluding label rows.
Built CONCURRENTLY (autocommit block, invalid-leftover sweep, IF NOT
EXISTS — same shape as 2071c7518f88) and only then drop the old full
index, so fuzzy probes never lose index coverage. Skipped entirely when
pg_trgm is absent (the resolver falls back to the "full" strategy, #626).
Oracle has no trigram index — it fuzzy-matches with a UTL_MATCH scan — so it
only gets the column + backfill; the resolver adds the matching
``entity_kind != 'label'`` filter to that scan.
Revision ID: b3e8d1c6f4a9
Revises: f2a6d8c4b1e9
Create Date: 2026-08-06
"""
import json
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "b3e8d1c6f4a9"
down_revision: str | Sequence[str] | None = "f2a6d8c4b1e9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_OLD_INDEX = "entities_canonical_name_lower_trgm_idx"
_NEW_INDEX = "entities_canonical_name_lower_trgm_nonlabel_idx"
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _backfill_entity_kind(schema: str) -> None:
"""Set entity_kind='label' on rows matching their bank's entity_labels config.
Runs the resolver's own classification (``is_label_entity``) per bank in
Python rather than reimplementing the enum/text/map prefix rules in SQL.
Shared by both dialects: plain SELECT/UPDATE with expanding IN binds.
"""
from hindsight_api.engine.retain.entity_labels import (
build_labels_lookup,
is_label_entity,
parse_entity_labels,
)
bind = op.get_bind()
banks = bind.execute(sa.text(f"SELECT bank_id, config FROM {schema}banks")).fetchall()
for bank_id, raw_config in banks:
# PG JSONB arrives as a dict; Oracle CLOB arrives as a LOB object on
# raw text() fetches (oracledb's fetch_lobs default) — read it into a
# JSON string first.
if raw_config is not None and not isinstance(raw_config, (str, dict)):
raw_config = raw_config.read()
config = json.loads(raw_config) if isinstance(raw_config, str) else (raw_config or {})
labels_cfg = parse_entity_labels(config.get("entity_labels"))
if labels_cfg is None:
continue
lookup = build_labels_lookup(labels_cfg)
rows = bind.execute(
sa.text(f"SELECT id, canonical_name FROM {schema}entities WHERE bank_id = :bank_id"),
{"bank_id": bank_id},
).fetchall()
label_ids = [entity_id for entity_id, name in rows if is_label_entity(name, labels_cfg, lookup)]
# Chunked to stay under Oracle's 1000-element IN limit; also keeps PG
# bind arrays bounded.
for start in range(0, len(label_ids), 500):
chunk = label_ids[start : start + 500]
stmt = sa.text(f"UPDATE {schema}entities SET entity_kind = 'label' WHERE id IN :ids").bindparams(
sa.bindparam("ids", expanding=True)
)
bind.execute(stmt, {"ids": chunk})
def _pg_upgrade() -> None:
bind = op.get_bind()
schema = _pg_schema_prefix()
# `or None` collapses an unset option and an explicit empty string into NULL
# so the COALESCE below falls back to current_schema() in both cases.
target_schema = context.config.get_main_option("target_schema") or None
# IF NOT EXISTS: the transactional part below commits when the autocommit
# block is entered, so a failure during the CONCURRENTLY build leaves the
# revision unstamped with the column already added — the retry must not
# trip over it. The constant default is a metadata-only change on PG 11+.
op.execute(
f"ALTER TABLE {schema}entities ADD COLUMN IF NOT EXISTS entity_kind TEXT DEFAULT 'regular' NOT NULL "
f"CONSTRAINT chk_entities_entity_kind CHECK (entity_kind IN ('regular', 'label'))"
)
_backfill_entity_kind(schema)
# Without pg_trgm neither the old index nor the extension's opclass exists;
# the resolver already runs the "full" strategy there (#626).
has_trgm = bind.execute(sa.text("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')")).scalar()
if not has_trgm:
return
# CREATE INDEX CONCURRENTLY cannot run inside a transaction block; the
# autocommit_block runs each statement outside Alembic's migration
# transaction. Build the partial index first and drop the old full index
# only afterwards, so fuzzy probes never lose index coverage.
with op.get_context().autocommit_block():
# A CONCURRENTLY build that errored on a previous run leaves an INVALID
# index of this name behind, which IF NOT EXISTS would skip forever.
leftover_invalid = bind.execute(
sa.text(
"SELECT NOT i.indisvalid "
"FROM pg_class c "
"JOIN pg_index i ON c.oid = i.indexrelid "
"JOIN pg_namespace n ON c.relnamespace = n.oid "
"WHERE c.relname = :index_name "
" AND n.nspname = COALESCE(:target_schema, current_schema())"
),
{"index_name": _NEW_INDEX, "target_schema": target_schema},
).scalar()
if leftover_invalid:
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}{_NEW_INDEX}")
# The predicate must textually match the resolver's candidate query
# (`entity_kind != 'label'`) for the planner to choose this index.
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {_NEW_INDEX} "
f"ON {schema}entities USING GIN (LOWER(canonical_name) gin_trgm_ops) "
f"WHERE entity_kind != 'label'"
)
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}{_OLD_INDEX}")
def _pg_downgrade() -> None:
bind = op.get_bind()
schema = _pg_schema_prefix()
has_trgm = bind.execute(sa.text("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')")).scalar()
if has_trgm:
# Restore the full index before dropping the partial one so fuzzy
# probes keep index coverage throughout.
with op.get_context().autocommit_block():
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {_OLD_INDEX} "
f"ON {schema}entities USING GIN (LOWER(canonical_name) gin_trgm_ops)"
)
# Dropping the column also drops the partial index and CHECK constraint.
op.execute(f"ALTER TABLE {schema}entities DROP COLUMN IF EXISTS entity_kind")
def _oracle_upgrade() -> None:
# Swallow ORA-01430 (column already exists) so a retry after a mid-run
# failure is idempotent — Oracle DDL auto-commits statement by statement.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE entities ADD (entity_kind VARCHAR2(16) DEFAULT ''regular'' NOT NULL
CONSTRAINT chk_entities_entity_kind CHECK (entity_kind IN (''regular'', ''label'')))';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -1430 THEN RAISE; END IF;
END;
"""
)
_backfill_entity_kind("")
def _oracle_downgrade() -> None:
# Swallow ORA-00904 (column does not exist).
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE entities DROP COLUMN entity_kind';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -904 THEN RAISE; END IF;
END;
"""
)
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -0,0 +1,61 @@
"""Add bank_stats_cache table for distributed get_bank_stats caching
Revision ID: b57a7c9e0d13
Revises: c3f7a1b9d2e4
Create Date: 2026-07-01
get_bank_stats aggregates over memory_links / unit_entities — a multi-second scan
on banks with millions of rows. The result was cached per-process (in-memory), so
every API worker recomputed it once per TTL and the first caller after expiry
stalled. This table backs a shared, cross-process TTL cache: one worker's compute
is written here and served to all the others.
PostgreSQL only. Oracle keeps the in-process cache (the runtime picks the backing
store by dialect), so the Oracle upgrade slot is intentionally absent.
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "b57a7c9e0d13"
down_revision: str | Sequence[str] | None = "c3f7a1b9d2e4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
# One row per bank: payload is the full get_bank_stats result, computed_at
# drives logical TTL expiry. Rows are overwritten in place (ON CONFLICT), so
# the table never grows beyond the number of banks and needs no purge job.
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}bank_stats_cache (
bank_id TEXT PRIMARY KEY,
payload JSONB NOT NULL,
computed_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP TABLE IF EXISTS {schema}bank_stats_cache")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade) # oracle slot intentionally absent → no-op
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,259 @@
"""Install the maintenance discovery routines into the configured schema.
The three discovery routines driving the background maintenance loop —
``banks_needing_consolidation()``, ``schemas_with_expired_rows(...)`` and
``mental_models_with_cron()`` — were installed into ``public`` and gated on the
run being the base run (no ``target_schema``) or an explicit
``target_schema='public'`` run (``e5f6a7b8c9d0`` → ``b2d4f6a8c1e3`` →
``c7e9f1a3b5d2``, ``f4d1c2b3a5e6``).
That leaves a **single-tenant deployment migrated into a dedicated, non-**
``public`` **schema** (``HINDSIGHT_API_DATABASE_SCHEMA=<non-public>``) with no
routines at all: the runtime migrates only that one schema, so ``target_schema``
is never falsy or ``public``, the gate never opens, and the maintenance loop
logs, forever::
function public.banks_needing_consolidation() does not exist
function public.schemas_with_expired_rows(...) does not exist
The revision is stamped applied, so redeploying the same version does not help
(issue #2638; #2056 only fixed the ``public``/base-run case).
**The bug was the hardcoded literal, not the gating.** These routines are
database-global — each enumerates ``pg_class`` across every schema and dispatches
per schema — so exactly one copy should exist, and the maintenance loop calls the
one in ``get_config().database_schema`` (see ``fq_routine``). The old gate
installed into whichever schema was named ``public`` instead of whichever schema
the deployment is actually configured to use. Comparing ``target_schema`` against
the configured schema instead of the literal fixes #2638 at the source.
That also keeps the property the gate existed for: exactly one migration run
satisfies the predicate, so concurrent per-schema runs never issue competing
``CREATE OR REPLACE`` against the same ``pg_proc`` row and cannot hit
``tuple concurrently updated``. No cross-process coordination is required — in
particular no advisory lock, which is unusable here because Hindsight runs behind
connection poolers and managed PG services (see #2817).
Runs targeting any *other* schema drop the routines from that schema rather than
merely skipping. An earlier revision of this migration installed a copy into
every schema it touched, which left one dead duplicate per tenant on any database
that ran it; the drop makes the next migration pass clean those up instead of
leaving them behind forever.
PostgreSQL only: the maintenance loop and worker poller are PG-only, so the
Oracle slot is intentionally absent (mirrors ``e5f6a7b8c9d0``).
Revision ID: b6d2f8a4c1e7
Revises: a8c1e4f7b0d3
Create Date: 2026-07-20
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
from hindsight_api.config import get_config
revision: str = "b6d2f8a4c1e7"
down_revision: str | Sequence[str] | None = "a8c1e4f7b0d3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _configured_schema() -> str:
"""The one schema this deployment's routines live in and are called from."""
return get_config().database_schema or "public"
def _target_schema() -> str | None:
return context.config.get_main_option("target_schema")
def _is_install_run() -> bool:
"""True for the single run that owns the routines.
The base run (no ``target_schema``) and the run targeting the configured
schema are the same deployment-level run; every other target is a tenant
schema that must not carry its own copy.
"""
target = _target_schema()
return not target or target == _configured_schema()
def _prefix(schema: str | None) -> str:
"""Qualifier for ``schema``, or ``""`` to fall back to ``search_path``."""
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
if not _is_install_run():
_drop_stray_copies()
return
schema = _prefix(_target_schema())
op.execute(
f"""
CREATE OR REPLACE FUNCTION {schema}banks_needing_consolidation()
RETURNS TABLE(schema_name text, bank_id text)
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
BEGIN
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'memory_units' AND c.relkind = 'r'
LOOP
BEGIN
RETURN QUERY EXECUTE format($q$
SELECT %1$L::text, m.bank_id
FROM %1$I.memory_units m
JOIN %1$I.banks b ON b.bank_id = m.bank_id
WHERE m.consolidated_at IS NULL
AND m.consolidation_failed_at IS NULL
AND m.fact_type IN ('experience', 'world')
AND COALESCE(b.config -> 'enable_auto_consolidation', 'true'::jsonb) <> 'false'::jsonb
AND NOT EXISTS (
SELECT 1 FROM %1$I.async_operations o
WHERE o.bank_id = m.bank_id
AND o.operation_type = 'consolidation'
AND o.status IN ('pending', 'processing')
)
GROUP BY m.bank_id
$q$, sch);
EXCEPTION
-- Schema or its tables vanished between the pg_class
-- snapshot and this query (tenant dropped or migrating).
WHEN undefined_table OR invalid_schema_name OR undefined_column THEN
CONTINUE;
END;
END LOOP;
END;
$fn$;
"""
)
op.execute(
f"""
CREATE OR REPLACE FUNCTION {schema}schemas_with_expired_rows(
p_table text, p_ts_col text, p_days int
)
RETURNS SETOF text
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
has_expired boolean;
BEGIN
IF p_days IS NULL OR p_days <= 0 THEN
RETURN;
END IF;
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = p_table AND c.relkind = 'r'
LOOP
BEGIN
EXECUTE format(
'SELECT EXISTS (SELECT 1 FROM %I.%I WHERE %I < NOW() - make_interval(days => $1))',
sch, p_table, p_ts_col
) INTO has_expired USING p_days;
EXCEPTION
-- Schema or its table vanished mid-scan; skip it.
WHEN undefined_table OR invalid_schema_name OR undefined_column THEN
CONTINUE;
END;
IF has_expired THEN
RETURN NEXT sch;
END IF;
END LOOP;
END;
$fn$;
"""
)
op.execute(
f"""
CREATE OR REPLACE FUNCTION {schema}mental_models_with_cron()
RETURNS TABLE(schema_name text, bank_id text, mental_model_id text,
refresh_cron text, last_refreshed_at timestamptz)
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
BEGIN
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'mental_models' AND c.relkind = 'r'
LOOP
BEGIN
RETURN QUERY EXECUTE format($q$
SELECT %1$L::text, mm.bank_id::text, mm.id::text,
mm.trigger->>'refresh_cron', mm.last_refreshed_at
FROM %1$I.mental_models mm
WHERE COALESCE(mm.trigger->>'refresh_cron', '') <> ''
AND NOT EXISTS (
SELECT 1 FROM %1$I.async_operations o
WHERE o.bank_id = mm.bank_id
AND o.operation_type = 'refresh_mental_model'
AND o.status IN ('pending', 'processing')
AND o.task_payload->>'mental_model_id' = mm.id::text
)
$q$, sch);
EXCEPTION
-- Schema or its tables vanished between the pg_class
-- snapshot and this query (tenant dropped or migrating).
WHEN undefined_table OR invalid_schema_name OR undefined_column THEN
CONTINUE;
END;
END LOOP;
END;
$fn$;
"""
)
def _drop_routines(schema: str | None) -> None:
prefix = _prefix(schema)
op.execute(f"DROP FUNCTION IF EXISTS {prefix}mental_models_with_cron()")
op.execute(f"DROP FUNCTION IF EXISTS {prefix}schemas_with_expired_rows(text, text, int)")
op.execute(f"DROP FUNCTION IF EXISTS {prefix}banks_needing_consolidation()")
def _drop_stray_copies() -> None:
"""Remove per-tenant duplicates left by the first cut of this migration.
That version installed a copy into every schema it touched, so a database
that ran it carries one dead duplicate per tenant — only the copy in the
configured schema is ever called. Dropping here means the next migration pass
cleans them up; without it they would persist for the life of the database.
Safe on a database that never had them: ``DROP FUNCTION IF EXISTS`` is a
no-op, and this branch never runs for the configured schema.
"""
_drop_routines(_target_schema())
def _pg_downgrade() -> None:
# Only drop what this migration uniquely owns. When the configured schema is
# ``public`` the copies there belong to e5f6a7b8c9d0 / f4d1c2b3a5e6, which are
# still applied at this point and drop them on their own downgrade — removing
# them here would strand those migrations without the functions they claim to
# have installed.
if not _is_install_run() or _configured_schema() == "public":
return
_drop_routines(_target_schema())
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,75 @@
"""Repair: widen ``*_history.bank_id`` from VARCHAR(64) to TEXT on PostgreSQL.
The original split-history migration (``a7b8c9d0e1f2``) declared
``observation_history.bank_id`` and ``mental_model_history.bank_id`` as
``VARCHAR(64)`` on PostgreSQL. But ``memory_units.bank_id`` — the backfill
source for observations — is ``TEXT`` (unbounded), as are ``banks``,
``documents`` and ``entities``. Any deployment whose ``bank_id`` exceeds 64
characters aborts the backfill ``INSERT`` with::
psycopg2.errors.StringDataRightTruncation: value too long for type
character varying(64)
Because the migration runs in ``lifespan`` startup inside a transaction, the
whole migration rolls back and the API never comes up — unrecoverable from the
running container. See https://github.com/vectorize-io/hindsight/issues/2106.
``a7b8c9d0e1f2`` itself has been corrected to create the column as ``TEXT``,
which unblocks deployments that *failed* (the migration rolled back, so it
re-runs the fixed DDL). This forward migration covers deployments that already
*succeeded* with the narrow ``VARCHAR(64)`` column — where editing
``a7b8c9d0e1f2`` has no effect because it will not re-run — by widening the
column in place. ``ALTER COLUMN ... TYPE TEXT`` is a no-op on a column that is
already ``TEXT`` (fresh installs and re-run failures), so every upgrade path
converges on ``TEXT``.
The history tables are per-tenant (they live in each tenant schema, not
``public``), so this runs for every migrated schema via the search-path-aware
prefix — unlike the shared-``public`` routines repaired in ``b2d4f6a8c1e3``.
PostgreSQL only. On Oracle both ``memory_units.bank_id`` and the history
``bank_id`` columns are already ``VARCHAR2(256)`` (consistent, never
truncates), so the Oracle slot is intentionally absent.
Revision ID: c3e5a7b9d1f4
Revises: c9a1b2d3e4f5
Create Date: 2026-06-10
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c3e5a7b9d1f4"
down_revision: str | Sequence[str] | None = "c9a1b2d3e4f5"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}observation_history ALTER COLUMN bank_id TYPE TEXT")
op.execute(f"ALTER TABLE {schema}mental_model_history ALTER COLUMN bank_id TYPE TEXT")
def _pg_downgrade() -> None:
# No-op: narrowing back to VARCHAR(64) could truncate real data and would
# re-introduce the bug this migration repairs. The column type is owned by
# ``a7b8c9d0e1f2``'s lifecycle.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,144 @@
"""Backfill search_vector for native-backend observations.
Observations created or updated by the consolidator landed with a NULL
``search_vector`` under the ``native`` text-search backend: the
single-row INSERT/UPDATE paths in ``consolidator.py`` never populated the
tsvector (only the batch raw-fact path in ``ops_postgresql.insert_facts_batch``
did). Those observations were therefore invisible to the BM25 retrieval arm
until they were re-written by a later consolidation pass. The writer is fixed
in the same change set (all four consolidator sites now call
``to_tsvector($lang, COALESCE(text, ''))``); this migration repairs the
historical residue so existing observations become BM25-searchable without a
re-ingest.
Scope mirrors the writer fix exactly:
* Only the ``native`` backend is touched. The gate is the column *type*:
under ``native`` ``search_vector`` is a regular (non-generated) tsvector
column; under ``vchord`` it is a ``bm25vector`` and under
``pg_textsearch`` / ``pgroonga`` / ``pg_search`` it is a dummy ``text``
column. ``_is_regular_tsvector`` is true only for ``native``, so every
other backend is a no-op.
* The tsvector is built from the observation's own ``text`` only — matching
the consolidator INSERT/UPDATE paths (entity / source / temporal signals
are intentionally excluded; the other retrieval arms cover those).
* Only ``fact_type = 'observation'`` rows with a NULL ``search_vector`` are
rewritten. Raw facts already carry a populated tsvector, and the
``IS NULL`` predicate makes the migration idempotent and re-runnable.
The configured ``HINDSIGHT_API_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE`` is used
so backfilled rows are lexically identical to newly-created observations. The
value is validated as a PG identifier (mirroring
``HindsightConfig.validate``) before being embedded as a SQL literal.
This is a single UPDATE per schema: it locks the targeted observation rows for
its duration. It is one-time and only touches unpopulated rows, so subsequent
online writes (which now carry the tsvector via the writer fix) are unaffected.
Oracle slot is intentionally absent: the consolidator INSERT/UPDATE paths that
this repairs are PostgreSQL-specific (``ops_postgresql``), and the native
tsvector ``search_vector`` column only exists on PostgreSQL. There is no Oracle
residue to repair.
Revision ID: c3f7a1b9d2e4
Revises: f4d1c2b3a5e6
Create Date: 2026-06-29
"""
import os
import re
from collections.abc import Sequence
from alembic import context, op
from sqlalchemy import Connection, text
from hindsight_api.alembic._dialect import run_for_dialect
from hindsight_api.config import (
DEFAULT_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE,
ENV_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE,
)
revision: str = "c3f7a1b9d2e4"
down_revision: str | Sequence[str] | None = "f4d1c2b3a5e6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# Matches HindsightConfig.validate(): a tsvector regconfig name embedded as a
# SQL literal must be a bare PG identifier.
_PG_IDENTIFIER = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*")
def _schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _schema_name() -> str:
return (context.config.get_main_option("target_schema") or "public").strip('"')
def _native_language() -> str:
"""Configured native tsvector language, validated as a PG identifier."""
lang = os.getenv(
ENV_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE,
DEFAULT_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE,
)
if not _PG_IDENTIFIER.fullmatch(lang):
return DEFAULT_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE
return lang
def _is_regular_tsvector(conn: Connection, schema: str, table: str) -> bool:
"""True iff ``schema.table.search_vector`` is a non-generated tsvector column.
This is the ``native`` backend signature. ``vchord`` (bm25vector) and
``pg_textsearch`` / ``pgroonga`` / ``pg_search`` (dummy text column) all
fail this check, so the backfill is a no-op for them.
"""
row = conn.execute(
text(
"""
SELECT is_generated, udt_name
FROM information_schema.columns
WHERE table_schema = :schema
AND table_name = :table
AND column_name = 'search_vector'
"""
),
{"schema": schema, "table": table},
).fetchone()
if not row:
return False
is_generated, udt_name = row[0], row[1]
return udt_name == "tsvector" and is_generated != "ALWAYS"
def _pg_upgrade() -> None:
conn = op.get_bind()
schema_name = _schema_name()
if not _is_regular_tsvector(conn, schema_name, "memory_units"):
# Non-native backend (or column absent) — nothing to backfill.
return
schema_prefix = _schema_prefix()
lang = _native_language()
op.execute(
f"""
UPDATE {schema_prefix}memory_units
SET search_vector = to_tsvector('{lang}'::regconfig, COALESCE(text, ''))
WHERE fact_type = 'observation' AND search_vector IS NULL
"""
)
def _pg_downgrade() -> None:
# No-op: backfilled rows are indistinguishable from observations that were
# populated by the post-fix writer, and reverting either to NULL would
# re-break BM25 retrieval. The column simply stays populated.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,90 @@
"""Add ``causal_links`` to the curation archive (invalidated_memory_units).
Causal edges (``caused_by`` and the historical ``causes``/``enables``/
``prevents``) are retain-time extraction output: unlike temporal and semantic
links they cannot be recomputed from dates or embeddings, and graph maintenance
never rebuilds them. Invalidation MOVES a fact out of ``memory_units``, so the
``memory_links → memory_units`` FK cascade deletes every incident edge — and
revert had no way to bring the causal ones back (#2864).
This column parks the descriptors of the causal edges incident to an archived
fact — ``[{"from_unit_id", "to_unit_id", "link_type", "weight"}, ...]`` — so
revert can rematerialize them. It is deliberately unindexed and lives only on
the archive: live facts keep their causal edges in ``memory_links`` (curation
edits no longer delete them), and the archive is small, cold, and only read by
low-frequency curation operations.
Revision ID: c7d1e9a4b3f2
Revises: d7b2f8a1c934
Create Date: 2026-07-24
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c7d1e9a4b3f2"
down_revision: str | Sequence[str] | None = "d7b2f8a1c934"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# NOT NULL DEFAULT is metadata-only on PG 11+, so this is cheap even on a
# large archive. Existing rows read as "no causal edges captured" — edges
# lost before this migration cannot be reconstructed and are not guessed.
op.execute(
f"ALTER TABLE {schema}invalidated_memory_units "
f"ADD COLUMN IF NOT EXISTS causal_links JSONB NOT NULL DEFAULT '[]'::jsonb"
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS causal_links")
def _oracle_upgrade() -> None:
# Kept in sync with PG for schema parity (curation itself is PostgreSQL-only
# today — it introspects pg_attribute to move rows between the two tables).
# Swallow ORA-01430 (column already exists) so the migration is idempotent.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units ADD (causal_links CLOB DEFAULT ''[]''
CONSTRAINT imu_causal_links_json CHECK (causal_links IS JSON))';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -1430 THEN RAISE; END IF;
END;
"""
)
def _oracle_downgrade() -> None:
# Swallow ORA-00904 (column does not exist).
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units DROP COLUMN causal_links';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -904 THEN RAISE; END IF;
END;
"""
)
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -0,0 +1,158 @@
"""Make maintenance routines resilient to schemas that vanish mid-scan.
``public.banks_needing_consolidation()`` and
``public.schemas_with_expired_rows(...)`` snapshot the set of schemas owning a
target table from ``pg_class`` and then run a dynamic query against each schema
in turn. That is a time-of-check/time-of-use race: a schema (or its tables) can
be dropped — a tenant being deleted, or a tenant migration that recreates
tables — between the snapshot and the per-schema query, which then aborts the
whole routine with::
relation "<schema>.memory_units" does not exist
relation "<schema>.audit_log" does not exist
In the test suite this surfaces as cross-worker contamination: the multi-tenant
maintenance test creates and drops ~100 ``mt<hash>_NNN`` schemas while
``test_maintenance_routines`` (on another xdist worker, same DB) calls the
routines. In production the background maintenance loop hits the same race when
a tenant is removed or mid-migration.
Wrap each per-schema query in its own ``BEGIN ... EXCEPTION`` block so a schema
that disappears (``undefined_table`` / ``invalid_schema_name`` /
``undefined_column``) is skipped instead of aborting the scan. The routines stay
``CREATE OR REPLACE`` and PostgreSQL-only, and are (re)installed only on the run
that targets the shared ``public`` schema — same gating as the original
install (``e5f6a7b8c9d0``) and its repair (``b2d4f6a8c1e3``).
Revision ID: c7e9f1a3b5d2
Revises: e1f2a3b4c5d6
Create Date: 2026-06-19
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c7e9f1a3b5d2"
down_revision: str | Sequence[str] | None = "e1f2a3b4c5d6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _should_install_public_routines(target_schema: str | None) -> bool:
"""True for the run that must (re)create the shared ``public.*`` routines.
The routines physically live in ``public``, so they are installed exactly
once — on the base run (no ``target_schema``) or the run that explicitly
targets ``public``. Mirrors ``b2d4f6a8c1e3``.
"""
return not target_schema or target_schema == "public"
def _pg_upgrade() -> None:
if not _should_install_public_routines(context.config.get_main_option("target_schema")):
return
# Same body as b2d4f6a8c1e3, but each per-schema query runs in its own
# subtransaction so a schema dropped mid-scan is skipped, not fatal.
op.execute(
"""
CREATE OR REPLACE FUNCTION public.banks_needing_consolidation()
RETURNS TABLE(schema_name text, bank_id text)
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
BEGIN
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'memory_units' AND c.relkind = 'r'
LOOP
BEGIN
RETURN QUERY EXECUTE format($q$
SELECT %1$L::text, m.bank_id
FROM %1$I.memory_units m
JOIN %1$I.banks b ON b.bank_id = m.bank_id
WHERE m.consolidated_at IS NULL
AND m.consolidation_failed_at IS NULL
AND m.fact_type IN ('experience', 'world')
AND COALESCE(b.config -> 'enable_auto_consolidation', 'true'::jsonb) <> 'false'::jsonb
AND NOT EXISTS (
SELECT 1 FROM %1$I.async_operations o
WHERE o.bank_id = m.bank_id
AND o.operation_type = 'consolidation'
AND o.status IN ('pending', 'processing')
)
GROUP BY m.bank_id
$q$, sch);
EXCEPTION
-- Schema or its tables vanished between the pg_class
-- snapshot and this query (tenant dropped or migrating).
WHEN undefined_table OR invalid_schema_name OR undefined_column THEN
CONTINUE;
END;
END LOOP;
END;
$fn$;
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION public.schemas_with_expired_rows(
p_table text, p_ts_col text, p_days int
)
RETURNS SETOF text
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
has_expired boolean;
BEGIN
IF p_days IS NULL OR p_days <= 0 THEN
RETURN;
END IF;
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = p_table AND c.relkind = 'r'
LOOP
BEGIN
EXECUTE format(
'SELECT EXISTS (SELECT 1 FROM %I.%I WHERE %I < NOW() - make_interval(days => $1))',
sch, p_table, p_ts_col
) INTO has_expired USING p_days;
EXCEPTION
-- Schema or its table vanished mid-scan; skip it.
WHEN undefined_table OR invalid_schema_name OR undefined_column THEN
CONTINUE;
END;
IF has_expired THEN
RETURN NEXT sch;
END IF;
END LOOP;
END;
$fn$;
"""
)
def _pg_downgrade() -> None:
# No-op: e5f6a7b8c9d0 owns these functions' lifecycle and drops them on its
# own downgrade. This migration only re-installs them (the resilient body is
# a strict superset of the previous behaviour), so there is nothing to undo
# without racing that migration's DROP.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,108 @@
"""Add invalidated_memory_units table for curation (edit/invalidate).
Curation keeps the recall hot-path (``memory_units``) clean by *moving*
invalidated facts into a sibling archive table rather than flagging them in
place. If a row is in ``memory_units`` it is live; if it is in
``invalidated_memory_units`` it has been retired. Recall/consolidation/graph
queries never need a state predicate — the rows simply aren't there.
The archive mirrors ``memory_units`` column-for-column — except ``embedding``,
which it never keeps: the archive is cold storage, never a recall surface, and
revert recomputes the embedding from the unit's text/dates/entities. Keeping no
archive vector also means a later embedding-model switch (which re-dimensions
``memory_units``) can't trip a dimension mismatch on the move (#2209). Plus:
- ``invalidation_reason`` optional free text recorded on invalidate
- ``invalidated_at`` when it was retired
- ``entity_ids`` snapshot of the unit's entity associations, so revert
can restore them (``unit_entities`` is cascade-deleted
when the live row is removed)
This migration also adds ``edited_at`` to ``memory_units``: set whenever a user
edits a memory's fields (text, context, dates, fact_type, entities) via curation.
NULL means never manually modified; a non-NULL value answers "has the user ever
changed this?" with the time of the last edit (distinct from ``updated_at``,
which background operations also bump). It is added to ``memory_units`` *before*
the archive is cloned below, so the archive inherits the column and the marker
travels with a fact when it is invalidated.
Revision ID: c9a1b2d3e4f5
Revises: b2d4f6a8c1e3
Create Date: 2026-06-03
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c9a1b2d3e4f5"
down_revision: str | Sequence[str] | None = "b2d4f6a8c1e3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# Add edited_at to the live table FIRST so the archive's LIKE clone below
# inherits it (keeps the two tables column-for-column identical for round-trip).
op.execute(f"ALTER TABLE {schema}memory_units ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ")
# LIKE ... INCLUDING DEFAULTS clones every memory_units column (incl.
# edited_at) so an invalidated row can move back verbatim. We deliberately
# omit indexes/constraints — the archive is cold storage, not a recall
# surface; only the lookups below need indexing.
op.execute(
f"CREATE TABLE IF NOT EXISTS {schema}invalidated_memory_units (LIKE {schema}memory_units INCLUDING DEFAULTS)"
)
# ...then drop the inherited embedding: the archive never stores one (revert
# recomputes it), so it isn't created here only to be dropped again later by
# d4f6a8c2e1b3. That migration still runs as a no-op (DROP ... IF EXISTS) on
# fresh DBs and does the real drop on DBs created before this column was removed.
op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS embedding")
op.execute(
f"ALTER TABLE {schema}invalidated_memory_units "
f"ADD COLUMN IF NOT EXISTS invalidation_reason TEXT, "
f"ADD COLUMN IF NOT EXISTS invalidated_at TIMESTAMPTZ DEFAULT now(), "
f"ADD COLUMN IF NOT EXISTS entity_ids UUID[]"
)
op.execute(f"CREATE UNIQUE INDEX IF NOT EXISTS idx_invalidated_mu_id ON {schema}invalidated_memory_units (id)")
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_invalidated_mu_bank "
f"ON {schema}invalidated_memory_units (bank_id, invalidated_at)"
)
# Deleting a document (or bank) should clear its archived facts too, mirroring
# the memory_units → documents cascade.
op.execute(
f"""
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'invalidated_mu_document_fkey') THEN
ALTER TABLE {schema}invalidated_memory_units
ADD CONSTRAINT invalidated_mu_document_fkey
FOREIGN KEY (document_id, bank_id)
REFERENCES {schema}documents(id, bank_id) ON DELETE CASCADE;
END IF; END $$;
"""
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
# Drops the archive (and its inherited edited_at) wholesale, then removes
# edited_at from the live table.
op.execute(f"DROP TABLE IF EXISTS {schema}invalidated_memory_units")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS edited_at")
def upgrade() -> None:
# PG-only: Oracle gets the table from the baseline snapshot, matching the
# convention used by sibling column/index migrations in this tree.
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,93 @@
"""Drop the embedding column from the curation archive (invalidated_memory_units).
The archive is cold storage, never a recall surface, so it has no business
keeping an embedding. Earlier curation code copied the live row's embedding into
``invalidated_memory_units`` on invalidate; the engine now leaves it out on
invalidate and recomputes it on revert, so the column is dead weight.
Dropping it makes "the archive holds no embedding" a schema-enforced invariant
rather than a convention the move queries have to honour, and removes a latent
failure mode (#2209): after an embedding-model switch the live tables are
re-dimensioned but the archive was not, so a stale old-dimension embedding in
the archive tripped a vector-dimension mismatch on the INSERT … SELECT
round-trip. With no column at all, there is nothing to mismatch.
The creation sites no longer add the column (the PG ``LIKE`` clone in
c9a1b2d3e4f5 drops it; the Oracle baseline omits it), so on a fresh database
this migration is a no-op (DROP ... IF EXISTS / Oracle ORA-00904 swallow). It
does the real work on databases created before the column was removed there.
DROP COLUMN is a metadata-only operation on both PostgreSQL and Oracle 23ai (no
table rewrite), so it is cheap even across many tenant schemas. The downgrade
re-adds an unconstrained vector column (any dimension) — empty, since the
embeddings are intentionally discarded.
Revision ID: d4f6a8c2e1b3
Revises: a1d3f5b7c9e2
Create Date: 2026-06-15
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "d4f6a8c2e1b3"
down_revision: str | Sequence[str] | None = "a1d3f5b7c9e2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS embedding")
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
# Unconstrained `vector` (no dimension) so the re-added column accepts any
# model's embeddings; it comes back empty regardless.
op.execute(f"ALTER TABLE {schema}invalidated_memory_units ADD COLUMN IF NOT EXISTS embedding vector")
def _oracle_upgrade() -> None:
# Oracle has no `DROP COLUMN IF EXISTS`; swallow ORA-00904 (column does not
# exist) so the migration is idempotent and safe on a fresh schema whose
# baseline already omits the column.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units DROP COLUMN embedding';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -904 THEN RAISE; END IF;
END;
"""
)
def _oracle_downgrade() -> None:
# Swallow ORA-01430 (column already exists) for idempotency.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units ADD (embedding VECTOR)';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -1430 THEN RAISE; END IF;
END;
"""
)
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -0,0 +1,150 @@
"""Add the ``schemas_with_expired_operations`` cross-tenant discovery routine.
The worker's terminal-operation cleanup (``a8c1e4f7b0d3``) opens a connection
and a prune transaction against *every* tenant schema on every cleanup cycle,
whether or not that tenant has anything to prune. At thousands of tenants that
is a per-cycle query storm whose cost is paid entirely by idle schemas.
This is the same problem ``public.schemas_with_expired_rows`` already solves for
the ``audit_log`` / ``llm_requests`` retention sweeps (``e5f6a7b8c9d0``): one
round-trip returns just the schemas that actually hold expired rows, and the
caller then does real work only there. ``async_operations`` needs its own
routine rather than reusing that one because eligibility is not "row older than
N days" — pending and processing rows are never prunable, so the status filter
has to be part of the predicate.
Install policy mirrors ``b6d2f8a4c1e7`` (#2638/#2824), the current behaviour for
the sibling routines: the routine is database-global — it enumerates ``pg_class``
across every schema and dispatches per schema — so exactly one copy should exist,
installed into the schema this deployment is *configured* to use and called from
there via ``fq_routine``. Gating on the literal ``"public"`` instead of the
configured schema is what left single-tenant deployments in a dedicated
non-``public`` schema without the routine (#2638).
Exactly one migration run satisfies that predicate, so concurrent per-schema runs
never issue competing ``CREATE OR REPLACE`` against the same ``pg_proc`` row and
cannot hit ``tuple concurrently updated``. No cross-process coordination is
required — in particular no advisory lock, which is unusable here because
Hindsight runs behind connection poolers and managed PG services (see #2817).
Each per-schema probe runs in its own ``BEGIN ... EXCEPTION`` block so a tenant
dropped mid-scan is skipped instead of aborting the sweep (see ``c7e9f1a3b5d2``).
Revision ID: d7b2f8a1c934
Revises: b6d2f8a4c1e7
Create Date: 2026-07-20
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
from hindsight_api.config import get_config
revision: str = "d7b2f8a1c934"
down_revision: str | Sequence[str] | None = "b6d2f8a4c1e7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _configured_schema() -> str:
"""The one schema this deployment's routines live in and are called from."""
return get_config().database_schema or "public"
def _target_schema() -> str | None:
return context.config.get_main_option("target_schema")
def _is_install_run() -> bool:
"""True for the single run that owns the routine (mirrors b6d2f8a4c1e7)."""
target = _target_schema()
return not target or target == _configured_schema()
def _prefix(schema: str | None) -> str:
"""Qualifier for ``schema``, or ``""`` to fall back to ``search_path``."""
return f'"{schema}".' if schema else ""
def _drop_routine(schema: str | None) -> None:
op.execute(f"DROP FUNCTION IF EXISTS {_prefix(schema)}schemas_with_expired_operations(int)")
def _pg_upgrade() -> None:
if not _is_install_run():
# Tenant schemas must not carry their own copy: the routine is
# database-global and only the configured schema's copy is ever called.
# Dropping (rather than skipping) also cleans up after any interim build
# of this branch that installed per-schema copies.
_drop_routine(_target_schema())
return
schema = _prefix(_target_schema())
op.execute(
f"""
CREATE OR REPLACE FUNCTION {schema}schemas_with_expired_operations(p_days int)
RETURNS SETOF text
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
has_expired boolean;
BEGIN
-- Zero (or negative) retention means "keep forever": report nothing
-- so the caller skips the sweep entirely.
IF p_days IS NULL OR p_days <= 0 THEN
RETURN;
END IF;
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'async_operations' AND c.relkind = 'r'
LOOP
BEGIN
-- Matches the worker's prune predicate: only terminal rows
-- are eligible, so a schema holding nothing but pending or
-- processing work is correctly reported as having nothing
-- to prune. Uses idx_async_operations_terminal_cleanup.
EXECUTE format(
'SELECT EXISTS ('
' SELECT 1 FROM %I.async_operations'
' WHERE status IN (''completed'', ''failed'', ''cancelled'')'
' AND updated_at < NOW() - make_interval(days => $1)'
')',
sch
) INTO has_expired USING p_days;
EXCEPTION
-- Schema or its table vanished between the pg_class
-- snapshot and this probe (tenant dropped or migrating).
WHEN undefined_table OR invalid_schema_name OR undefined_column THEN
CONTINUE;
END;
IF has_expired THEN
RETURN NEXT sch;
END IF;
END LOOP;
END;
$fn$;
"""
)
def _pg_downgrade() -> None:
# This migration is the sole creator of this routine — no older migration
# owns a copy the way e5f6a7b8c9d0 owns the public sibling routines — so the
# install run's own copy is always ours to drop.
if not _is_install_run():
return
_drop_routine(_target_schema())
def upgrade() -> None:
# Oracle slot intentionally absent: this mirrors the PostgreSQL-only
# maintenance routines, and the Oracle worker keeps its per-schema sweep.
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,39 @@
"""Merge two divergent migration heads.
``d4f6a8c2e1b3`` (drop the curation-archive embedding column) and
``2071c7518f88`` (add the memory_links(bank_id, link_type) index) were authored
in parallel off the same parent (``a1d3f5b7c9e2``) and merged independently,
leaving the DAG with two heads. This is a no-op merge that re-unifies them so
``alembic upgrade head`` is unambiguous again (enforced by
``tests/test_alembic_dag.py::test_single_head``).
Revision ID: e1f2a3b4c5d6
Revises: d4f6a8c2e1b3, 2071c7518f88
Create Date: 2026-06-16
"""
from collections.abc import Sequence
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "e1f2a3b4c5d6"
down_revision: str | Sequence[str] | None = ("d4f6a8c2e1b3", "2071c7518f88")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_upgrade() -> None:
# Pure DAG merge — both parents already applied their schema changes.
pass
def _pg_downgrade() -> None:
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,114 @@
"""Drop the never-written `access_count` column from memory_units (and its archive).
``memory_units.access_count`` has been dead since the initial schema
(5a366d414dce): no code path anywhere in the repo ever writes it, and — despite
the ``access_count DESC`` index created alongside it — no query ever reads or
orders by it either. It is 0 on every row of every install. The lone remaining
mentions were an index, a stale comment naming an ``access_count_update`` task
type that was never implemented, and the column's name in the Oracle backend's
numeric-RETURNING list; all three go away with this change.
The column is dropped from the curation archive too. ``invalidated_memory_units``
was cloned ``LIKE memory_units`` (c9a1b2d3e4f5), so it inherited the column, and
curation's INSERT…SELECT round-trip builds its column list from the catalog
(``writes.py::_memory_unit_columns``) — the two tables must stay in lockstep or
the round-trip breaks on a column-count mismatch.
Dropping the column implicitly drops its index on both dialects
(``idx_memory_units_access_count`` on PG, ``idx_mu_access_count`` on Oracle), so
PostgreSQL also stops maintaining a btree that nothing ever probed.
Cost: on PostgreSQL ``DROP COLUMN`` is metadata-only (the attribute is marked
dropped, no table rewrite). On Oracle it does delete the column data row by row,
so on a large ``memory_units`` this migration is not free — it is still bounded
work on a single small integer column, and Oracle installs of that size can run
it during a maintenance window ahead of the upgrade if they prefer.
Revision ID: e4a7c1b9d2f6
Revises: a9b8c7d6e5f4
Create Date: 2026-08-03
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "e4a7c1b9d2f6"
down_revision: str | Sequence[str] | None = "a9b8c7d6e5f4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_TABLES = ("memory_units", "invalidated_memory_units")
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
for table in _TABLES:
# Drops idx_memory_units_access_count along with the column.
op.execute(f"ALTER TABLE {schema}{table} DROP COLUMN IF EXISTS access_count")
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
for table in _TABLES:
op.execute(f"ALTER TABLE {schema}{table} ADD COLUMN IF NOT EXISTS access_count integer NOT NULL DEFAULT 0")
# The archive was cloned without indexes; only the live table carried one.
op.execute(f"CREATE INDEX IF NOT EXISTS idx_memory_units_access_count ON {schema}memory_units (access_count DESC)")
def _oracle_upgrade() -> None:
# Oracle has no `DROP COLUMN IF EXISTS`; swallow ORA-00904 (column does not
# exist) so the migration is idempotent and safe on a schema that already
# lacks the column. Dropping the column also drops idx_mu_access_count.
for table in _TABLES:
op.execute(
f"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE {table} DROP COLUMN access_count';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -904 THEN RAISE; END IF;
END;
"""
)
def _oracle_downgrade() -> None:
# Swallow ORA-01430 (column already exists) for idempotency. Matches the
# Oracle baseline's declaration: NUMBER(10) DEFAULT 0 NOT NULL.
for table in _TABLES:
op.execute(
f"""
BEGIN
EXECUTE IMMEDIATE
'ALTER TABLE {table} ADD (access_count NUMBER(10) DEFAULT 0 NOT NULL)';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -1430 THEN RAISE; END IF;
END;
"""
)
# ORA-00955: index name already in use.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'CREATE INDEX idx_mu_access_count ON memory_units(access_count DESC)';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -955 THEN RAISE; END IF;
END;
"""
)
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -0,0 +1,96 @@
"""Drop the search_vector column from the curation archive (invalidated_memory_units).
The archive is cold storage, never a recall surface, and carries no text-search
index. Like ``embedding`` (dropped in d4f6a8c2e1b3), ``search_vector`` is a
recall-surface column whose type follows the configured text-search backend, so
it has no business living on the archive. Earlier curation code copied the live
row's ``search_vector`` into ``invalidated_memory_units`` on invalidate; the
engine now leaves it out on invalidate and recomputes it on revert, so the
column is dead weight.
Dropping it removes a latent failure mode (#2503): under a non-native backend
(pgroonga / pg_textsearch / pg_search / vchord) ``ensure_text_search_extension``
reconciles ``memory_units.search_vector`` to ``text`` / ``bm25vector`` but never
touched the archive, which the ``LIKE memory_units`` clone (c9a1b2d3e4f5) created
as ``tsvector``. The type mismatch then broke the curation INSERT … SELECT
round-trip:
column "search_vector" is of type tsvector but expression is of type text
With no column at all, there is nothing to mismatch. Unlike ``embedding`` (whose
creation sites already omit it), the ``LIKE`` clone still adds ``search_vector``,
so this migration does real work on both fresh and existing PostgreSQL databases.
DROP COLUMN is a metadata-only operation on both PostgreSQL and Oracle 23ai (no
table rewrite), so it is cheap even across many tenant schemas. The downgrade
re-adds an empty ``tsvector`` column (its original creation type).
Revision ID: e7c3a9f1b2d5
Revises: b57a7c9e0d13
Create Date: 2026-07-02
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "e7c3a9f1b2d5"
down_revision: str | Sequence[str] | None = "b57a7c9e0d13"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS search_vector")
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
# Re-add as the original tsvector creation type; comes back empty regardless.
op.execute(f"ALTER TABLE {schema}invalidated_memory_units ADD COLUMN IF NOT EXISTS search_vector tsvector")
def _oracle_upgrade() -> None:
# Oracle has no `DROP COLUMN IF EXISTS`; swallow ORA-00904 (column does not
# exist) so the migration is idempotent and safe on a schema whose baseline
# may already omit the column.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units DROP COLUMN search_vector';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -904 THEN RAISE; END IF;
END;
"""
)
def _oracle_downgrade() -> None:
# Swallow ORA-01430 (column already exists) for idempotency. Oracle stores
# search_vector as CLOB (see the Oracle baseline), so re-add it as CLOB.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units ADD (search_vector CLOB)';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -1430 THEN RAISE; END IF;
END;
"""
)
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -0,0 +1,85 @@
"""Repair: drop the stale global memory_units vector index on per-bank backends.
Revision ID: f2a6d8c4b1e9
Revises: e4a7c1b9d2f6
Create Date: 2026-08-06
Migration d5e6f7a8b9c0 dropped the global ``idx_memory_units_embedding`` for
per-bank backends (every vector search is bank + fact_type scoped and served
by the ``idx_mu_emb_*`` partial indexes; the global index is never chosen by
the planner). However, older versions of the post-migration reconcile
(``ensure_vector_extension``) recreated the index when they found none, so
schemas that were provisioned or reconciled in that window carry it to this
day — paying a second vector graph insertion on every ``memory_units`` write
for an index no query uses.
This repair drops the leftover index. It is intentionally a migration, not
runtime reconcile behavior: ``DROP INDEX`` takes an ACCESS EXCLUSIVE lock on
``memory_units``, which belongs in the versioned, once-per-schema migration
path — not in code that runs at unpredictable times during startup or tenant
provisioning. The reconcile now leaves memory_units vector-index DDL to
migrations entirely on per-bank backends.
ScaNN deployments keep the global index by design (filtered vector search over
a global index; per-bank partial indexes cannot be built safely there), so the
migration is a no-op for them.
"""
import os
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "f2a6d8c4b1e9"
down_revision: str | Sequence[str] | None = "e4a7c1b9d2f6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _configured_vector_extension() -> str:
ext = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
if ext not in {"pgvector", "pgvectorscale", "vchord", "scann"}:
raise ValueError(
f"Invalid HINDSIGHT_API_VECTOR_EXTENSION: {ext}. Must be 'pgvector', 'vchord', 'pgvectorscale', or 'scann'"
)
return ext
def _pg_upgrade() -> None:
# ScaNN uses a global vector index by design — nothing stale to repair.
if _configured_vector_extension() == "scann":
return
schema = _pg_schema_prefix()
# DROP INDEX needs ACCESS EXCLUSIVE on memory_units. While it waits for
# in-flight transactions, every new query on the table queues behind it,
# so on a write-busy schema an unbounded wait can pile up traffic. Fail
# fast instead: the migration errors, the schema stays below head, and
# the next migration pass retries — preferable to freezing the table.
# SET LOCAL scopes the timeout to this migration's transaction.
op.execute("SET LOCAL lock_timeout = '10s'")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_embedding")
def _pg_downgrade() -> None:
# Intentional no-op: recreating a potentially multi-GB vector index that no
# query uses is not a safe downgrade action. Downgrading past d5e6f7a8b9c0
# restores the global index for deployments that genuinely need it.
pass
def upgrade() -> None:
# PG-only repair: the stale index is a PostgreSQL artifact of the old
# reconcile; Oracle deployments never had a reconcile that created it.
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,110 @@
"""Add server-side routine for cron-scheduled mental model refresh.
Installs ``public.mental_models_with_cron()`` — a discovery routine that returns
every mental model carrying a non-empty ``trigger->>'refresh_cron'`` across all
tenant schemas in one round-trip (the same per-schema scan as the other
maintenance routines from ``e5f6a7b8c9d0``). The maintenance loop evaluates each
candidate's cron expression in Python (``croniter``) against ``last_refreshed_at``
to decide whether a scheduled refresh is due — cron arithmetic isn't expressible
in plain SQL — and only the cron *candidate set* is discovered here.
Models that already have a ``refresh_mental_model`` operation pending/processing
are excluded so a slow refresh isn't double-queued (mirrors the in-flight guard
in ``banks_needing_consolidation``). Each per-schema query runs in its own
``BEGIN ... EXCEPTION`` subtransaction so a schema dropped mid-scan (tenant
deletion / migration) is skipped, not fatal — same resilience as
``c7e9f1a3b5d2``.
Read-only (STABLE) discovery routine — the caller performs the refresh enqueue —
so installing it never mutates data. PostgreSQL only: the worker poller and the
maintenance loop are PG-only (Oracle slot intentionally absent, mirroring
``e5f6a7b8c9d0``). The routine lives in ``public`` and is CREATE OR REPLACE, so
it is installed exactly once (base / ``public`` run) to avoid the
``tuple concurrently updated`` race on concurrent per-tenant runs.
Revision ID: f4d1c2b3a5e6
Revises: c7e9f1a3b5d2
Create Date: 2026-06-23
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "f4d1c2b3a5e6"
down_revision: str | Sequence[str] | None = "c7e9f1a3b5d2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _should_install_public_routines(target_schema: str | None) -> bool:
"""True for the run that must (re)create the shared ``public.*`` routine.
The routine physically lives in ``public``, so it is installed exactly once —
on the base run (no ``target_schema``) or the run that explicitly targets
``public``. Mirrors ``c7e9f1a3b5d2``.
"""
return not target_schema or target_schema == "public"
def _pg_upgrade() -> None:
if not _should_install_public_routines(context.config.get_main_option("target_schema")):
return
op.execute(
"""
CREATE OR REPLACE FUNCTION public.mental_models_with_cron()
RETURNS TABLE(schema_name text, bank_id text, mental_model_id text,
refresh_cron text, last_refreshed_at timestamptz)
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
BEGIN
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'mental_models' AND c.relkind = 'r'
LOOP
BEGIN
RETURN QUERY EXECUTE format($q$
SELECT %1$L::text, mm.bank_id::text, mm.id::text,
mm.trigger->>'refresh_cron', mm.last_refreshed_at
FROM %1$I.mental_models mm
WHERE COALESCE(mm.trigger->>'refresh_cron', '') <> ''
AND NOT EXISTS (
SELECT 1 FROM %1$I.async_operations o
WHERE o.bank_id = mm.bank_id
AND o.operation_type = 'refresh_mental_model'
AND o.status IN ('pending', 'processing')
AND o.task_payload->>'mental_model_id' = mm.id::text
)
$q$, sch);
EXCEPTION
-- Schema or its tables vanished between the pg_class
-- snapshot and this query (tenant dropped or migrating).
WHEN undefined_table OR invalid_schema_name OR undefined_column THEN
CONTINUE;
END;
END LOOP;
END;
$fn$;
"""
)
def _pg_downgrade() -> None:
if not _should_install_public_routines(context.config.get_main_option("target_schema")):
return
op.execute("DROP FUNCTION IF EXISTS public.mental_models_with_cron()")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -122,6 +122,7 @@ _TABLES: tuple[str, ...] = (
text_signals CLOB,
consolidation_failed_at TIMESTAMP WITH TIME ZONE,
search_vector CLOB,
edited_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_memory_units PRIMARY KEY (id),
@@ -138,6 +139,50 @@ _TABLES: tuple[str, ...] = (
PARTITION BY LIST (bank_id) AUTOMATIC
(PARTITION p_default VALUES ('__default__'))
""",
# Cold archive for curation: invalidated facts are MOVED here out of
# memory_units so the recall hot-path never sees them. Mirrors memory_units
# plus invalidation bookkeeping and an entity-id snapshot for lossless revert.
# No `embedding` column: the archive is cold storage and revert recomputes the
# embedding, so there is no archive vector to fall out of sync with the live
# model's dimension on a model switch (#2209).
"""
CREATE TABLE IF NOT EXISTS invalidated_memory_units (
id RAW(16) NOT NULL,
bank_id VARCHAR2(256) NOT NULL,
document_id VARCHAR2(512),
chunk_id VARCHAR2(512),
text CLOB NOT NULL,
context CLOB,
event_date TIMESTAMP WITH TIME ZONE NOT NULL,
occurred_start TIMESTAMP WITH TIME ZONE,
occurred_end TIMESTAMP WITH TIME ZONE,
mentioned_at TIMESTAMP WITH TIME ZONE,
fact_type VARCHAR2(64) DEFAULT 'world' NOT NULL,
confidence_score BINARY_DOUBLE,
access_count NUMBER(10) DEFAULT 0 NOT NULL,
consolidated_at TIMESTAMP WITH TIME ZONE,
observation_scopes CLOB CONSTRAINT imu_obs_scopes_json CHECK (observation_scopes IS JSON OR observation_scopes IS NULL),
tags CLOB DEFAULT '[]' NOT NULL,
metadata CLOB DEFAULT '{}' NOT NULL
CONSTRAINT imu_metadata_json CHECK (metadata IS JSON),
proof_count NUMBER(10) DEFAULT 1,
source_memory_ids CLOB,
history CLOB DEFAULT '[]'
CONSTRAINT imu_history_json CHECK (history IS JSON OR history IS NULL),
text_signals CLOB,
consolidation_failed_at TIMESTAMP WITH TIME ZONE,
search_vector CLOB,
edited_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
invalidation_reason CLOB,
invalidated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP,
entity_ids CLOB CONSTRAINT imu_entity_ids_json CHECK (entity_ids IS JSON OR entity_ids IS NULL),
CONSTRAINT pk_invalidated_memory_units PRIMARY KEY (id),
CONSTRAINT fk_imu_document FOREIGN KEY (document_id, bank_id)
REFERENCES documents(id, bank_id) ON DELETE CASCADE
)
""",
"""
CREATE TABLE IF NOT EXISTS entities (
id RAW(16) DEFAULT SYS_GUID() NOT NULL,
@@ -16,9 +16,7 @@ retention parameters, retrieval settings, etc.) in Python field name format.
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
from sqlalchemy.dialects.postgresql import JSONB
from hindsight_api.alembic._dialect import run_for_dialect
@@ -0,0 +1,97 @@
"""Client-disconnect detection that works behind ``BaseHTTPMiddleware``.
``Request.is_disconnected()`` is the obvious way to notice an abandoned HTTP
request, but it is silently broken once any ``@app.middleware("http")``
(Starlette ``BaseHTTPMiddleware``) is installed: that middleware runs the route
in a child task behind anyio memory streams, so the ``http.disconnect`` ASGI
event never reaches the route's ``Request``. This app has such middlewares, so
the recall/reflect cancellation in #2122/#2127 never actually fired in
production — the disconnect was never observed.
This pure-ASGI middleware sits *outside* the ``BaseHTTPMiddleware`` layer, where
it still owns the real ``receive`` channel. For the recall and reflect routes it
drains ``receive`` in a background task and trips a :class:`CancellationToken`
the moment ``http.disconnect`` arrives, stashing the token on the ASGI ``scope``.
The route copies that token onto its ``RequestContext`` and the engine checks it
at stage boundaries — so abandoned work stops instead of running to completion.
It only wraps recall/reflect (small JSON bodies); every other request — uploads,
MCP streams, etc. — passes straight through untouched, so there is no buffering
or latency cost elsewhere.
"""
from __future__ import annotations
import asyncio
import contextlib
from collections.abc import Awaitable, Callable, MutableMapping
from typing import Any
from ..cancellation import CancellationToken
# Key under which the per-request CancellationToken is stored on the ASGI scope.
# A dedicated top-level scope key (not scope["state"]) avoids any interaction
# with Starlette's per-request state copying.
SCOPE_CANCELLATION_TOKEN = "hindsight.cancellation_token"
_CLIENT_DISCONNECTED_REASON = "client disconnected"
Scope = MutableMapping[str, Any]
Receive = Callable[[], Awaitable[MutableMapping[str, Any]]]
Send = Callable[[MutableMapping[str, Any]], Awaitable[None]]
def _should_monitor(path: str) -> bool:
"""Only the two long-running, abandon-prone read endpoints need monitoring."""
return path.endswith("/memories/recall") or path.endswith("/reflect")
class ClientDisconnectCancellationMiddleware:
"""Trip a scope-level CancellationToken when the client disconnects.
Must be installed *outside* any ``BaseHTTPMiddleware`` so it owns the real
ASGI ``receive`` channel.
"""
def __init__(self, app: Callable) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http" or not _should_monitor(scope.get("path", "")):
await self.app(scope, receive, send)
return
token = CancellationToken()
scope[SCOPE_CANCELLATION_TOKEN] = token
# The downstream app still needs to read the request body, so we cannot
# simply consume `receive` ourselves. Instead a single pump task drains
# the real channel, forwards every message to a queue the app reads from,
# and trips the token the instant `http.disconnect` shows up — which the
# app would otherwise never pull once it has finished reading the body.
queue: asyncio.Queue = asyncio.Queue()
async def pump() -> None:
while True:
message = await receive()
if message["type"] == "http.disconnect":
token.cancel(_CLIENT_DISCONNECTED_REASON)
await queue.put(message)
return
await queue.put(message)
async def proxied_receive() -> MutableMapping[str, Any]:
return await queue.get()
pump_task = asyncio.create_task(pump())
try:
await self.app(scope, proxied_receive, send)
finally:
pump_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await pump_task
def get_scope_cancellation_token(scope: Scope) -> CancellationToken | None:
"""Return the CancellationToken the middleware attached, if any."""
return scope.get(SCOPE_CANCELLATION_TOKEN)
File diff suppressed because it is too large Load Diff
+22 -1
View File
@@ -9,7 +9,7 @@ from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api import __version__ as HINDSIGHT_VERSION
from hindsight_api.config import _get_raw_config
from hindsight_api.config import DEFAULT_MCP_RECALL_DESCRIPTION, DEFAULT_MCP_RETAIN_DESCRIPTION, _get_raw_config
from hindsight_api.engine.memory_engine import _current_schema
from hindsight_api.extensions import MCPExtension, load_extension
from hindsight_api.extensions.tenant import AuthenticationError
@@ -78,6 +78,19 @@ def get_current_mcp_authenticated() -> bool:
return _current_mcp_authenticated.get()
def _build_mcp_tool_descriptions(extra_instructions: str | None) -> tuple[str | None, str | None]:
"""Return custom retain/recall descriptions when server-level MCP instructions are set."""
if not isinstance(extra_instructions, str):
return None, None
extra_instructions = extra_instructions.strip()
if not extra_instructions:
return None, None
suffix = f"\n\nAdditional instructions: {extra_instructions}"
return DEFAULT_MCP_RETAIN_DESCRIPTION + suffix, DEFAULT_MCP_RECALL_DESCRIPTION + suffix
def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
"""
Create and configure the Hindsight MCP server.
@@ -113,6 +126,8 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
"delete_directive",
"list_memories",
"get_memory",
"update_memory",
"invalidate_memory",
"list_documents",
"get_document",
"delete_document",
@@ -133,6 +148,10 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
allowed = frozenset(global_config.mcp_enabled_tools)
base_tools = (base_tools if base_tools is not None else _ALL_TOOLS) & allowed
retain_description, recall_description = _build_mcp_tool_descriptions(
getattr(global_config, "mcp_instructions", None)
)
# Configure and register tools using shared module
config = MCPToolsConfig(
bank_id_resolver=get_current_bank_id,
@@ -142,6 +161,8 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
mcp_authenticated_resolver=get_current_mcp_authenticated, # Propagate MCP pre-auth flag
include_bank_id_param=multi_bank,
tools=base_tools,
retain_description=retain_description,
recall_description=recall_description,
)
register_mcp_tools(mcp, memory, config)
@@ -0,0 +1,171 @@
"""Markdown rendering for knowledge pages.
Knowledge pages render as *read-only* markdown documents over the existing mental
models: each mental model becomes a markdown body with a YAML frontmatter block
(``type`` required; ``title``/``description``/``tags``/``timestamp`` optional).
This module is intentionally pure: every function transforms the mental-model
dicts returned by ``MemoryEngine.list_mental_models`` / ``get_mental_model`` and
never touches the database. That keeps rendering unit-testable without a DB or
LLM and lets the HTTP layer stay a thin wrapper.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
# Every page carries exactly one ``type`` frontmatter field. We default to this
# when a page does not declare one via a ``type:<x>`` tag.
DEFAULT_PAGE_TYPE = "knowledge-page"
# A page declares its ``type`` through a tag of the form ``type:runbook``.
# This keeps rendering schema-free (no new mental_models column): the type is
# lifted from the existing tags array.
TYPE_TAG_PREFIX = "type:"
INDEX_FILENAME = "index.md"
@dataclass(frozen=True)
class PageType:
"""A page's ``type`` and the tags that remain after the type tag is split off."""
type: str
display_tags: list[str]
def _scalar(value: Any) -> str:
"""Emit a YAML-safe double-quoted scalar.
We always double-quote so arbitrary page names / source queries can't be
misread as YAML special forms (``true``, ``2026-01-01``, ``- x``, etc.).
"""
text = str(value)
escaped = text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "")
return f'"{escaped}"'
def page_type(tags: list[str] | None) -> PageType:
"""Split a ``type`` out of the tag list.
The first ``type:<x>`` tag wins; all ``type:`` tags are removed from the
returned ``display_tags`` so they don't leak into the page's displayed tags.
Falls back to :data:`DEFAULT_PAGE_TYPE`.
"""
resolved = DEFAULT_PAGE_TYPE
display: list[str] = []
for tag in tags or []:
if tag.startswith(TYPE_TAG_PREFIX):
suffix = tag[len(TYPE_TAG_PREFIX) :].strip()
if suffix and resolved == DEFAULT_PAGE_TYPE:
resolved = suffix
continue
display.append(tag)
return PageType(type=resolved, display_tags=display)
def _timestamp(mm: dict[str, Any]) -> str | None:
return mm.get("last_refreshed_at") or mm.get("created_at")
def frontmatter(mm: dict[str, Any]) -> dict[str, Any]:
"""Build the ordered frontmatter mapping for a mental model.
``None``/empty values are dropped by :func:`render_frontmatter`.
"""
pt = page_type(mm.get("tags"))
return {
"id": mm.get("id"),
"type": pt.type,
"title": mm.get("name"),
"description": mm.get("source_query"),
"tags": pt.display_tags,
"timestamp": _timestamp(mm),
}
def render_frontmatter(fm: dict[str, Any]) -> str:
"""Render a frontmatter mapping into a ``---`` fenced YAML block."""
lines = ["---"]
for key, value in fm.items():
if value is None:
continue
if isinstance(value, list):
if not value:
continue
lines.append(f"{key}:")
lines.extend(f" - {_scalar(item)}" for item in value)
else:
lines.append(f"{key}: {_scalar(value)}")
lines.append("---")
return "\n".join(lines)
def render_document(mm: dict[str, Any]) -> str:
"""Render a full markdown document: frontmatter block + markdown body."""
body = (mm.get("content") or "").strip()
return f"{render_frontmatter(frontmatter(mm))}\n\n{body}\n" if body else f"{render_frontmatter(frontmatter(mm))}\n"
def page_filename(page_id: str) -> str:
"""Bundle filename for a page id."""
return f"{page_id}.md"
def log_filename(page_id: str) -> str:
"""Reserved per-page history filename."""
return f"{page_id}.log.md"
def render_index(nodes: list[dict[str, Any]]) -> str:
"""Render the reserved ``index.md`` — nested markdown navigation over the tree.
``nodes`` is the flat folder/page list (each with ``id``, ``kind``, ``name``,
``parent_id``); folders nest their children, pages link to their ``.md``.
"""
fm = render_frontmatter({"type": "index", "title": "Knowledge base"})
lines = [fm, "", "# Knowledge base", ""]
children: dict[Any, list[dict[str, Any]]] = {}
for node in nodes:
children.setdefault(node.get("parent_id"), []).append(node)
def walk(parent: Any, depth: int) -> None:
ordered = sorted(children.get(parent, []), key=lambda n: (n.get("sort_order", 0), n.get("name") or ""))
for node in ordered:
indent = " " * depth
if node.get("kind") == "folder":
lines.append(f"{indent}- **{node['name']}/**")
walk(node["id"], depth + 1)
else:
description = node.get("source_query") or node.get("description")
link = f"{indent}- [{node['name']}](./{page_filename(node['id'])})"
lines.append(f"{link}{description}" if description else link)
walk(None, 0)
if len(lines) == 4:
lines.append("_No knowledge pages yet._")
return "\n".join(lines) + "\n"
def render_log(mm: dict[str, Any], history: list[dict[str, Any]]) -> str:
"""Render the reserved per-page ``log.md`` from refresh history.
Each history entry is ``{previous_content, previous_reflect_response,
changed_at}`` (newest first), capturing the content *before* a refresh.
"""
name = mm.get("name") or mm.get("id")
fm = render_frontmatter({"type": "log", "title": f"{name} — history"})
lines = [fm, "", f"# {name} — history", ""]
if not history:
lines.append("_No refresh history._")
return "\n".join(lines) + "\n"
for entry in history:
changed_at = entry.get("changed_at") or "unknown"
previous = (entry.get("previous_content") or "").strip()
lines.append(f"## {changed_at}")
lines.append("")
lines.append(previous if previous else "_(empty)_")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
@@ -66,11 +66,6 @@ def color_end(text: str) -> str:
return color(text, 1.0)
def color_mid(text: str) -> str:
"""Color text with gradient middle color."""
return color(text, 0.5)
def dim(text: str) -> str:
"""Dim/gray text."""
return f"\033[38;2;128;128;128m{text}\033[0m"
@@ -0,0 +1,85 @@
"""Cooperative cancellation for long-running engine operations.
Recall runs as a staged pipeline whose heavy stages — graph expansion and
cross-encoder reranking — execute in worker threads (``run_in_executor``) that
asyncio task cancellation cannot interrupt once they have started. Cancelling
the awaiting task only unblocks the ``await``; the thread keeps burning CPU to
completion. So rather than rely on task cancellation, callers thread a
``CancellationToken`` through ``RequestContext`` and the engine checks it at
stage boundaries (``raise_if_cancelled``), bailing out *before* dispatching the
next expensive stage.
This is cooperative by design: it cannot stop a computation already inside a
worker thread, but it does stop an abandoned recall from progressing into — or
past — that work, which is what starves the instance in issue #2122. The token
lives on ``RequestContext``, so any operation that receives one (recall today;
reflect/consolidation/MCP later) can adopt the same checkpoints, and any driver
(client disconnect today; a deadline tomorrow) can fire it.
"""
from __future__ import annotations
import asyncio
class OperationCancelledError(Exception):
"""Raised at a checkpoint when the operation has been cancelled.
Carries the ``reason`` set by whoever cancelled (e.g. "client disconnected")
so the HTTP layer can translate it into the appropriate status code instead
of a generic 500.
NOTE: this is a plain ``Exception`` on purpose, NOT ``BaseException``. The
recall/reflect pipelines have broad ``except Exception`` handlers that would
otherwise swallow it — those handlers re-raise ``OperationCancelledError``
explicitly (see ``_search_with_retries``) so cancellation propagates to the
HTTP layer. A ``BaseException`` would dodge those handlers but also slip past
legitimate ``isinstance(result, Exception)`` checks (e.g. the reflect agent's
``asyncio.gather(..., return_exceptions=True)`` tool-result handling), which
expect every non-tuple result to be an ``Exception``.
"""
def __init__(self, reason: str = "operation cancelled") -> None:
super().__init__(reason)
self.reason = reason
class CancellationToken:
"""A one-shot, cooperative cancellation signal.
Cheap to poll (``raise_if_cancelled``) at stage boundaries and awaitable
(``wait``) so a driver task can block until cancellation. Safe to share
across an engine call tree; polling is a no-op until something cancels, and
cancellation is idempotent (the first reason wins).
"""
__slots__ = ("_event", "_reason")
def __init__(self) -> None:
self._event = asyncio.Event()
self._reason = "operation cancelled"
def cancel(self, reason: str = "operation cancelled") -> None:
"""Signal cancellation. Idempotent; the first reason recorded wins."""
if not self._event.is_set():
self._reason = reason
self._event.set()
@property
def cancelled(self) -> bool:
"""Whether cancellation has been signalled."""
return self._event.is_set()
@property
def reason(self) -> str:
"""The reason recorded by the first ``cancel`` call."""
return self._reason
def raise_if_cancelled(self) -> None:
"""Raise ``OperationCancelledError`` if cancellation has been signalled."""
if self._event.is_set():
raise OperationCancelledError(self._reason)
async def wait(self) -> None:
"""Block until cancellation is signalled."""
await self._event.wait()
File diff suppressed because it is too large Load Diff
@@ -8,16 +8,21 @@ Config values are resolved on every request to ensure consistency across
multiple API servers.
"""
import asyncio
import json
import logging
from dataclasses import asdict, replace
from typing import TYPE_CHECKING, Any
from dataclasses import asdict, fields, replace
from functools import lru_cache
from types import UnionType
from typing import TYPE_CHECKING, Any, Union, get_args, get_origin
from hindsight_api.config import (
RECALL_BUDGET_FUNCTIONS,
HindsightConfig,
_get_raw_config,
normalize_config_dict,
validate_retain_chunking_config,
validate_retain_completion_token_budget,
)
from hindsight_api.engine.memory_engine import fq_table
from hindsight_api.extensions.tenant import TenantExtension
@@ -29,6 +34,43 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
class BankConfigPersistenceConflictError(ValueError):
"""Raised when a validated bank config update can no longer be persisted."""
def __init__(self, bank_id: str):
self.bank_id = bank_id
super().__init__(f"Cannot update config for bank '{bank_id}': the bank does not exist")
def _validate_retain_strategy_chunking(base_config: HindsightConfig, strategies: Any) -> None:
"""Validate retain strategy chunking with the same semantics as apply_strategy()."""
if not isinstance(strategies, dict):
return
configurable = HindsightConfig.get_configurable_fields()
for strategy_name, overrides in strategies.items():
if not isinstance(overrides, dict):
raise ValueError(f"Invalid retain strategy {strategy_name!r}: must be an object")
filtered = {k: v for k, v in overrides.items() if k in configurable}
if not filtered:
continue
try:
resolved = replace(base_config, **filtered)
validate_retain_chunking_config(
resolved.retain_chunk_size,
resolved.retain_structured_chunk_size,
)
validate_retain_completion_token_budget(
llm_provider=resolved.llm_provider,
retain_max_completion_tokens=resolved.retain_max_completion_tokens,
retain_chunk_size=resolved.retain_chunk_size,
retain_llm_model=resolved.retain_llm_model,
llm_model=resolved.llm_model,
retain_llm_provider=resolved.retain_llm_provider,
)
except ValueError as e:
raise ValueError(f"Invalid retain strategy {strategy_name!r}: {e}") from e
class ConfigResolver:
"""Resolves hierarchical configuration with tenant/bank overrides."""
@@ -46,6 +88,26 @@ class ConfigResolver:
self._configurable_fields = HindsightConfig.get_configurable_fields()
self._credential_fields = HindsightConfig.get_credential_fields()
async def _resolve_parent_config_dict(self, bank_id: str, context: RequestContext | None = None) -> dict[str, Any]:
"""Resolve global + tenant config before bank-level overrides."""
config_dict = asdict(self._global_config)
if self.tenant_extension and context:
try:
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
if tenant_overrides:
# Normalize keys and filter to configurable fields only
normalized_tenant = normalize_config_dict(tenant_overrides)
configurable_tenant = {k: v for k, v in normalized_tenant.items() if k in self._configurable_fields}
config_dict.update(configurable_tenant)
logger.debug(
f"Applied tenant config overrides for bank {bank_id}: {list(configurable_tenant.keys())}"
)
except Exception as e:
logger.warning(f"Failed to load tenant config for bank {bank_id}: {e}")
return config_dict
async def resolve_full_config(self, bank_id: str, context: RequestContext | None = None) -> HindsightConfig:
"""
Resolve full HindsightConfig for a bank with hierarchical overrides applied.
@@ -65,23 +127,7 @@ class ConfigResolver:
Returns:
Complete HindsightConfig with hierarchical overrides applied
"""
# Start with global config (all fields)
config_dict = asdict(self._global_config)
# Load tenant config overrides (if tenant extension available)
if self.tenant_extension and context:
try:
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
if tenant_overrides:
# Normalize keys and filter to configurable fields only
normalized_tenant = normalize_config_dict(tenant_overrides)
configurable_tenant = {k: v for k, v in normalized_tenant.items() if k in self._configurable_fields}
config_dict.update(configurable_tenant)
logger.debug(
f"Applied tenant config overrides for bank {bank_id}: {list(configurable_tenant.keys())}"
)
except Exception as e:
logger.warning(f"Failed to load tenant config for bank {bank_id}: {e}")
config_dict = await self._resolve_parent_config_dict(bank_id, context)
# Load bank config overrides
bank_overrides = await self._load_bank_config(bank_id)
@@ -92,6 +138,26 @@ class ConfigResolver:
# Return full config object (dataclass doesn't have __init__ that accepts kwargs, so we update the object)
# Create a new config instance by copying the global config and updating fields
resolved_config = HindsightConfig(**config_dict)
# Multi-LLM chains and the reranker failover chain are static credential fields
# (never tenant/bank-overridable), but asdict() above flattened their member
# dataclasses into plain dicts. Restore the original typed objects from the global
# config so the resolved object stays well-typed for any consumer that reads them.
resolved_config = replace(
resolved_config,
reranker_members=self._global_config.reranker_members,
llm_members=self._global_config.llm_members,
llm_strategy=self._global_config.llm_strategy,
retain_llm_members=self._global_config.retain_llm_members,
retain_llm_strategy=self._global_config.retain_llm_strategy,
reflect_llm_members=self._global_config.reflect_llm_members,
reflect_llm_strategy=self._global_config.reflect_llm_strategy,
consolidation_llm_members=self._global_config.consolidation_llm_members,
consolidation_llm_strategy=self._global_config.consolidation_llm_strategy,
)
validate_retain_chunking_config(
resolved_config.retain_chunk_size,
resolved_config.retain_structured_chunk_size,
)
return resolved_config
async def get_bank_config(self, bank_id: str, context: RequestContext | None = None) -> dict[str, Any]:
@@ -122,26 +188,83 @@ class ConfigResolver:
resolved_config = await self.resolve_full_config(bank_id, context)
config_dict = asdict(resolved_config)
# SECURITY: Filter to only configurable fields (exclude static/infrastructure)
filtered = {k: v for k, v in config_dict.items() if k in self._configurable_fields}
# SECURITY: drop static/infrastructure + credential fields, then permission-filter.
filtered = self._strip_static_and_credential_fields(config_dict)
return await self._apply_permission_filter(filtered, bank_id, context)
# SECURITY: Remove ALL credential fields (API keys, base URLs, etc.)
filtered = {k: v for k, v in filtered.items() if k not in self._credential_fields}
def _strip_static_and_credential_fields(self, config_dict: dict[str, Any]) -> dict[str, Any]:
"""Keep only configurable, non-credential fields.
# PERMISSIONS: Further filter based on tenant/bank permissions
SECURITY: excludes static/infrastructure fields and ALL credential fields
(API keys, base URLs, etc.) so a resolved config is safe to return over the API.
"""
return {
k: v for k, v in config_dict.items() if k in self._configurable_fields and k not in self._credential_fields
}
async def _apply_permission_filter(
self, filtered: dict[str, Any], bank_id: str, context: RequestContext | None
) -> dict[str, Any]:
"""Further restrict already-stripped config to the tenant/bank permission allow-list.
On extension error, leaves ``filtered`` unchanged (parity with the historical
single-bank path: a permissions lookup failure must not leak or drop fields).
"""
if not (self.tenant_extension and context):
return filtered
try:
allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id)
if allowed_fields is not None: # None means "allow all"
filtered = {k: v for k, v in filtered.items() if k in allowed_fields}
logger.debug(
f"Applied permission filter for bank {bank_id}: allowed={len(allowed_fields)} fields, "
f"returned={len(filtered)} fields"
)
except Exception as e:
logger.warning(f"Failed to load permissions for bank {bank_id}: {e}")
return filtered
async def get_bank_configs(
self, bank_ids: list[str], context: RequestContext | None = None
) -> dict[str, dict[str, Any]]:
"""Batch variant of :meth:`get_bank_config` for many banks.
Equivalent to calling ``get_bank_config`` per bank, but resolves the
global + tenant base once and loads every bank's ``banks.config`` JSONB
in a single query, instead of one config round-trip per bank. Used by
``list_banks`` to overlay disposition + mission without an N+1.
Returns a mapping of bank_id -> filtered configurable-field dict. A bank
with no config row still appears, mapped to the global+tenant base.
"""
if not bank_ids:
return {}
# Global + tenant base, resolved once (tenant override is per-request, not per-bank).
base_dict = asdict(self._global_config)
if self.tenant_extension and context:
try:
allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id)
if allowed_fields is not None: # None means "allow all"
filtered = {k: v for k, v in filtered.items() if k in allowed_fields}
logger.debug(
f"Applied permission filter for bank {bank_id}: allowed={len(allowed_fields)} fields, "
f"returned={len(filtered)} fields"
)
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
if tenant_overrides:
normalized_tenant = normalize_config_dict(tenant_overrides)
base_dict.update({k: v for k, v in normalized_tenant.items() if k in self._configurable_fields})
except Exception as e:
logger.warning(f"Failed to load permissions for bank {bank_id}: {e}")
logger.warning(f"Failed to load tenant config for bulk resolve: {e}")
return filtered
# All bank overrides in one query, then merge + strip per bank.
bank_overrides = await self._load_bank_configs(bank_ids)
stripped = {
bank_id: self._strip_static_and_credential_fields({**base_dict, **bank_overrides.get(bank_id, {})})
for bank_id in bank_ids
}
# Permission filter is per-bank; resolve concurrently when an extension is present.
if not (self.tenant_extension and context):
return stripped
permission_filtered = await asyncio.gather(
*(self._apply_permission_filter(stripped[bank_id], bank_id, context) for bank_id in bank_ids)
)
return dict(zip(bank_ids, permission_filtered, strict=True))
async def _load_bank_config(self, bank_id: str) -> dict[str, Any]:
"""
@@ -174,17 +297,63 @@ class ConfigResolver:
# Only return active overrides for configurable fields. JSON null is a tombstone
# for "Server Default" in the bank-config UI and should not override defaults.
return {k: v for k, v in normalized.items() if k in self._configurable_fields and v is not None}
active = {k: v for k, v in normalized.items() if k in self._configurable_fields and v is not None}
return _coerce_stored_bank_overrides(bank_id, active)
except Exception as e:
logger.error(f"Failed to load bank config for {bank_id}: {e}")
return {}
async def update_bank_config(
self, bank_id: str, updates: dict[str, Any], context: RequestContext | None = None
) -> None:
async def _load_bank_configs(self, bank_ids: list[str]) -> dict[str, dict[str, Any]]:
"""Bulk variant of :meth:`_load_bank_config`: load many banks' overrides in one query.
Returns a mapping of bank_id -> normalized active overrides. Banks with no row
(or an empty/all-tombstone config) are simply absent from the mapping.
"""
Update bank configuration overrides (with permission checking).
result: dict[str, dict[str, Any]] = {}
if not bank_ids:
return result
try:
async with self._backend.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT bank_id, config FROM {fq_table("banks")} WHERE bank_id = ANY($1)
""",
bank_ids,
)
for row in rows:
config_data = row["config"]
if not config_data:
continue
# Handle case where JSONB is returned as JSON string
if isinstance(config_data, str):
config_data = json.loads(config_data)
# Normalize keys (handle both env var format and Python field format)
normalized = normalize_config_dict(config_data)
# Only active overrides for configurable fields. JSON null is a tombstone
# for "Server Default" in the bank-config UI and must not override defaults.
overrides = {
k: v for k, v in normalized.items() if k in self._configurable_fields and v is not None
}
if overrides:
result[row["bank_id"]] = _coerce_stored_bank_overrides(row["bank_id"], overrides)
except Exception as e:
logger.error(f"Failed to bulk-load bank configs: {e}")
return result
async def validate_bank_config_updates(
self,
bank_id: str,
updates: dict[str, Any],
context: RequestContext | None = None,
*,
projected_bank_overrides: dict[str, Any] | None = None,
check_permissions: bool = True,
) -> dict[str, Any]:
"""
Normalize and validate bank configuration overrides.
Args:
bank_id: Bank identifier
@@ -193,9 +362,16 @@ class ConfigResolver:
or Python field format (llm_provider).
Only configurable fields are allowed.
context: Request context for permission checking
projected_bank_overrides: Bank overrides to use as the validation
base instead of loading the current bank row.
check_permissions: Whether client field permissions apply to these
updates. Server-owned projected values set this to false.
Returns:
Normalized updates ready to persist.
Raises:
ValueError: If attempting to override invalid/disallowed fields
ValueError: If attempting to override invalid/disallowed fields.
"""
# Normalize keys
normalized_updates = normalize_config_dict(updates)
@@ -227,7 +403,7 @@ class ConfigResolver:
)
# PERMISSIONS: Check tenant/bank permissions
if self.tenant_extension and context:
if check_permissions and self.tenant_extension and context:
try:
allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id)
if allowed_fields is not None: # None means "allow all"
@@ -237,7 +413,7 @@ class ConfigResolver:
f"Not allowed to modify fields: {sorted(disallowed)}. "
f"Your permissions allow: {sorted(list(allowed_fields)[:10])}..."
if allowed_fields
else "Not allowed to modify fields: {sorted(disallowed)}. "
else f"Not allowed to modify fields: {sorted(disallowed)}. "
"Your permissions do not allow any config modifications."
)
except ValueError:
@@ -246,6 +422,11 @@ class ConfigResolver:
logger.warning(f"Failed to check permissions for bank {bank_id}: {e}")
# Continue without permission check (fail open for backward compatibility)
# Validate every value against its declared field type before the
# field-specific checks below, so a wrong-shaped value is reported as such
# instead of tripping a structural validator with a confusing message.
_validate_config_value_types(normalized_updates)
# Validate entity_labels structure
if "entity_labels" in normalized_updates and normalized_updates["entity_labels"] is not None:
from .engine.retain.entity_labels import parse_entity_labels
@@ -262,21 +443,70 @@ class ConfigResolver:
raise ValueError(
"Strategy names must not be empty strings. Remove entries with empty names before saving."
)
# A strategy's overrides are applied with dataclasses.replace() at retain
# time, so a wrong-shaped value there wedges the bank exactly as a
# top-level one would. Same contract, same door.
for strategy_name, strategy_overrides in normalized_updates["retain_strategies"].items():
if not isinstance(strategy_overrides, dict):
raise ValueError(f"Invalid retain strategy {strategy_name!r}: must be an object")
try:
_validate_config_value_types(normalize_config_dict(strategy_overrides))
except ValueError as e:
raise ValueError(f"Invalid retain strategy {strategy_name!r}: {e}") from e
# Validate recall budget fields
_validate_recall_budget_updates(normalized_updates)
# Persist the override. Banks are created lazily (on first retain), so a
# PATCH that precedes any ingestion would otherwise UPDATE zero rows and
# silently no-op while returning 200. Ensure the bank row exists first
# (this also creates its per-bank vector indexes), then merge defensively:
# COALESCE guards against a NULL config column (NULL || jsonb is NULL),
# which would drop the override even when a row is updated.
from .engine.retain.fact_storage import ensure_bank_exists
# Validate disposition trait fields (1-5 integer scale)
_validate_disposition_updates(normalized_updates)
chunking_fields_updated = (
"retain_chunk_size" in normalized_updates
or "retain_structured_chunk_size" in normalized_updates
or "retain_strategies" in normalized_updates
)
if chunking_fields_updated:
config_dict = await self._resolve_parent_config_dict(bank_id, context)
active_bank_overrides = (
await self._load_bank_config(bank_id)
if projected_bank_overrides is None
else dict(projected_bank_overrides)
)
for key, value in normalized_updates.items():
if key not in self._configurable_fields:
continue
if value is None:
active_bank_overrides.pop(key, None)
else:
active_bank_overrides[key] = value
config_dict.update(active_bank_overrides)
base_config = HindsightConfig(**config_dict)
validate_retain_chunking_config(
base_config.retain_chunk_size,
base_config.retain_structured_chunk_size,
)
_validate_retain_strategy_chunking(base_config, base_config.retain_strategies)
return normalized_updates
async def update_bank_config(
self, bank_id: str, updates: dict[str, Any], context: RequestContext | None = None
) -> None:
"""Validate and persist bank configuration overrides for an existing bank.
Bank creation belongs to ``MemoryEngine``; this raises ``ValueError`` if
the bank does not exist rather than silently discarding the overrides.
"""
normalized_updates = await self.validate_bank_config_updates(bank_id, updates, context)
await self._persist_bank_config(bank_id, normalized_updates)
async def _persist_bank_config(self, bank_id: str, normalized_updates: dict[str, Any]) -> None:
"""Persist already-validated overrides without changing bank lifecycle state."""
# Bank lifecycle belongs to MemoryEngine. Callers must create the row
# before reaching this persistence step. COALESCE guards against a NULL
# config column (NULL || jsonb is NULL), which would drop the override.
async with self._backend.acquire() as conn:
await ensure_bank_exists(conn, bank_id, ops=self._backend.ops)
await conn.execute(
result = await conn.execute(
f"""
UPDATE {fq_table("banks")}
SET config = COALESCE(config, '{{}}'::jsonb) || $1::jsonb,
@@ -287,6 +517,14 @@ class ConfigResolver:
bank_id,
)
# A missing bank row matches zero rows, which would otherwise persist
# nothing while reporting success. Fail loudly instead: reaching here
# without the row means a caller skipped the engine's provisioning step.
# (The Oracle wrapper reshapes rowcount into the same "UPDATE <n>" form.)
updated = int(result.split()[-1]) if isinstance(result, str) and result.startswith("UPDATE") else 0
if updated == 0:
raise BankConfigPersistenceConflictError(bank_id)
logger.info(f"Updated bank config for {bank_id}: {list(normalized_updates.keys())}")
async def reset_bank_config(self, bank_id: str) -> None:
@@ -310,6 +548,147 @@ class ConfigResolver:
logger.info(f"Reset bank config for {bank_id} to defaults")
# Fields whose accepted input shape is deliberately wider than the dataclass
# annotation, because a dedicated structural validator normalizes them later.
_WIDENED_FIELD_TYPES: dict[str, tuple[type, ...]] = {
# parse_entity_labels() accepts both the bare list of label groups and the
# {"attributes": [...]} envelope, though the field is annotated `list | None`.
"entity_labels": (list, dict),
}
def _runtime_types(declared: Any) -> tuple[type, ...]:
"""Runtime-checkable base classes for a dataclass field annotation.
Unwraps unions (``str | None``) and generic aliases (``list[str]`` -> ``list``);
``None`` is dropped because callers handle the tombstone separately. Returns an
empty tuple for anything not reducible to concrete classes, which the callers
read as "no type contract to enforce".
"""
if declared is type(None):
return ()
origin = get_origin(declared)
if origin in (Union, UnionType):
return tuple(t for arg in get_args(declared) for t in _runtime_types(arg))
if origin is not None:
return (origin,) if isinstance(origin, type) else ()
return (declared,) if isinstance(declared, type) else ()
@lru_cache(maxsize=1)
def _configurable_field_types() -> dict[str, tuple[type, ...]]:
"""Map each configurable field to the value types it accepts."""
configurable = HindsightConfig.get_configurable_fields()
field_types: dict[str, tuple[type, ...]] = {}
for field in fields(HindsightConfig):
if field.name not in configurable:
continue
allowed = _WIDENED_FIELD_TYPES.get(field.name) or _runtime_types(field.type)
if allowed:
field_types[field.name] = allowed
return field_types
def _value_matches_type(value: Any, allowed: tuple[type, ...]) -> bool:
"""Whether ``value`` satisfies a field's declared type contract."""
if isinstance(value, bool):
# bool is an int subclass; it must not slip into a numeric field.
return bool in allowed
if isinstance(value, int) and float in allowed:
# JSON draws no int/float distinction: 1 is a valid ratio.
return True
return isinstance(value, allowed)
# Field types are reported to API clients, so name them the way the JSON payload
# reads rather than by their Python class.
_TYPE_DESCRIPTIONS: dict[type, str] = {
bool: "a boolean",
int: "an integer",
float: "a number",
str: "a string",
list: "a list",
dict: "an object",
}
def _describe_types(allowed: tuple[type, ...]) -> str:
return " or ".join(dict.fromkeys(_TYPE_DESCRIPTIONS.get(t, t.__name__) for t in allowed))
def _validate_config_value_types(updates: dict[str, Any]) -> None:
"""Reject values whose type contradicts the declared HindsightConfig type.
Without this, the bank-config API happily stores e.g. a JSON object in
``observations_mission``; the write succeeds and the bank then fails every
consolidation with ``expected string or bytes-like object, got 'dict'`` from
deep inside prompt assembly (issue #3218). Reject at the door instead, naming
the field and the expected type.
"""
field_types = _configurable_field_types()
for key, value in updates.items():
allowed = field_types.get(key)
# None is the "clear this override" tombstone; unknown keys are rejected
# elsewhere as non-configurable.
if allowed is None or value is None:
continue
if not _value_matches_type(value, allowed):
raise ValueError(f"{key} must be {_describe_types(allowed)}, got {type(value).__name__}")
def _coerce_stored_bank_overrides(bank_id: str, overrides: dict[str, Any], where: str = "") -> dict[str, Any]:
"""Make stored bank overrides safe to consume, tolerating pre-validation shapes.
``_validate_config_value_types`` rejects bad types at write time, but banks
configured before that landed can still hold e.g. a JSON object in a
string-typed field. Every consumer that treats such a value as text blows up
identically on every run (``escape_for_prompt`` -> ``re.sub`` ->
"expected string or bytes-like object, got 'dict'"), so the bank's
consolidation never recovers on its own (issue #3218).
String fields are JSON-encoded, which preserves the author's intent — the
structure still reaches the prompt, as text. Anything else is dropped so the
bank falls back to the tenant/global value rather than wedging.
``where`` labels the location in warnings; it is set when recursing into a
retain strategy, whose overrides reach the same fields via ``apply_strategy``.
"""
field_types = _configurable_field_types()
coerced: dict[str, Any] = {}
for key, value in overrides.items():
allowed = field_types.get(key)
# None passes through: the caller has already dropped top-level tombstones,
# and inside a retain strategy a null is a deliberate override to None.
if allowed is None or value is None or _value_matches_type(value, allowed):
coerced[key] = value
continue
if str in allowed:
coerced[key] = json.dumps(value, ensure_ascii=False)
logger.warning(
f"Bank {bank_id} config field '{key}'{where} holds a {type(value).__name__} but is a string field; "
f"using its JSON encoding. Re-save this field as a string to silence this warning."
)
else:
logger.warning(
f"Bank {bank_id} config field '{key}'{where} holds a {type(value).__name__} but must be "
f"{_describe_types(allowed)}; ignoring the override and falling back to the server default."
)
# Strategy overrides are spliced onto the resolved config by apply_strategy(),
# so a bad value nested there wedges the bank just as a top-level one does.
strategies = coerced.get("retain_strategies")
if isinstance(strategies, dict):
coerced["retain_strategies"] = {
name: (
_coerce_stored_bank_overrides(bank_id, strategy, where=f" in retain strategy {name!r}")
if isinstance(strategy, dict)
else strategy
)
for name, strategy in strategies.items()
}
return coerced
_RECALL_BUDGET_FIXED_KEYS = (
"recall_budget_fixed_low",
"recall_budget_fixed_mid",
@@ -357,6 +736,31 @@ def _validate_recall_budget_updates(updates: dict[str, Any]) -> None:
)
_DISPOSITION_KEYS = (
"disposition_skepticism",
"disposition_literalism",
"disposition_empathy",
)
def _validate_disposition_updates(updates: dict[str, Any]) -> None:
"""Validate disposition trait config updates. Raises ValueError on invalid input.
Each trait is an integer on a 1-5 scale (or None to clear the per-bank
override). The read overlay injects the stored value verbatim into a strict
``DispositionTraits(int, ge=1, le=5)``; an out-of-contract value (a float, a
0-1 scale, or an int outside 1-5) accepted here would later 500 the whole
bank list when any bank profile is serialized (issue #2348).
"""
for key in _DISPOSITION_KEYS:
if key in updates:
value = updates[key]
if value is None:
continue
if not isinstance(value, int) or isinstance(value, bool) or not (1 <= value <= 5):
raise ValueError(f"{key} must be an integer between 1 and 5, got {value!r}")
def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConfig:
"""
Apply a named retain strategy's overrides on top of a resolved config.
@@ -364,7 +768,8 @@ def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConf
A strategy is a named set of hierarchical field overrides stored in
config.retain_strategies. Any field in _HIERARCHICAL_FIELDS can be
overridden, including retain_extraction_mode, retain_chunk_size,
entity_labels, entities_allow_free_form, etc.
retain_structured_chunk_size, entity_labels,
entities_allow_free_form, etc.
Unknown strategy names log a warning and return config unchanged.
Unknown or non-hierarchical fields in the strategy are silently ignored.
@@ -386,4 +791,17 @@ def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConf
return config
logger.debug(f"Applying retain strategy '{strategy_name}': {list(filtered.keys())}")
return replace(config, **filtered)
resolved = replace(config, **filtered)
validate_retain_chunking_config(
resolved.retain_chunk_size,
resolved.retain_structured_chunk_size,
)
validate_retain_completion_token_budget(
llm_provider=resolved.llm_provider,
retain_max_completion_tokens=resolved.retain_max_completion_tokens,
retain_chunk_size=resolved.retain_chunk_size,
retain_llm_model=resolved.retain_llm_model,
llm_model=resolved.llm_model,
retain_llm_provider=resolved.retain_llm_provider,
)
return resolved
+4 -30
View File
@@ -14,10 +14,7 @@ import subprocess
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import IO
from typing import IO
logger = logging.getLogger(__name__)
@@ -42,37 +39,28 @@ class IdleTimeoutMiddleware:
self.app = app
self.idle_timeout = idle_timeout
self.last_activity = time.time()
self._checker_task = None
async def __call__(self, scope, receive, send):
# Update activity timestamp on each request
self.last_activity = time.time()
await self.app(scope, receive, send)
def start_idle_checker(self):
"""Start the background task that checks for idle timeout."""
self._checker_task = asyncio.create_task(self._check_idle())
async def _check_idle(self):
"""Background task that exits the process after idle timeout."""
# If idle_timeout is 0, don't auto-exit
"""Exit the daemon after the configured period without requests."""
if self.idle_timeout <= 0:
return
while True:
await asyncio.sleep(30) # Check every 30 seconds
await asyncio.sleep(30)
idle_time = time.time() - self.last_activity
if idle_time > self.idle_timeout:
logger.info(f"Idle timeout reached ({self.idle_timeout}s), shutting down daemon")
# Give a moment for any in-flight requests
await asyncio.sleep(1)
# Send SIGTERM to ourselves to trigger graceful shutdown
import signal
os.kill(os.getpid(), signal.SIGTERM)
def _detach_popen_kwargs(log_handle: "IO[bytes]") -> dict:
def _detach_popen_kwargs(log_handle: IO[bytes]) -> dict:
"""Cross-platform kwargs to spawn a subprocess detached from the caller.
On POSIX, ``start_new_session=True`` calls ``setsid(2)`` so the child
@@ -169,17 +157,3 @@ def daemonize():
subprocess.Popen(cmd, env=env, **_detach_popen_kwargs(log_handle))
sys.exit(0)
def check_daemon_running(port: int = DEFAULT_DAEMON_PORT) -> bool:
"""Check if a daemon is running and responsive on the given port."""
import socket
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(1)
result = sock.connect_ex(("127.0.0.1", port))
sock.close()
return result == 0
except Exception:
return False
@@ -3,7 +3,7 @@ Memory Engine - Core implementation of the memory system.
This package contains all the implementation details of the memory engine:
- MemoryEngine: Main class for memory operations
- Utility modules: embedding_utils, link_utils, think_utils, bank_utils
- Utility modules: embedding_utils, link_utils, bank_utils
- Supporting modules: embeddings, cross_encoder, entity_resolver, etc.
"""
@@ -10,7 +10,7 @@ import asyncio
import json
import logging
import uuid
from collections.abc import Callable
from collections.abc import Awaitable, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from datetime import datetime, timezone
@@ -19,6 +19,8 @@ from typing import Any
from pydantic import BaseModel, Field
from ..engine.db_utils import acquire_with_retry
from ..models import RequestContext
from .schema import fq_table_explicit
logger = logging.getLogger(__name__)
@@ -119,23 +121,60 @@ class AuditLogger:
schema_getter: Callable[[], str],
enabled: bool,
allowed_actions: list[str],
bank_enabled_resolver: Callable[[str, RequestContext | None], Awaitable[bool]] | None = None,
) -> None:
self._pool_getter = pool_getter
self._schema_getter = schema_getter
self._enabled = enabled
self._allowed_actions: frozenset[str] | None = frozenset(allowed_actions) if allowed_actions else None
# Resolves the hierarchical ``audit_log_enabled`` for one bank
# (env -> tenant -> bank). None means "no per-bank resolution wired",
# in which case the global value alone decides.
self._bank_enabled_resolver = bank_enabled_resolver
def is_enabled(self, action: str) -> bool:
"""Check if audit logging is enabled for this action."""
if not self._enabled:
def action_allowed(self, action: str) -> bool:
"""Global action-allowlist check. Cheap, synchronous, bank-independent.
The allowlist is deployment-wide, so this is a valid pre-filter to skip
work for actions that can never be audited. It deliberately does NOT
consult the enabled flag: that is per-bank overridable, so a bank may
turn auditing ON even when the deployment default is off.
"""
if self._allowed_actions is None:
return True
return action in self._allowed_actions
async def should_log(self, action: str, bank_id: str | None, context: RequestContext | None = None) -> bool:
"""Full audit decision: action allowlist AND the bank's resolved switch.
``audit_log_enabled`` is hierarchical (env -> tenant -> bank), so the
effective value depends on which bank the action targets. Falls back to
the global value when there is no bank in scope or no resolver wired.
"""
if not self.action_allowed(action):
return False
if self._allowed_actions is not None:
return action in self._allowed_actions
return True
if bank_id is None or self._bank_enabled_resolver is None:
return self._enabled
try:
return await self._bank_enabled_resolver(bank_id, context)
except Exception as e:
# Never let a config-resolution failure break the request. Fall back
# to the deployment default: a transient DB blip must not silently
# create an audit gap for a bank meant to be audited. The tradeoff is
# the opt-out direction — a bank that overrode to false under a
# default-on deployment will be audited during the outage. We accept
# that: a few extra audit rows during a DB blip is the safer failure
# than dropping records that compliance may require.
logger.warning(f"Audit config resolution failed for bank={bank_id}: {e}; using global default")
return self._enabled
def log_fire_and_forget(self, entry: AuditEntry) -> None:
"""Schedule an audit write as a background task."""
if not self.is_enabled(entry.action):
"""Schedule an audit write as a background task.
Assumes the caller already made the audit decision via ``should_log``;
only the bank-independent allowlist is re-checked here.
"""
if not self.action_allowed(entry.action):
return
try:
asyncio.create_task(self._safe_log(entry))
@@ -150,8 +189,12 @@ class AuditLogger:
logger.debug("Audit log skipped: pool not available")
return
try:
schema = self._schema_getter()
table = f"{schema}.audit_log"
# fq_table_explicit qualifies per dialect: "schema".audit_log on
# PostgreSQL, bare audit_log on Oracle (where the schema is set at the
# session level). A raw f"{schema}.audit_log" produced public.audit_log
# on Oracle, where "public" is a reserved word — every write failed
# with ORA-00903 even though the table exists.
table = fq_table_explicit("audit_log", self._schema_getter())
async with acquire_with_retry(pool, max_retries=1) as conn:
await conn.execute(
f"""
@@ -182,6 +225,7 @@ async def audit_context(
bank_id: str | None = None,
request: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
context: RequestContext | None = None,
):
"""Async context manager that times the operation and writes audit on exit.
@@ -190,7 +234,7 @@ async def audit_context(
result = await do_work()
entry.response = result_dict
"""
if audit_logger is None or not audit_logger.is_enabled(action):
if audit_logger is None or not await audit_logger.should_log(action, bank_id, context):
entry = AuditEntry(action=action, transport=transport, bank_id=bank_id)
yield entry
return
@@ -0,0 +1,47 @@
"""Per-bank provider cost attribution via the OpenAI ``user`` field.
Shared by the OpenAI-compatible LLM path and the OpenAI embeddings path so both
tag outbound requests identically. Opt-in via ``HINDSIGHT_API_LLM_SEND_BANK_AS_USER``;
downstream cost gateways (OpenRouter usage accounting, LiteLLM, Helicone) key spend
on the OpenAI ``user`` field.
Note: when enabled, the bank id is transmitted to the upstream provider as the
end-user identifier. Banks that are themselves end-user identifiers are therefore
forwarded to the provider — which is exactly what the OpenAI ``user`` field is for,
but operators should opt in with that in mind.
"""
from typing import Any
RERANKER_BANK_ID_HEADER = "X-Hindsight-Bank-Id"
def apply_bank_attribution(request: dict[str, Any]) -> None:
"""Tag ``request`` with ``user=<bank_id>`` for per-bank cost attribution.
Mutates ``request`` in place. No-op when the flag is off, no bank is in context,
or the caller already set ``user`` — we never override an explicit value.
"""
if "user" in request:
return
# Lazy imports: memory_engine imports the embeddings/provider modules that call
# this, so a top-level import of memory_engine here would be circular.
from ..config import get_config
from .memory_engine import get_current_bank_id
if not get_config().llm_send_bank_as_user:
return
bank_id = get_current_bank_id()
if bank_id:
request["user"] = bank_id
def reranker_bank_attribution_headers() -> dict[str, str]:
"""Return the fixed per-bank header for trusted remote reranker endpoints."""
from ..config import get_config
from .memory_engine import get_current_bank_id
if not get_config().reranker_send_bank_as_header:
return {}
bank_id = get_current_bank_id()
return {RERANKER_BANK_ID_HEADER: bank_id} if bank_id else {}
@@ -13,9 +13,18 @@ in-flight task so that N concurrent callers produce one query rather than N.
from __future__ import annotations
import asyncio
import json
import logging
import time
from collections import OrderedDict
from typing import Any, Awaitable, Callable
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from .db_utils import acquire_with_retry
if TYPE_CHECKING:
from .db.base import DatabaseBackend
logger = logging.getLogger(__name__)
class BankStatsCache:
@@ -66,17 +75,28 @@ class BankStatsCache:
schema: str,
bank_id: str,
loader: Callable[[], Awaitable[dict[str, Any]]],
*,
force_refresh: bool = False,
) -> dict[str, Any]:
"""Return cached stats for `(schema, bank_id)` or call `loader()`.
Concurrent misses on the same key are coalesced onto a single
in-flight loader.
in-flight loader. When ``force_refresh`` is set the cached value is
ignored: the loader runs and its result replaces the cached entry.
"""
if not self.enabled:
return await loader()
key = (schema, bank_id)
if force_refresh:
value = await loader()
async with self._lock:
self._store_unlocked(key, value)
# Supersede any loader that was in flight for this key.
self._in_flight.pop(key, None)
return value
async with self._lock:
cached = self._get_fresh_unlocked(key)
if cached is not None:
@@ -96,7 +116,10 @@ class BankStatsCache:
value = await loader()
except BaseException as exc:
async with self._lock:
self._in_flight.pop(key, None)
# Invalidation may have detached this loader and allowed a new
# one to claim the key. Never remove that newer loader's slot.
if self._in_flight.get(key) is in_flight:
self._in_flight.pop(key, None)
if not in_flight.done():
in_flight.set_exception(exc)
# Suppress "Future exception was never retrieved" when no other
@@ -106,8 +129,12 @@ class BankStatsCache:
raise
async with self._lock:
self._store_unlocked(key, value)
self._in_flight.pop(key, None)
# Only the loader that still owns the key may populate the cache.
# An invalidated loader can finish for its original callers, but its
# pre-invalidation result must not overwrite a newer load.
if self._in_flight.get(key) is in_flight:
self._store_unlocked(key, value)
self._in_flight.pop(key, None)
if not in_flight.done():
in_flight.set_result(value)
return value
@@ -115,8 +142,113 @@ class BankStatsCache:
async def invalidate(self, schema: str, bank_id: str) -> None:
"""Drop any cached stats for `(schema, bank_id)`."""
async with self._lock:
self._entries.pop((schema, bank_id), None)
key = (schema, bank_id)
self._entries.pop(key, None)
# Detach rather than cancel: existing callers may finish with the
# snapshot they requested, while post-invalidation callers reload.
self._in_flight.pop(key, None)
async def clear(self) -> None:
async with self._lock:
self._entries.clear()
self._in_flight.clear()
class DistributedBankStatsCache:
"""Table-backed (cross-process) TTL cache for `get_bank_stats`.
Same ``get_or_load`` / ``invalidate`` / ``clear`` contract as
:class:`BankStatsCache`, but the store is the per-schema ``bank_stats_cache``
table instead of a per-process dict — so one worker's computation is shared
with every other worker, and no caller recomputes while a fresh row exists.
On a hit, a call is a single primary-key ``SELECT`` (sub-millisecond); only a
miss runs the (expensive) ``loader`` and writes the row back. Concurrent
misses are *not* coalesced across processes (that would need a lock): they
each compute and ``UPSERT``, last write wins — all results are correct, at the
cost of a brief redundant compute at expiry.
Every DB touch is best-effort: if the cache table is unreachable or missing
(e.g. a schema mid-migration), the call degrades to computing without caching
rather than failing ``get_bank_stats``. PostgreSQL only — the engine keeps the
in-process :class:`BankStatsCache` for Oracle.
"""
def __init__(self, *, backend: "DatabaseBackend", ttl_seconds: float) -> None:
self._backend = backend
self._ttl = float(ttl_seconds)
@property
def enabled(self) -> bool:
return self._ttl > 0
@staticmethod
def _qualified(schema: str) -> str:
return f'"{schema}".bank_stats_cache' if schema else "bank_stats_cache"
async def get_or_load(
self,
schema: str,
bank_id: str,
loader: Callable[[], Awaitable[dict[str, Any]]],
*,
force_refresh: bool = False,
) -> dict[str, Any]:
if not self.enabled:
return await loader()
table = self._qualified(schema)
# 1. Fresh row? Single PK lookup; ``payload::text`` sidesteps any
# jsonb->object codec so we always decode the same way. Skipped when
# the caller forces a refresh — then we recompute and overwrite below.
if not force_refresh:
try:
async with acquire_with_retry(self._backend) as conn:
row = await conn.fetchrow(
f"SELECT payload::text AS payload FROM {table} "
f"WHERE bank_id = $1 AND computed_at > now() - make_interval(secs => $2::double precision)",
bank_id,
self._ttl,
)
if row is not None:
return json.loads(row["payload"])
except Exception as exc: # noqa: BLE001 — cache read must never break the endpoint
logger.debug("bank_stats_cache read failed for %s.%s (%s); computing uncached", schema, bank_id, exc)
return await loader()
# 2. Miss — compute, then write the row back (best-effort).
value = await loader()
try:
async with acquire_with_retry(self._backend) as conn:
await conn.execute(
f"INSERT INTO {table} (bank_id, payload, computed_at) VALUES ($1, $2::jsonb, now()) "
f"ON CONFLICT (bank_id) DO UPDATE SET payload = EXCLUDED.payload, computed_at = now()",
bank_id,
json.dumps(value),
)
except Exception as exc: # noqa: BLE001 — a failed write just means no caching this round
logger.warning("bank_stats_cache write failed for %s.%s (%s)", schema, bank_id, exc)
return value
async def invalidate(self, schema: str, bank_id: str) -> None:
"""Drop the cached row so the next read recomputes."""
if not self.enabled:
return
try:
async with acquire_with_retry(self._backend) as conn:
await conn.execute(f"DELETE FROM {self._qualified(schema)} WHERE bank_id = $1", bank_id)
except Exception as exc: # noqa: BLE001 — invalidation must never break the write path
logger.debug("bank_stats_cache invalidate failed for %s.%s (%s)", schema, bank_id, exc)
async def clear(self) -> None:
"""Drop all cached rows in the current schema (best-effort)."""
if not self.enabled:
return
from .memory_engine import get_current_schema
try:
async with acquire_with_retry(self._backend) as conn:
await conn.execute(f"DELETE FROM {self._qualified(get_current_schema())}")
except Exception as exc: # noqa: BLE001
logger.debug("bank_stats_cache clear failed (%s)", exc)
@@ -0,0 +1,185 @@
"""Server-side prompt-cache affinity hints for OpenAI-compatible providers.
Prompt caching only pays off when the same conversation reaches the same backend
cache, and providers expose different mechanisms for that:
- xAI stores prompt-cache entries **per backend server** and routes requests
carrying the same ``x-grok-conv-id`` to one server (docs.x.ai, "Maximizing
Cache Hits"). Without it, consecutive calls of one agentic loop can each land
on a cache-cold replica.
- OpenAI accepts a ``prompt_cache_key`` request field that improves its own
cache routing.
Hindsight already does provider-specific cache work for its first-class
providers (``anthropic_llm`` sets ``cache_control`` breakpoints; ``gemini_llm``
runs an explicit ``CachedContent`` manager). This module is the equivalent for
the OpenAI-compatible family — ``OpenAICompatibleLLM`` and its ``fireworks``
and ``nous`` subclasses — which sent no affinity hint at all.
Default ``auto`` per member (``cache_affinity``). ``auto`` is an allowlist, not a
best-effort probe: it emits a hint only for hosts documented to accept one and
resolves to ``none`` for everything else, so an unknown OpenAI-compatible backend
never receives an unfamiliar field. Every helper here is fail-open — when no id
can be derived the request goes out byte-identical to before. Set ``none`` to
disable entirely.
"""
from __future__ import annotations
import hashlib
import json
import logging
from enum import StrEnum
from typing import Any
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
# xAI's documented cache-pinning header, and OpenAI's cache-routing field.
XAI_CONV_ID_HEADER = "x-grok-conv-id"
OPENAI_PROMPT_CACHE_KEY_PARAM = "prompt_cache_key"
# Hosts (exact or parent domain) whose backends implement the xAI header.
_XAI_DOMAINS = ("x.ai", "grok.com")
# Hosts (exact or parent domain) that accept OpenAI's prompt_cache_key field.
_OPENAI_DOMAINS = ("openai.com", "openai.azure.com")
class CacheAffinityMode(StrEnum):
"""How (and whether) to pin a request to a backend prompt cache."""
NONE = "none"
XAI_CONV_ID = "xai_conv_id"
OPENAI_PROMPT_CACHE_KEY = "openai_prompt_cache_key"
AUTO = "auto"
def parse_cache_affinity(value: str | None) -> CacheAffinityMode:
"""Validate a configured cache-affinity mode, defaulting to ``none``.
Raises ``ValueError`` on an unrecognized value so a typo fails loudly at
provider construction rather than silently disabling the feature — the whole
point of the setting is that its effect is invisible in the response.
"""
if not value:
return CacheAffinityMode.NONE
try:
return CacheAffinityMode(value.strip().lower())
except ValueError as e:
valid = ", ".join(mode.value for mode in CacheAffinityMode)
raise ValueError(f"Invalid cache_affinity {value!r}. Must be one of: {valid}.") from e
def _host_matches(hostname: str, domain: str) -> bool:
"""True when ``hostname`` is ``domain`` itself or a subdomain of it.
Parsed-host suffix matching, never a substring test: a bare
``"x.ai" in base_url`` also matches ``vertex.ai`` and
``https://x.ai.evil.example``. The in-tree Azure check
(``".openai.azure.com" in self.base_url``) gets away with a substring only
because its needle is long and dotted; ``x.ai`` is four characters.
"""
return hostname == domain or hostname.endswith(f".{domain}")
def resolve_cache_affinity(mode: CacheAffinityMode, provider: str, base_url: str | None) -> CacheAffinityMode:
"""Resolve ``auto`` to a concrete mode from the provider and base-URL host.
Non-``auto`` modes are returned unchanged. ``auto`` resolves to
``xai_conv_id`` for an x.ai / grok.com host, ``openai_prompt_cache_key`` for
native OpenAI (no base URL) or an openai.com / Azure OpenAI host, and
``none`` for everything else — an unknown backend gets no unfamiliar field.
The xAI check is host-only and deliberately provider-independent: the
documented setup for an xAI endpoint is ``provider=openai`` plus an x.ai base
URL, exactly like Azure OpenAI, so keying on the provider name would miss it.
"""
if mode is not CacheAffinityMode.AUTO:
return mode
hostname = (urlparse(base_url).hostname or "") if base_url else ""
if hostname and any(_host_matches(hostname, domain) for domain in _XAI_DOMAINS):
return CacheAffinityMode.XAI_CONV_ID
if provider.lower() == "openai":
if not hostname:
return CacheAffinityMode.OPENAI_PROMPT_CACHE_KEY
if any(_host_matches(hostname, domain) for domain in _OPENAI_DOMAINS):
return CacheAffinityMode.OPENAI_PROMPT_CACHE_KEY
return CacheAffinityMode.NONE
def _first_message_fingerprint(messages: Any) -> str | None:
"""Hash the first message into a 32-hex id, or None if the shape is wrong.
Used only when no trace context is bound (direct provider use, tests). The
first message is the system prompt, so the id is stable as the message list
grows through an agent loop — which is the property cache pinning needs —
while differing across conversations whose first messages differ.
Shape-checked rather than truthiness-checked: a bare string ``messages``
would index to its first character and mint an id from garbage. Anything
unexpected returns None and the request goes out with no affinity hint.
"""
if not isinstance(messages, list) or not messages or not isinstance(messages[0], dict):
return None
try:
canonical = json.dumps(messages[0], sort_keys=True, ensure_ascii=False, default=str)
except (TypeError, ValueError):
logger.debug("Cache affinity: first message not serializable; sending no hint", exc_info=True)
return None
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32]
def cache_affinity_id(messages: Any) -> str | None:
"""Return the affinity id for the in-flight call, or None to send nothing.
Primary source is the operation's ``trace_id`` — one uuid per
retain/reflect/consolidation run, generated in ``LLMProvider.with_config``
and bound around every underlying provider call, so every LLM call of one
run shares it. That is engine identity rather than payload hashing: it stays
constant across a run even when the first message changes mid-run.
The value is always 32 lowercase hex characters, including for the trace_id
path (hashed rather than passed through) so the wire format is uniform and
carries no uuid semantics.
"""
from .llm_trace import current_trace_context
trace_ctx = current_trace_context()
if trace_ctx is not None and trace_ctx.trace_id:
return hashlib.sha256(str(trace_ctx.trace_id).encode("utf-8")).hexdigest()[:32]
return _first_message_fingerprint(messages)
def apply_cache_affinity(request: dict[str, Any], mode: CacheAffinityMode) -> None:
"""Add this request's cache-affinity hint to ``request`` in place.
``mode`` must already be resolved (see :func:`resolve_cache_affinity`);
``none`` — and an unresolved ``auto`` — add nothing.
User-wins semantics throughout, matching the file's ``setdefault`` precedent
in ``_apply_provider_extra_body_defaults``: an ``x-grok-conv-id`` the caller
already placed in ``extra_headers`` is kept, and a ``prompt_cache_key`` in
the operator's configured ``extra_body`` (the escape hatch for a backend
that wants its own value) suppresses ours entirely.
Never raises: when no id can be derived the request is left byte-identical
to a pre-affinity one.
"""
affinity_id = cache_affinity_id(request.get("messages"))
if affinity_id is None:
return
if mode is CacheAffinityMode.XAI_CONV_ID:
extra_headers = request.setdefault("extra_headers", {})
extra_headers.setdefault(XAI_CONV_ID_HEADER, affinity_id)
elif mode is CacheAffinityMode.OPENAI_PROMPT_CACHE_KEY:
# prompt_cache_key is a first-class named parameter on
# chat.completions.create() in the resolved openai SDK, so it goes at the
# top level rather than through extra_body. An operator value in
# extra_body would still reach the same wire field, so honour it and
# send nothing rather than sending both.
extra_body = request.get("extra_body")
if isinstance(extra_body, dict) and OPENAI_PROMPT_CACHE_KEY_PARAM in extra_body:
return
request.setdefault(OPENAI_PROMPT_CACHE_KEY_PARAM, affinity_id)
@@ -0,0 +1,70 @@
"""Shared causal-link taxonomy.
Retain writes only the canonical relationship. Transfer import/export also
preserves historical relationship types so existing banks keep their graph
semantics without allowing new retain output to create those types.
"""
from dataclasses import dataclass
from typing import Any
CANONICAL_CAUSAL_LINK_TYPE = "caused_by"
LEGACY_CAUSAL_LINK_TYPE_NAMES = ("causes", "enables", "prevents")
CANONICAL_CAUSAL_LINK_TYPES = frozenset({CANONICAL_CAUSAL_LINK_TYPE})
LEGACY_CAUSAL_LINK_TYPES = frozenset(LEGACY_CAUSAL_LINK_TYPE_NAMES)
CAUSAL_LINK_TYPES = (CANONICAL_CAUSAL_LINK_TYPE, *LEGACY_CAUSAL_LINK_TYPE_NAMES)
DEFAULT_CAUSAL_LINK_WEIGHT = 1.0
@dataclass(frozen=True)
class CausalLinkDescriptor:
"""One causal edge, parked on the curation archive while an endpoint is invalidated.
Invalidation moves a fact out of ``memory_units``, so the FK cascade deletes
its ``memory_links`` rows — and nothing could recreate a causal edge, which
is extraction output rather than derived data. The descriptor is what the
archive row stores so revert can rematerialize the edge (#2864).
"""
from_unit_id: str
to_unit_id: str
link_type: str
weight: float = DEFAULT_CAUSAL_LINK_WEIGHT
def as_json_dict(self) -> dict[str, Any]:
"""Serializable form written to ``invalidated_memory_units.causal_links``.
The key names double as the column list of the ``jsonb_to_recordset``
read in ``snapshot_causal_links`` — keep them in sync.
"""
return {
"from_unit_id": self.from_unit_id,
"to_unit_id": self.to_unit_id,
"link_type": self.link_type,
"weight": self.weight,
}
@classmethod
def from_json_dict(cls, raw: Any) -> "CausalLinkDescriptor | None":
"""Parse one stored descriptor, or None when it isn't a usable causal edge.
The archive column is plain JSON with no schema enforcement (a restore
from an older backup, or a hand-edited row, can put anything there), and
``memory_links`` has a ``link_type`` CHECK constraint — so an unusable
entry is skipped rather than allowed to abort the whole revert.
"""
if not isinstance(raw, dict):
return None
from_unit_id = raw.get("from_unit_id")
to_unit_id = raw.get("to_unit_id")
link_type = raw.get("link_type")
if not from_unit_id or not to_unit_id or link_type not in CAUSAL_LINK_TYPES:
return None
return cls(
from_unit_id=str(from_unit_id),
to_unit_id=str(to_unit_id),
link_type=str(link_type),
weight=float(raw.get("weight") or DEFAULT_CAUSAL_LINK_WEIGHT),
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -17,6 +17,20 @@ _MISSION_PRIORITY_NOTE = (
"DECISION GUIDE, or OUTPUT FORMAT below, the MISSION takes priority."
)
# Default language rule — used only when HINDSIGHT_API_LLM_OUTPUT_LANGUAGE is
# unset. Without it the whole prompt is English and multilingual models drift:
# Chinese source facts intermittently produce English observations. Retain's
# fact extraction carries the equivalent rule (see _BASE_FACT_EXTRACTION_PROMPT),
# so this makes "preserve the source language" the pipeline-wide default. When an
# output language IS configured, this section is omitted and
# output_language_directive() takes over — the two must never both be present or
# they contradict each other.
_DEFAULT_LANGUAGE_RULE = """## LANGUAGE
Write every observation in the language of its own source facts — never translate them. Per observation, not per batch: when one merges facts of several languages, the majority wins. Proper nouns, identifiers, and units stay verbatim.
When an existing observation is written in a different language from the new facts updating it, do NOT edit its wording in place — that is what produces an English sentence with a Chinese detail bolted on. Discard the old phrasing and compose the merged observation from scratch in the new facts' language."""
_PROCESSING_RULES = """## PROCESSING RULES
1. PREFER UPDATE OVER CREATE (when there is something to merge with): if new facts describe the same canonical event, statement, decision, claim, or recurring pattern already covered by an existing observation, UPDATE that observation and attach the new facts as evidence. Do NOT create a near-duplicate sibling. One canonical observation with many source facts is always better than many siblings with one source fact each. Merge aggressively on: same named event, same diagnostic finding, same architectural decision, same recurring claim. **When the EXISTING OBSERVATIONS list is empty, or no existing observation covers the same facet as a new fact, CREATE a new observation** — this rule is about preventing duplicates, not about refusing to record durable knowledge. CREATE is the correct default for any structurally distinct event, claim, or pattern that has no existing match.
@@ -37,19 +51,36 @@ _PROCESSING_RULES = """## PROCESSING RULES
9. KEEP DISTINCT TOPICS DISTINCT: do not merge observations about different people, entities, or unrelated topics. Merging is for the same canonical fact recurring — not for related-but-distinct claims."""
# Field-by-field definitions of the input shape 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.
- `mentioned_at`: when the source material that states this fact was written. This is the fact's recency: how up to date the statement is, NOT when it was added to memory. A fact taken from an old document keeps its old `mentioned_at` even if it was only just processed."""
_OBSERVATION_FIELDS = """- `id`: unique identifier — copy this exactly when issuing an UPDATE or DELETE
- `text`: the observation content
- `proof_count`: how many source facts this observation has already merged
- `occurred_start` / `occurred_end`: the span of the events behind the observation — earliest start and latest end across its source facts
- `mentioned_at`: the latest of the `mentioned_at` values of its source facts — the most recent point at which this observation was stated
- `source_memories`: the supporting facts behind this observation. May be partial or absent for large observations — the count above remains the true total. Each entry carries the same `text` and temporal fields as a new fact, plus:
- `context`: optional surrounding context for that fact"""
# Stable description of the input shape. For the cached split path this lives in
# the system prefix (build_consolidation_system_prompt) so it is not re-sent on
# every batch; the per-batch user message then carries only the actual data.
_INPUT_FORMAT_NOTE = """## INPUT FORMAT
_INPUT_FORMAT_NOTE = f"""## INPUT FORMAT
Each request provides new facts and existing observations:
- New facts: one per line, each prefixed with its `[uuid]`, followed by the fact text and optional temporal fields.
- Existing observations: a JSON array pooled from recalls across the new facts. Each entry has:
- `id`: unique identifier — copy this exactly when issuing an UPDATE or DELETE
- `text`: the observation content
- `proof_count`: number of supporting memories
- `occurred_start` / `occurred_end`: temporal range of source facts
- `source_memories`: array of supporting facts with their text and dates"""
Each request provides new facts and existing observations. Every temporal field is optional and is omitted when unknown.
### New facts
{_FACT_FIELDS}
### Existing observations
A JSON array pooled from recalls across the new facts. Each entry has:
{_OBSERVATION_FIELDS}"""
# Per-batch data section for the cached split path — the stable format
# explanation above is omitted here (it lives in the cached prefix); only the
@@ -64,24 +95,6 @@ _SPLIT_INPUT_SECTION = """## INPUT
{observations_text}"""
# Data section — format placeholders {facts_text} and {observations_text} are substituted at call time
_INPUT_SECTION = """## INPUT
### New facts
{facts_text}
### Existing observations
JSON array, pooled from recalls across all new facts above. Each entry has:
- `id`: unique identifier — copy this exactly when issuing an UPDATE or DELETE
- `text`: the observation content
- `proof_count`: number of supporting memories
- `occurred_start` / `occurred_end`: temporal range of source facts
- `source_memories`: array of supporting facts with their text and dates
{observations_text}"""
_DECISION_GUIDE = """## DECISION GUIDE
- **Same canonical event, decision, claim, or facet as an existing observation → UPDATE** (use `observation_id` + new `source_fact_ids`).
@@ -142,39 +155,6 @@ Expected output (UPDATE for the state change; CREATE for the unrelated work-hour
- Return `{{"creates": [], "updates": [], "deletes": []}}` if nothing durable is found."""
def build_batch_consolidation_prompt(
observations_mission: str | None = None,
observation_capacity_note: str | None = None,
llm_output_language: str | None = None,
) -> str:
"""
Build the consolidation prompt for batch mode (multiple facts per LLM call).
The mission defines *what* to track (customisable per bank) and takes
priority over the built-in processing rules when the two conflict.
Processing rules, decision guide, and output format are always present.
When ``llm_output_language`` is set, observations are emitted in that
language.
"""
mission = escape_for_prompt(observations_mission or _DEFAULT_MISSION)
capacity_section = ""
if observation_capacity_note:
capacity_section = f"\n\n## CAPACITY CONSTRAINT\n\n{escape_for_prompt(observation_capacity_note)}"
return (
"You are a memory consolidation system. Synthesize new facts into "
"observations, merging with existing observations when appropriate.\n\n"
f"## MISSION\n\n{mission}\n\n"
f"{_MISSION_PRIORITY_NOTE}"
f"{capacity_section}\n\n"
f"{_PROCESSING_RULES}\n\n"
f"{_INPUT_SECTION}\n\n"
f"{_DECISION_GUIDE}\n\n"
f"{_OUTPUT_SECTION}" + output_language_directive(llm_output_language)
)
def build_consolidation_system_prompt(
llm_output_language: str | None = None,
) -> str:
@@ -189,11 +169,17 @@ def build_consolidation_system_prompt(
bank and a single CachedContent serves them all. Returns final text
(brace-escaped examples already unescaped) for verbatim use as system message
and cached prefix.
``llm_output_language`` picks between two mutually exclusive language rules:
unset keeps each observation in the language of its own source facts (the
default), set forces every observation into that one configured language.
"""
language_section = "" if llm_output_language else f"{_DEFAULT_LANGUAGE_RULE}\n\n"
template = (
"You are a memory consolidation system. Synthesize new facts into "
"observations, merging with existing observations when appropriate.\n\n"
f"{_MISSION_PRIORITY_NOTE}\n\n"
f"{language_section}"
f"{_PROCESSING_RULES}\n\n"
f"{_INPUT_FORMAT_NOTE}\n\n"
f"{_DECISION_GUIDE}\n\n"
@@ -8,10 +8,10 @@ Configuration via environment variables - see hindsight_api.config for all env v
import asyncio
import logging
import os
import warnings
from abc import ABC, abstractmethod
from concurrent.futures import ThreadPoolExecutor
from typing import Any
import httpx
@@ -20,85 +20,32 @@ from ..config import (
DEFAULT_RERANKER_ALIBABA_MODEL,
DEFAULT_RERANKER_COHERE_MODEL,
DEFAULT_RERANKER_FLASHRANK_CACHE_DIR,
DEFAULT_RERANKER_FLASHRANK_CPU_MEM_ARENA,
DEFAULT_RERANKER_FLASHRANK_MODEL,
DEFAULT_RERANKER_GOOGLE_MODEL,
DEFAULT_RERANKER_LITELLM_MAX_TOKENS_PER_DOC,
DEFAULT_RERANKER_LITELLM_MODEL,
DEFAULT_RERANKER_LITELLM_SDK_MODEL,
DEFAULT_RERANKER_LOCAL_BATCH_SIZE,
DEFAULT_RERANKER_LOCAL_FORCE_CPU,
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT,
DEFAULT_RERANKER_LOCAL_MODEL,
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE,
DEFAULT_RERANKER_PROVIDER,
DEFAULT_RERANKER_SILICONFLOW_BASE_URL,
DEFAULT_RERANKER_SILICONFLOW_MODEL,
DEFAULT_RERANKER_TEI_BATCH_SIZE,
DEFAULT_RERANKER_TEI_HTTP_TIMEOUT,
DEFAULT_RERANKER_TEI_MAX_CONCURRENT,
DEFAULT_RERANKER_ZEROENTROPY_MODEL,
DEFAULT_ZEROENTROPY_BASE_URL,
ENV_RERANKER_ALIBABA_API_KEY,
ENV_RERANKER_COHERE_API_KEY,
ENV_RERANKER_COHERE_MODEL,
ENV_RERANKER_FLASHRANK_CACHE_DIR,
ENV_RERANKER_FLASHRANK_CPU_MEM_ARENA,
ENV_RERANKER_FLASHRANK_MODEL,
ENV_RERANKER_GOOGLE_PROJECT_ID,
ENV_RERANKER_LOCAL_FORCE_CPU,
ENV_RERANKER_LOCAL_MAX_CONCURRENT,
ENV_RERANKER_LOCAL_MODEL,
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE,
ENV_RERANKER_PROVIDER,
ENV_RERANKER_SILICONFLOW_API_KEY,
ENV_RERANKER_TEI_BATCH_SIZE,
ENV_RERANKER_TEI_HTTP_TIMEOUT,
ENV_RERANKER_TEI_MAX_CONCURRENT,
ENV_RERANKER_TEI_URL,
ENV_RERANKER_ZEROENTROPY_API_KEY,
RerankerMemberConfig,
)
from .bank_attribution import reranker_bank_attribution_headers
from .local_device import (
release_local_inference_memory,
resolve_model_device_type,
select_local_device,
)
from .tei_retry import tei_retry_delay
logger = logging.getLogger(__name__)
def _resolve_malloc_trim():
"""Return a callable that asks glibc to release freed heap pages to the OS.
Local CPU rerankers (FlashRank/ONNX, SentenceTransformers/torch) allocate
large transient numpy/tensor buffers per call. On Linux glibc, those pages
are freed at the Python level but kept by the allocator as a high-water
mark — RSS grows monotonically across many recalls (see issue #1717).
Calling `malloc_trim(0)` after each batch returns those pages to the OS.
Resolved once at import; returns a no-op on non-glibc platforms (macOS,
musl, Windows) where the call is unavailable or unnecessary.
"""
import sys
if sys.platform != "linux":
return lambda: None
import ctypes
import ctypes.util
libc_path = ctypes.util.find_library("c")
if libc_path is None:
return lambda: None
try:
libc = ctypes.CDLL(libc_path)
trim = libc.malloc_trim
except (OSError, AttributeError):
# Not glibc (musl has no malloc_trim) or libc lookup failed.
return lambda: None
trim.argtypes = [ctypes.c_size_t]
trim.restype = ctypes.c_int
return lambda: trim(0)
_malloc_trim = _resolve_malloc_trim()
class CrossEncoderModel(ABC):
"""
Abstract base class for cross-encoder reranking.
@@ -112,6 +59,15 @@ class CrossEncoderModel(ABC):
"""Return a human-readable name for this provider (e.g., 'local', 'tei')."""
pass
@property
def blocking_init(self) -> bool:
"""Whether ``initialize()`` blocks the event loop (loads a model in-process).
Callers run those in a thread pool. Remote providers leave this False, and
so does :class:`MultiCrossEncoder` — it offloads its own members.
"""
return False
@abstractmethod
async def initialize(self) -> None:
"""
@@ -163,6 +119,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
fp16: bool = False,
bucket_batching: bool = False,
batch_size: int = DEFAULT_RERANKER_LOCAL_BATCH_SIZE,
allow_mps: bool = False,
):
"""
Initialize local SentenceTransformers cross-encoder.
@@ -184,6 +141,9 @@ class LocalSTCrossEncoder(CrossEncoderModel):
Default: False (opt-in via env var).
batch_size: Batch size for predict() calls. Optimal values vary by
hardware and model (MPS: 32, CUDA: 128+). Default: 32.
allow_mps: Opt in to the Apple Silicon MPS GPU. Disabled by default
because MPS leaks memory under variable-length workloads
(see engine/local_device.py). Default: False
"""
self.model_name = model_name or DEFAULT_RERANKER_LOCAL_MODEL
self.force_cpu = force_cpu
@@ -191,13 +151,19 @@ class LocalSTCrossEncoder(CrossEncoderModel):
self.fp16 = fp16
self.bucket_batching = bucket_batching
self.batch_size = batch_size
self.allow_mps = allow_mps
self._model = None
self._device_type: str = "cpu"
LocalSTCrossEncoder._max_concurrent = max_concurrent
@property
def provider_name(self) -> str:
return "local"
@property
def blocking_init(self) -> bool:
return True
async def initialize(self) -> None:
"""Load the cross-encoder model and initialize the executor."""
if self._model is not None:
@@ -213,30 +179,13 @@ class LocalSTCrossEncoder(CrossEncoderModel):
logger.info(f"Reranker: initializing local provider with model {self.model_name}")
# Determine device based on hardware availability.
# We always set low_cpu_mem_usage=False to prevent lazy loading (meta tensors)
# which can cause issues when accelerate is installed but no GPU is available.
# Determine device based on hardware availability. We always set
# low_cpu_mem_usage=False to prevent lazy loading (meta tensors) which can
# cause issues when accelerate is installed but no GPU is available.
# Note: We do NOT use device_map because CrossEncoder internally calls .to(device)
# after loading, which conflicts with accelerate's device_map handling.
import torch
# Force CPU mode if configured (used in daemon mode to avoid MPS/XPC issues on macOS)
if self.force_cpu:
device = "cpu"
logger.info("Reranker: forcing CPU mode (HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=1)")
else:
# Check for GPU (CUDA) or Apple Silicon (MPS)
# Wrap in try-except to gracefully handle any device detection issues
# (e.g., in CI environments or when PyTorch is built without GPU support)
device = "cpu" # Default to CPU
try:
has_gpu = torch.cuda.is_available() or (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
)
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
# MPS is opt-in (allow_mps) — see engine/local_device.py for why.
device = select_local_device(self.force_cpu, self.allow_mps)
# Patch transformers 5.x compatibility for models using XLM-RoBERTa
# (e.g., jina-reranker-v2-base-multilingual). transformers 5.x removed
@@ -280,9 +229,11 @@ class LocalSTCrossEncoder(CrossEncoderModel):
# Restore original logging level
transformers_logger.setLevel(original_level)
self._device_type = resolve_model_device_type(self._model)
# FP16 inference: convert model weights to half precision.
# Empirically validated: 27-36% faster on MPS, quality-identical (20/20 overlap).
if self.fp16 and device != "cpu":
if self.fp16 and self._device_type != "cpu":
self._model.model.half()
logger.info("Reranker: FP16 inference enabled")
@@ -303,7 +254,6 @@ class LocalSTCrossEncoder(CrossEncoderModel):
- bucket_batching: sort pairs by token length to reduce padding waste (36-54% speedup)
- batch_size: explicit batch size for predict() calls (MPS optimal: 32)
"""
import numpy as np
try:
if self.bucket_batching and len(pairs) > 1:
@@ -326,7 +276,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
scores = self._model.predict(pairs, batch_size=self.batch_size, show_progress_bar=False)
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
finally:
_malloc_trim()
release_local_inference_memory(self._device_type)
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
@@ -442,14 +392,20 @@ class RemoteTEICrossEncoder(CrossEncoderModel):
await asyncio.sleep(delay)
delay *= 2 # Exponential backoff
except httpx.HTTPStatusError as e:
# Retry on 5xx server errors
if e.response.status_code >= 500 and attempt < self.max_retries:
# TEI uses 429 as normal overload backpressure. Retry it with
# the same bounded budget as transient server errors.
if (e.response.status_code == 429 or e.response.status_code >= 500) and attempt < self.max_retries:
last_error = e
logger.warning(
f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. "
f"Retrying in {delay}s..."
sleep_delay = tei_retry_delay(
e.response,
delay,
request_timeout=self.timeout,
)
await asyncio.sleep(delay)
logger.warning(
f"TEI transient error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. "
f"Retrying in {sleep_delay:.2f}s..."
)
await asyncio.sleep(sleep_delay)
delay *= 2
else:
raise
@@ -495,6 +451,7 @@ class RemoteTEICrossEncoder(CrossEncoderModel):
semaphore,
"POST",
f"{self.base_url}/rerank",
headers=reranker_bank_attribution_headers(),
json={
"query": query,
"texts": texts,
@@ -635,7 +592,11 @@ class _CohereCompatibleRerankClient:
if self.include_top_n:
body["top_n"] = len(texts)
response = await self._async_client.post(self.rerank_url, json=body)
response = await self._async_client.post(
self.rerank_url,
headers=reranker_bank_attribution_headers(),
json=body,
)
response.raise_for_status()
result = response.json()
@@ -930,6 +891,7 @@ class FlashRankCrossEncoder(CrossEncoderModel):
self.max_length = max_length
self.cpu_mem_arena = cpu_mem_arena
self._ranker = None
self._device_type: str = "cpu" # FlashRank runs on CPU via ONNX Runtime
FlashRankCrossEncoder._max_concurrent = max_concurrent
@property
@@ -1001,11 +963,11 @@ class FlashRankCrossEncoder(CrossEncoderModel):
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Synchronous predict - processes each query group."""
from flashrank import RerankRequest
if not pairs:
return []
from flashrank import RerankRequest
try:
# Group pairs by query
query_groups: dict[str, list[tuple[int, str]]] = {}
@@ -1034,7 +996,7 @@ class FlashRankCrossEncoder(CrossEncoderModel):
return all_scores
finally:
_malloc_trim()
release_local_inference_memory(self._device_type)
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
@@ -1162,6 +1124,7 @@ class LiteLLMCrossEncoder(CrossEncoderModel):
# LiteLLM /rerank follows Cohere API format
response = await self._async_client.post(
f"{self.api_base}/rerank",
headers=reranker_bank_attribution_headers(),
json={
"model": self.model,
"query": query,
@@ -1280,10 +1243,11 @@ class LiteLLMSDKCrossEncoder(CrossEncoderModel):
indices = [idx for idx, _ in indexed_texts]
# Build kwargs for rerank call
rerank_kwargs = {
rerank_kwargs: dict[str, Any] = {
"model": self.model,
"query": query,
"documents": texts,
"headers": reranker_bank_attribution_headers(),
}
if self.api_key:
rerank_kwargs["api_key"] = self.api_key
@@ -1292,21 +1256,9 @@ class LiteLLMSDKCrossEncoder(CrossEncoderModel):
response = await self._litellm.arerank(**rerank_kwargs)
# Map scores back to original positions
# Response format: RerankResponse with results list
# Each result is a TypedDict with "index" and "relevance_score"
if hasattr(response, "results") and response.results:
for result in response.results:
# Results are TypedDicts, use dict-style access
original_idx = result["index"]
score = result.get("relevance_score", result.get("score", 0.0))
all_scores[indices[original_idx]] = score
elif isinstance(response, list):
# Direct list of scores (unlikely but defensive)
for i, score in enumerate(response):
all_scores[indices[i]] = score
else:
logger.warning(f"Unexpected response format from LiteLLM rerank: {type(response)}")
for result in response.results:
original_idx = result["index"]
all_scores[indices[original_idx]] = result["relevance_score"]
return all_scores
@@ -1625,131 +1577,246 @@ class AlibabaCloudCrossEncoder(CrossEncoderModel):
return await self._client.predict(pairs)
def create_cross_encoder_from_env() -> CrossEncoderModel:
"""
Create a CrossEncoderModel instance based on configuration.
class MultiCrossEncoder(CrossEncoderModel):
"""Failover across an ordered chain of cross-encoders.
Reads configuration via get_config() to ensure consistency across the codebase.
Member 0 is the primary (the unindexed ``HINDSIGHT_API_RERANKER_*`` config);
members 1..N are the indexed fallbacks. Each ``predict`` tries members in order
and returns the first usable set of scores, so an unreachable reranker costs
ranking quality (whatever the next member gives) instead of the whole recall.
Put ``rrf`` last to degrade to the fusion order rather than failing.
Each member keeps its own retry budget, so we only advance after a member has
exhausted its retries and raised. A member that fails to initialize is not
fatal — that is the point of the chain — it is retried lazily on the next
request that reaches it.
"""
def __init__(self, members: list[CrossEncoderModel]) -> None:
if len(members) < 2:
raise ValueError("MultiCrossEncoder requires at least two members")
self._members = members
self._ready = [False] * len(members)
self._locks = [asyncio.Lock() for _ in members]
self._active = 0
@property
def provider_name(self) -> str:
"""The provider of the member that last served a request (primary before any).
Callers use this to detect a passthrough reranker, so it has to track the
member actually serving rather than name the chain: a chain that has
degraded to its ``rrf`` member is passthrough. Concurrent requests share it,
so a request that fails over can briefly mislabel a neighbour — this only
tunes downstream scoring, never correctness.
"""
return self._members[self._active].provider_name
async def _initialize_member(self, index: int) -> None:
"""Initialize one member, off the event loop when it loads a model in-process."""
member = self._members[index]
if member.blocking_init:
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, lambda: asyncio.run(member.initialize()))
else:
await member.initialize()
self._ready[index] = True
async def _ensure_member_ready(self, index: int) -> None:
async with self._locks[index]:
if not self._ready[index]:
await self._initialize_member(index)
async def initialize(self) -> None:
"""Initialize every member, tolerating members that are down.
Members initialize concurrently so one unreachable member cannot eat the
startup budget the others need. Failures are logged and retried on use.
"""
results = await asyncio.gather(
*(self._ensure_member_ready(i) for i in range(len(self._members))),
return_exceptions=True,
)
for index, result in enumerate(results):
if isinstance(result, BaseException):
logger.warning(
"Reranker member %d (%s) failed to initialize: %s; it will be retried on use",
index,
self._members[index].provider_name,
result,
)
if not any(self._ready):
logger.error("Reranker: no member of the failover chain initialized; recall will retry them per request")
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Score ``pairs`` with the first member that answers usably."""
last_exc: BaseException | None = None
for index, member in enumerate(self._members):
try:
if not self._ready[index]:
await self._ensure_member_ready(index)
scores = await member.predict(pairs)
if len(scores) != len(pairs):
raise RuntimeError(f"returned {len(scores)} scores for {len(pairs)} pairs")
except Exception as e: # noqa: BLE001 - re-raised below if no member answers
last_exc = e
remaining = len(self._members) - index - 1
logger.warning(
"Reranker member %d (%s) failed: %s%s",
index,
member.provider_name,
e,
f"; trying next member ({remaining} left)" if remaining else "; no members left",
)
continue
if index != self._active:
logger.info(
"Reranker: now serving from member %d (%s)",
index,
member.provider_name,
)
self._active = index
return scores
# All members failed; surface the last error (loop ran at least once).
assert last_exc is not None
raise last_exc
def create_cross_encoder(member: RerankerMemberConfig) -> CrossEncoderModel:
"""
Create a CrossEncoderModel for one member of the reranker chain.
``member`` is the primary (index 0, the unindexed ``HINDSIGHT_API_RERANKER_*``
config) or an indexed fallback. Missing-setting errors name the member's own
env var, so a chain misconfiguration points at the exact indexed variable.
Args:
member: Resolved settings for this member
Returns:
Configured CrossEncoderModel instance
"""
from ..config import get_config
config = get_config()
provider = config.reranker_provider.lower()
provider = member.provider.lower()
if provider == "tei":
url = config.reranker_tei_url
url = member.tei_url
if not url:
raise ValueError(f"{ENV_RERANKER_TEI_URL} is required when {ENV_RERANKER_PROVIDER} is 'tei'")
raise ValueError(f"{member.env_name('TEI_URL')} is required when {member.env_name('PROVIDER')} is 'tei'")
return RemoteTEICrossEncoder(
base_url=url,
timeout=config.reranker_tei_http_timeout,
batch_size=config.reranker_tei_batch_size,
max_concurrent=config.reranker_tei_max_concurrent,
timeout=member.tei_http_timeout,
batch_size=member.tei_batch_size,
max_concurrent=member.tei_max_concurrent,
)
elif provider == "local":
return LocalSTCrossEncoder(
model_name=config.reranker_local_model,
max_concurrent=config.reranker_local_max_concurrent,
force_cpu=config.reranker_local_force_cpu,
trust_remote_code=config.reranker_local_trust_remote_code,
fp16=config.reranker_local_fp16,
bucket_batching=config.reranker_local_bucket_batching,
batch_size=config.reranker_local_batch_size,
model_name=member.local_model,
max_concurrent=member.local_max_concurrent,
force_cpu=member.local_force_cpu,
trust_remote_code=member.local_trust_remote_code,
fp16=member.local_fp16,
bucket_batching=member.local_bucket_batching,
batch_size=member.local_batch_size,
allow_mps=member.local_allow_mps,
)
elif provider == "cohere":
api_key = config.reranker_cohere_api_key
if not api_key:
raise ValueError(f"{ENV_RERANKER_COHERE_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'cohere'")
return CohereCrossEncoder(
api_key=api_key,
model=config.reranker_cohere_model,
base_url=config.reranker_cohere_base_url,
timeout=config.reranker_cohere_timeout,
)
elif provider == "openrouter":
api_key = config.reranker_openrouter_api_key
api_key = member.cohere_api_key
if not api_key:
raise ValueError(
"HINDSIGHT_API_RERANKER_OPENROUTER_API_KEY, HINDSIGHT_API_OPENROUTER_API_KEY, "
f"or HINDSIGHT_API_LLM_API_KEY is required when {ENV_RERANKER_PROVIDER} is 'openrouter'"
f"{member.env_name('COHERE_API_KEY')} is required when {member.env_name('PROVIDER')} is 'cohere'"
)
return CohereCrossEncoder(
api_key=api_key,
model=config.reranker_openrouter_model,
base_url="https://openrouter.ai/api/v1/rerank",
timeout=config.reranker_openrouter_timeout,
model=member.cohere_model,
base_url=member.cohere_base_url,
timeout=member.cohere_timeout,
)
elif provider == "openrouter":
api_key = member.openrouter_api_key
if not api_key:
shared = ", HINDSIGHT_API_OPENROUTER_API_KEY, or HINDSIGHT_API_LLM_API_KEY" if member.index == 0 else ""
raise ValueError(
f"{member.env_name('OPENROUTER_API_KEY')}{shared} is required "
f"when {member.env_name('PROVIDER')} is 'openrouter'"
)
return CohereCrossEncoder(
api_key=api_key,
model=member.openrouter_model,
base_url=member.openrouter_base_url,
timeout=member.openrouter_timeout,
)
elif provider == "flashrank":
model = os.environ.get(ENV_RERANKER_FLASHRANK_MODEL, DEFAULT_RERANKER_FLASHRANK_MODEL)
cache_dir = os.environ.get(ENV_RERANKER_FLASHRANK_CACHE_DIR, DEFAULT_RERANKER_FLASHRANK_CACHE_DIR)
cpu_mem_arena = os.environ.get(
ENV_RERANKER_FLASHRANK_CPU_MEM_ARENA, str(DEFAULT_RERANKER_FLASHRANK_CPU_MEM_ARENA)
).lower() in ("true", "1", "yes")
return FlashRankCrossEncoder(model_name=model, cache_dir=cache_dir, cpu_mem_arena=cpu_mem_arena)
return FlashRankCrossEncoder(
model_name=member.flashrank_model,
cache_dir=member.flashrank_cache_dir,
cpu_mem_arena=member.flashrank_cpu_mem_arena,
)
elif provider == "litellm":
return LiteLLMCrossEncoder(
api_base=config.reranker_litellm_api_base,
api_key=config.reranker_litellm_api_key,
model=config.reranker_litellm_model,
max_tokens_per_doc=config.reranker_litellm_max_tokens_per_doc,
timeout=config.reranker_litellm_timeout,
api_base=member.litellm_api_base,
api_key=member.litellm_api_key,
model=member.litellm_model,
max_tokens_per_doc=member.litellm_max_tokens_per_doc,
timeout=member.litellm_timeout,
)
elif provider == "litellm-sdk":
return LiteLLMSDKCrossEncoder(
api_key=config.reranker_litellm_sdk_api_key or None,
model=config.reranker_litellm_sdk_model,
api_base=config.reranker_litellm_sdk_api_base,
max_tokens_per_doc=config.reranker_litellm_max_tokens_per_doc,
timeout=config.reranker_litellm_sdk_timeout,
api_key=member.litellm_sdk_api_key or None,
model=member.litellm_sdk_model,
api_base=member.litellm_sdk_api_base,
max_tokens_per_doc=member.litellm_max_tokens_per_doc,
timeout=member.litellm_sdk_timeout,
)
elif provider == "zeroentropy":
api_key = config.reranker_zeroentropy_api_key
api_key = member.zeroentropy_api_key
if not api_key:
raise ValueError(
f"{ENV_RERANKER_ZEROENTROPY_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'zeroentropy'"
f"{member.env_name('ZEROENTROPY_API_KEY')} is required "
f"when {member.env_name('PROVIDER')} is 'zeroentropy'"
)
return ZeroEntropyCrossEncoder(
api_key=api_key,
model=config.reranker_zeroentropy_model,
base_url=config.reranker_zeroentropy_base_url,
timeout=config.reranker_zeroentropy_timeout,
model=member.zeroentropy_model,
base_url=member.zeroentropy_base_url,
timeout=member.zeroentropy_timeout,
)
elif provider == "siliconflow":
api_key = config.reranker_siliconflow_api_key
api_key = member.siliconflow_api_key
if not api_key:
raise ValueError(
f"{ENV_RERANKER_SILICONFLOW_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'siliconflow'"
f"{member.env_name('SILICONFLOW_API_KEY')} is required "
f"when {member.env_name('PROVIDER')} is 'siliconflow'"
)
return SiliconFlowCrossEncoder(
api_key=api_key,
model=config.reranker_siliconflow_model,
base_url=config.reranker_siliconflow_base_url,
timeout=config.reranker_siliconflow_timeout,
model=member.siliconflow_model,
base_url=member.siliconflow_base_url,
timeout=member.siliconflow_timeout,
)
elif provider == "google":
project_id = config.reranker_google_project_id
project_id = member.google_project_id
if not project_id:
shared = " (or HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID)" if member.index == 0 else ""
raise ValueError(
f"{ENV_RERANKER_GOOGLE_PROJECT_ID} (or HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID) "
f"is required when {ENV_RERANKER_PROVIDER} is 'google'"
f"{member.env_name('GOOGLE_PROJECT_ID')}{shared} "
f"is required when {member.env_name('PROVIDER')} is 'google'"
)
return GoogleCrossEncoder(
project_id=project_id,
model=config.reranker_google_model,
service_account_key=config.reranker_google_service_account_key,
timeout=config.reranker_google_timeout,
model=member.google_model,
service_account_key=member.google_service_account_key,
timeout=member.google_timeout,
)
elif provider == "alibaba":
api_key = config.reranker_alibaba_api_key
api_key = member.alibaba_api_key
if not api_key:
raise ValueError(f"{ENV_RERANKER_ALIBABA_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'alibaba'")
raise ValueError(
f"{member.env_name('ALIBABA_API_KEY')} is required when {member.env_name('PROVIDER')} is 'alibaba'"
)
return AlibabaCloudCrossEncoder(
api_key=api_key,
model=config.reranker_alibaba_model,
timeout=config.reranker_alibaba_timeout,
model=member.alibaba_model,
timeout=member.alibaba_timeout,
)
elif provider == "rrf":
return RRFPassthroughCrossEncoder()
@@ -1759,3 +1826,23 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
raise ValueError(
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'zeroentropy', 'siliconflow', 'alibaba', 'google', 'flashrank', 'litellm', 'litellm-sdk', 'rrf', 'jina-mlx'"
)
def create_cross_encoder_from_env() -> CrossEncoderModel:
"""
Create the configured reranker, based on configuration.
Reads configuration via get_config() to ensure consistency across the codebase.
With no ``HINDSIGHT_API_RERANKER_<n>_*`` members configured (the default) this
is the single configured reranker; otherwise the chain is wrapped in a
:class:`MultiCrossEncoder` that fails over across members in order.
Returns:
Configured CrossEncoderModel instance
"""
from ..config import get_config
chain = get_config().reranker_chain()
if len(chain) == 1:
return create_cross_encoder(chain[0])
return MultiCrossEncoder([create_cross_encoder(member) for member in chain])
@@ -67,16 +67,28 @@ def create_database_backend(backend_type: str) -> DatabaseBackend:
return _get_backend_class(backend_type)()
_OPS_CACHE: dict[str, DataAccessOps] = {}
def create_data_access_ops(backend_type: str) -> DataAccessOps:
"""Factory: create a DataAccessOps by backend name.
"""Factory: the DataAccessOps for a backend name.
Returns a per-dialect SINGLETON: ``DataAccessOps`` is stateless (it only builds and runs SQL),
so one shared instance per dialect is correct — and it means the database backend and the
memories store hold the *same* ops object, so a test that patches a method on it (e.g.
``enqueue_graph_maintenance``) observes every caller regardless of which layer issued it.
Args:
backend_type: One of "postgresql" or "oracle".
Returns:
A DataAccessOps instance.
The shared DataAccessOps instance for that backend.
Raises:
ValueError: If backend_type is not recognized.
"""
return _get_ops_class(backend_type)()
ops = _OPS_CACHE.get(backend_type)
if ops is None:
ops = _get_ops_class(backend_type)()
_OPS_CACHE[backend_type] = ops
return ops
@@ -112,6 +112,23 @@ class DatabaseConnection(ABC):
"""
...
async def execute_rows_affected(self, query: str, *args: Any, timeout: float | None = None) -> int:
"""Execute a DML statement and return the number of rows it affected.
Normalizes the dialect-specific execute result into a plain int so callers
never hand-parse an ``"UPDATE <n>"`` / ``"DELETE <n>"`` command tag in
business logic (mirrors ``parse_json`` above, which normalizes the other
dialect-divergent result shape). asyncpg returns the tag directly; the
Oracle connection reshapes ``cursor.rowcount`` into the same trailing-count
form, so parsing the last token is dialect-safe. Returns 0 when the status
has no trailing count (e.g. a non-DML statement).
"""
status = await self.execute(query, *args, timeout=timeout)
if not isinstance(status, str):
return 0
parts = status.split()
return int(parts[-1]) if parts and parts[-1].isdigit() else 0
@abstractmethod
async def executemany(self, query: str, args: list[tuple[Any, ...]], *, timeout: float | None = None) -> None:
"""Execute a query for each set of arguments.
@@ -307,6 +324,17 @@ class DatabaseBackend(ABC):
"""Close the connection pool and release all resources."""
...
@property
@abstractmethod
def is_ready(self) -> bool:
"""Whether the pool exists and can serve connections.
False before :meth:`initialize` and after :meth:`shutdown`. Best-effort
callers (tracing, auditing) check this to skip work during those windows
instead of acquiring and interpreting the resulting error.
"""
...
@abstractmethod
@asynccontextmanager
async def acquire(self) -> AsyncIterator[DatabaseConnection]:
@@ -18,13 +18,75 @@ and mirrors Django's ``DatabaseOperations`` architecture.
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from uuid import UUID
from .base import DatabaseConnection
from .result import ResultRow
def graph_maintenance_bank_serialization_sql(table: str, alias: str) -> str:
"""SQL predicate serialising ``graph_maintenance`` claims per bank (#3230).
Every graph_maintenance run is the same bank-wide sweep — the payload carries
only ``bank_id``, and ``run_graph_maintenance_job`` drains the whole queue —
so a second concurrent run for one bank adds no work. It is worse than
useless: ``claim_graph_maintenance_batch`` locks queue rows ``FOR UPDATE``
*without* ``SKIP LOCKED`` (it is written assuming a single runner per bank),
so the runs convoy on each other's row locks while each holds a worker slot.
Same guarantee ``consolidation`` already gets from its ``bank_id != ALL(busy)``
exclusion, and the same caveat: a row wedged in 'processing' holds its bank
until something releases it (``hindsight-admin recover``, or a restart with a
stable ``HINDSIGHT_API_WORKER_ID`` so ``recover_own_tasks`` matches it). That
is a general gap in claim recovery, not specific to graph_maintenance.
Two differences from the consolidation form, both forced by the shape of this
problem:
* It is a **predicate**, not a separate claim phase. Pulling graph_maintenance
into its own phase after the generic shared-pool query would drop it below
every other operation type: it has no reserved-slot floor
(``WORKER_SLOT_TYPE_DEFAULTS`` gives consolidation 2 and graph_maintenance
0), and the poller's fairness pass calls ``claim_tasks`` with
``shared_limit=1``, so a single pending retain would starve it indefinitely.
As a predicate it keeps competing by ``created_at``.
* It also suppresses every same-bank row but the oldest **within one batch**.
Excluding busy banks alone does not: with several pending rows and nothing
yet processing, one batch claims them all — the convoy, unchanged. Several
pending rows per bank are reachable through the recovery paths
(``_reclaim_own_processing_tasks`` resets *all* of a worker's processing
rows in one statement, from ``recover_own_tasks`` at startup and
``release_own_tasks`` at shutdown, plus ``_schedule_retry`` /
``_defer_operation`` / ``hindsight-admin recover``).
The candidate row is always 'pending' and the 'pending' branch is
strictly-older, so the subquery can never match the candidate itself. The
fragment carries no SQL comments on purpose — it is rewritten for Oracle by
regex (``db/oracle.py``).
Args:
table: Fully-qualified async_operations table.
alias: Alias of the outer candidate row in the calling query.
"""
return f"""
({alias}.operation_type <> 'graph_maintenance' OR NOT EXISTS (
SELECT 1 FROM {table} gm_peer
WHERE gm_peer.bank_id = {alias}.bank_id
AND gm_peer.operation_type = 'graph_maintenance'
AND (
gm_peer.status = 'processing'
OR (gm_peer.status = 'pending'
AND gm_peer.task_payload IS NOT NULL
AND (gm_peer.next_retry_at IS NULL OR gm_peer.next_retry_at <= NOW())
AND (gm_peer.created_at < {alias}.created_at
OR (gm_peer.created_at = {alias}.created_at
AND gm_peer.operation_id < {alias}.operation_id)))
)
))
"""
@dataclass
class TagListingParts:
"""Backend-specific SQL fragments for the tag listing query."""
@@ -35,6 +97,57 @@ class TagListingParts:
bank_prefix: str
@dataclass(frozen=True)
class UpdatedWindow:
"""Recall's ``created_after``/``created_before`` bounds, as SQL for graph expansion.
Recall applies the window to ``updated_at`` — a consolidation touch makes a
fact current again — so link expansion has to bound the same column its seed
query does. Filtering only the seeds is not enough: a single in-window seed
would otherwise drag its whole neighbourhood (shared entities, semantic kNN
links, causal links) into the results no matter how old those neighbours are.
``first_param_index`` is where the bounds land in the owning query's param
list, so each call site keeps the placeholder numbering next to the params it
binds. Rendering is per-alias because the same window is applied to several
correlation names within one query.
"""
after: datetime | None
before: datetime | None
first_param_index: int
def clause(self, alias: str) -> str:
"""``AND <alias>.updated_at > $n ...`` — empty when the window is unbounded."""
parts: list[str] = []
index = self.first_param_index
if self.after is not None:
parts.append(f" AND {alias}.updated_at > ${index}")
index += 1
if self.before is not None:
parts.append(f" AND {alias}.updated_at < ${index}")
return "".join(parts)
@property
def params(self) -> list[datetime]:
"""The bound values, in placeholder order. Append to the owning param list."""
return [bound for bound in (self.after, self.before) if bound is not None]
@dataclass(frozen=True)
class LinkExpansionRows:
"""The three link-expansion signals, kept apart until they are scored.
They cannot be concatenated at the SQL layer: each carries a different score
scale (shared-entity count, kNN weight, causal weight) and the caller applies
a different transformation to each before summing them.
"""
entity: list[ResultRow]
semantic: list[ResultRow]
causal: list[ResultRow]
class DataAccessOps(ABC):
"""Backend-specific multi-statement data access operations.
@@ -150,9 +263,14 @@ class DataAccessOps(ABC):
bank_id: str,
entity_names: list[str],
entity_dates: list,
entity_kinds: list[str],
) -> dict[str, str]:
"""Bulk insert entities with ON CONFLICT DO NOTHING, returning id-by-lowercase-name.
``entity_kinds`` ("regular"/"label", parallel to ``entity_names``) is
stored on the row so label entities stay out of the partial trigram
index (#3208).
PG uses INSERT ... SELECT FROM unnest() with RETURNING.
Non-PG inserts row-by-row then SELECTs.
"""
@@ -173,6 +291,26 @@ class DataAccessOps(ABC):
"""
...
@abstractmethod
async def bulk_reassert_entities(
self,
conn: DatabaseConnection,
table: str,
bank_id: str,
entity_ids: list[str],
canonical_names: list[str],
entity_kinds: list[str],
) -> None:
"""Lock resolved parents and re-create any pruned since Phase-1 resolution.
Closes the retain Phase-1/prune race (#2662): existing rows are locked
(PG ``FOR KEY SHARE`` / Oracle ``FOR UPDATE``) so a concurrent
``prune_orphan_entities`` blocks until the caller's transaction commits,
while rows already deleted are re-inserted idempotently. ``entity_ids``
must be sorted by the caller for a stable lock order.
"""
...
@abstractmethod
async def bulk_insert_unit_entities(
self,
@@ -231,12 +369,16 @@ class DataAccessOps(ABC):
mu_table: str,
ue_table: str,
per_entity_limit: int,
window: UpdatedWindow,
) -> str:
"""Build entity expansion CTE for link expansion retrieval.
PG uses DISTINCT ON with CROSS JOIN LATERAL and GROUP BY.
Non-PG splits into entity_scores subquery then JOINs for full columns
(can't GROUP BY CLOB).
``window`` narrows candidates *before* the per-entity cap, so out-of-window
neighbours don't consume an entity's bounded fan-out.
"""
...
@@ -245,6 +387,7 @@ class DataAccessOps(ABC):
self,
ml_table: str,
mu_table: str,
window: UpdatedWindow,
) -> str:
"""Build semantic + causal expansion CTEs.
@@ -263,7 +406,8 @@ class DataAccessOps(ABC):
seed_ids: list,
budget: int,
per_entity_limit: int,
) -> tuple[list[ResultRow], list[ResultRow], list[ResultRow]]:
window: UpdatedWindow,
) -> LinkExpansionRows:
"""Observation-specific graph expansion.
PG uses native array ops (source_memory_ids column) for performance.
@@ -485,6 +629,23 @@ class DataAccessOps(ABC):
# -- Task claiming operations ------------------------------------------
@abstractmethod
async def prune_terminal_operations(
self,
conn: DatabaseConnection,
table: str,
cutoff: datetime,
*,
batch_size: int,
) -> int:
"""Delete one deterministic batch of terminal operations older than ``cutoff``.
Implementations must lock candidates without waiting on rows another
worker is pruning, never select pending/processing rows, and return the
number deleted. The caller provides a transaction around this method.
"""
...
@abstractmethod
async def claim_tasks(
self,
@@ -502,6 +663,10 @@ class DataAccessOps(ABC):
Oracle implementation uses two-step claims (query busy banks first, then
claim excluding them) to avoid ORA-02014.
Implementations must apply :func:`graph_maintenance_bank_serialization_sql`
to every query that can return a ``graph_maintenance`` row, so at most one
such row per bank is ever in flight.
Args:
consolidation_bank_priority: Per-bank priority for consolidation scheduling.
Maps bank name patterns to integer priorities (higher = claimed first).
@@ -8,13 +8,19 @@ columns can't appear in GROUP BY).
import json
import uuid as uuid_mod
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from .base import DatabaseConnection
from .ops import DataAccessOps, TagListingParts
from .ops import (
DataAccessOps,
LinkExpansionRows,
TagListingParts,
UpdatedWindow,
graph_maintenance_bank_serialization_sql,
)
from .result import DictResultRow as ResultRow
ORACLE_IN_LIST_LIMIT = 1000
class OracleOps(DataAccessOps):
"""Oracle-specific data access operations."""
@@ -174,22 +180,24 @@ class OracleOps(DataAccessOps):
bank_id: str,
entity_names: list[str],
entity_dates: list,
entity_kinds: list[str],
) -> dict[str, str]:
# Row-by-row insert with duplicate suppression.
# Can't use RETURNING with ON CONFLICT DO NOTHING reliably,
# so INSERT (ignoring dups) then SELECT all IDs at the end.
id_by_name: dict[str, str] = {}
for name, event_date in zip(entity_names, entity_dates):
for name, event_date, kind in zip(entity_names, entity_dates, entity_kinds):
ts = event_date if event_date else datetime.now(UTC)
await conn.execute(
f"""
INSERT INTO {table} (bank_id, canonical_name, first_seen, last_seen, mention_count)
VALUES ($1, $2, $3, $3, 0)
INSERT INTO {table} (bank_id, canonical_name, first_seen, last_seen, mention_count, entity_kind)
VALUES ($1, $2, $3, $3, 0, $4)
ON CONFLICT (bank_id, LOWER(canonical_name)) DO NOTHING
""",
bank_id,
name,
ts,
kind,
)
# Now SELECT all the entities we just inserted (or that already existed)
for name in entity_names:
@@ -218,7 +226,7 @@ class OracleOps(DataAccessOps):
for orig_name in missing_names:
row = await conn.fetchrow(
f"""
SELECT id, LOWER(canonical_name) AS name_lower
SELECT id, canonical_name, LOWER(canonical_name) AS name_lower
FROM {table}
WHERE bank_id = $1 AND LOWER(canonical_name) = LOWER($2)
""",
@@ -226,10 +234,41 @@ class OracleOps(DataAccessOps):
orig_name,
)
if row:
# Wrap in a dict-like to include input_name for downstream compat
results.append(row)
return results
async def bulk_reassert_entities(
self,
conn: DatabaseConnection,
table: str,
bank_id: str,
entity_ids: list[str],
canonical_names: list[str],
entity_kinds: list[str],
) -> None:
# Oracle has no FOR KEY SHARE; FOR UPDATE is the row-lock equivalent that
# blocks a concurrent prune DELETE until this transaction commits. Lock
# each surviving parent in the caller's stable id order (pruned ids are
# simply absent here), then re-insert any that vanished. The translation
# layer rewrites ON CONFLICT DO NOTHING to strip-and-catch ORA-00001, so
# a name recreated under a new id is suppressed rather than raising.
for entity_id in entity_ids:
await conn.fetchrow(
f"SELECT id FROM {table} WHERE id = $1 FOR UPDATE",
entity_id,
)
await conn.executemany(
f"""
INSERT INTO {table} (id, bank_id, canonical_name, entity_kind)
VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING
""",
[
(entity_id, bank_id, canonical_name, kind)
for entity_id, canonical_name, kind in zip(entity_ids, canonical_names, entity_kinds)
],
)
async def bulk_insert_unit_entities(
self,
conn: DatabaseConnection,
@@ -255,16 +294,31 @@ class OracleOps(DataAccessOps):
) -> None:
if not unit_ids:
return
# Oracle doesn't support ON CONFLICT; rely on the PK and the
# IGNORE_ROW_ON_DUPKEY_INDEX hint to skip duplicates server-side.
# The hint name must match the PK constraint exactly.
# Locking upsert (#3034), the Oracle analogue of the PG
# ``ON CONFLICT DO UPDATE``. The old IGNORE_ROW_ON_DUPKEY_INDEX insert
# skipped duplicates WITHOUT locking the existing row, so a mutation
# re-enqueueing an already-queued unit could not block a worker from
# concurrently claiming (deleting) that row and processing the unit's
# pre-mutation state — the re-enqueue signal was silently lost. MERGE
# WHEN MATCHED takes an exclusive row lock on the existing queue row
# (the SET is a deliberate no-op that preserves enqueued_at); WHEN NOT
# MATCHED inserts a fresh row. That serialises the mutation against the
# worker's claim for the same (bank_id, unit_id).
#
# Sort to enforce a global (bank_id, unit_id) lock-acquisition order,
# matching claim_graph_maintenance_batch's delete order, so overlapping
# mutation/worker sets acquire the shared row locks ascending and cannot
# cycle.
sorted_unit_ids = sorted(unit_ids)
await conn.executemany(
f"""
INSERT /*+ IGNORE_ROW_ON_DUPKEY_INDEX({table}, pk_graph_maintenance_queue) */
INTO {table} (bank_id, unit_id)
VALUES ($1, $2)
MERGE INTO {table} q
USING (SELECT $1 AS bank_id, $2 AS unit_id FROM dual) s
ON (q.bank_id = s.bank_id AND q.unit_id = s.unit_id)
WHEN MATCHED THEN UPDATE SET q.enqueued_at = q.enqueued_at
WHEN NOT MATCHED THEN INSERT (bank_id, unit_id) VALUES (s.bank_id, s.unit_id)
""",
[(bank_id, uid) for uid in unit_ids],
[(bank_id, uid) for uid in sorted_unit_ids],
)
async def claim_graph_maintenance_batch(
@@ -287,7 +341,15 @@ class OracleOps(DataAccessOps):
bank_id,
limit,
)
claimed = [str(row["unit_id"]) for row in rows]
# Ordered locking (#3034): the per-row DELETE takes the queue rows'
# exclusive locks in executemany array order. Sort the claimed keys by
# unit_id so those locks are acquired in the same (bank_id, unit_id)
# order the enqueue MERGE uses — overlapping mutation/worker sets then
# lock the shared rows ascending and cannot cycle. (The batch is still
# *chosen* oldest-first by enqueued_at above; only the lock/delete order
# is normalised.) The Pass 1 retry wrap in run_graph_maintenance_job is
# the ORA-00060 backstop for any residual interleaving.
claimed = sorted(str(row["unit_id"]) for row in rows)
if claimed:
await conn.executemany(
f"DELETE FROM {table} WHERE bank_id = $1 AND unit_id = $2",
@@ -323,6 +385,12 @@ class OracleOps(DataAccessOps):
entities_table: str,
bank_id: str,
) -> int:
# NB: the Postgres path additionally selects victims FOR UPDATE in sorted
# (entity_id_1, entity_id_2) order to prevent the #2529 deadlock against
# retain's sorted cooccurrence upsert. Oracle's DELETE can't carry that
# ordered-lock CTE the same way, so here we rely on the Pass 2/3 retry
# wrap in run_graph_maintenance_job (retry_with_backoff is ORA-00060
# deadlock-aware) to recover instead. Deliberate dialect asymmetry.
deleted = await conn.execute(
f"""
DELETE FROM {ec_table}
@@ -424,6 +492,7 @@ class OracleOps(DataAccessOps):
mu_table: str,
ue_table: str,
per_entity_limit: int,
window: UpdatedWindow,
) -> str:
# Oracle: can't GROUP BY CLOB columns (text, context).
# Restructure: count entities per unit_id in a subquery, then join to get full columns.
@@ -441,6 +510,16 @@ class OracleOps(DataAccessOps):
FROM {ue_table} ue_target
WHERE ue_target.entity_id = se.entity_id
AND ue_target.unit_id != ALL($1::uuid[])
-- Filter before applying the cap: candidates from other fact
-- types, or outside the recall window, must not consume this
-- entity's bounded fan-out.
AND EXISTS (
SELECT 1
FROM {mu_table} mu_target
WHERE mu_target.id = ue_target.unit_id
AND mu_target.fact_type = $2
{window.clause("mu_target")}
)
ORDER BY ue_target.unit_id DESC
FETCH FIRST {per_entity_limit} ROWS ONLY
) t
@@ -453,7 +532,6 @@ class OracleOps(DataAccessOps):
es.score, 'entity' AS source
FROM entity_scores es
JOIN {mu_table} mu ON mu.id = es.unit_id
WHERE mu.fact_type = $2
ORDER BY es.score DESC
FETCH FIRST $3 ROWS ONLY
)"""
@@ -462,6 +540,7 @@ class OracleOps(DataAccessOps):
self,
ml_table: str,
mu_table: str,
window: UpdatedWindow,
) -> str:
# Non-PG: can't GROUP BY CLOB columns, no DISTINCT ON.
# Restructure semantic: compute max weight per id, then join for full columns.
@@ -476,6 +555,7 @@ class OracleOps(DataAccessOps):
AND ml.link_type = 'semantic'
AND mu.fact_type = $2
AND mu.id != ALL($1::uuid[])
{window.clause("mu")}
UNION ALL
SELECT mu.id, ml.weight
FROM {ml_table} ml
@@ -484,6 +564,7 @@ class OracleOps(DataAccessOps):
AND ml.link_type = 'semantic'
AND mu.fact_type = $2
AND mu.id != ALL($1::uuid[])
{window.clause("mu")}
) sem_raw
GROUP BY id
),
@@ -510,6 +591,7 @@ class OracleOps(DataAccessOps):
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type IN ('causes', 'caused_by', 'enables', 'prevents')
AND mu.fact_type = $2
{window.clause("mu")}
),
causal_expanded AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at,
@@ -528,7 +610,8 @@ class OracleOps(DataAccessOps):
seed_ids: list,
budget: int,
per_entity_limit: int,
) -> tuple[list[ResultRow], list[ResultRow], list[ResultRow]]:
window: UpdatedWindow,
) -> LinkExpansionRows:
import logging
logger = logging.getLogger(__name__)
@@ -582,11 +665,13 @@ class OracleOps(DataAccessOps):
WHERE os3.observation_id = mu.id
AND os3.source_id IN (SELECT source_id FROM connected_sources)
)
{window.clause("mu")}
ORDER BY score DESC
FETCH FIRST $2 ROWS ONLY
""",
seed_ids,
budget,
*window.params,
)
logger.debug(f"[LinkExpansion] observation graph (Oracle): found {len(entity_rows)} connected observations")
@@ -602,12 +687,14 @@ class OracleOps(DataAccessOps):
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type = 'semantic' AND mu.fact_type = 'observation'
AND mu.id != ALL($1::uuid[])
{window.clause("mu")}
UNION ALL
SELECT mu.id, ml.weight
FROM {ml_table} ml JOIN {mu_table} mu ON mu.id = ml.from_unit_id
WHERE ml.to_unit_id = ANY($1::uuid[])
AND ml.link_type = 'semantic' AND mu.fact_type = 'observation'
AND mu.id != ALL($1::uuid[])
{window.clause("mu")}
) sem_raw
GROUP BY id
),
@@ -633,6 +720,7 @@ class OracleOps(DataAccessOps):
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type IN ('causes', 'caused_by', 'enables', 'prevents')
AND mu.fact_type = 'observation'
{window.clause("mu")}
),
causal_expanded AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at,
@@ -647,11 +735,12 @@ class OracleOps(DataAccessOps):
""",
seed_ids,
budget,
*window.params,
)
semantic_rows = [r for r in sem_causal_rows if r["source"] == "semantic"]
causal_rows = [r for r in sem_causal_rows if r["source"] == "causal"]
return list(entity_rows), semantic_rows, causal_rows
return LinkExpansionRows(entity=list(entity_rows), semantic=semantic_rows, causal=causal_rows)
def build_tag_listing_parts(self, mu_table: str) -> TagListingParts:
return TagListingParts(
@@ -818,6 +907,157 @@ class OracleOps(DataAccessOps):
# -- Task claiming operations ------------------------------------------
async def prune_terminal_operations(
self,
conn: DatabaseConnection,
table: str,
cutoff: datetime,
*,
batch_size: int,
) -> int:
# Oracle rejects a row-limited SELECT ... FOR UPDATE (ORA-02014). Pick
# the deterministic bounded IDs first, then lock only that candidate
# set and re-check eligibility before deleting in the same transaction.
# Clamp to Oracle's 1000-expression IN-list limit because the adapter
# expands the candidate UUID list into individual bind variables.
# Cancelled children cannot complete parent aggregation, so retain the
# parent guard only for completed/failed children. Before removing a
# cancelled child, preserve its signal by cancelling a pending parent
# in this transaction and refreshing the parent's retention window.
# Validate metadata before HEXTORAW: CASE makes malformed UUIDs yield
# NULL while keeping the indexed RAW parent.operation_id key unwrapped.
effective_batch_size = min(batch_size, ORACLE_IN_LIST_LIMIT)
candidates = await conn.fetch(
f"""
SELECT candidate_operation.operation_id
FROM {table} candidate_operation
WHERE candidate_operation.status IN ('completed', 'failed', 'cancelled')
AND candidate_operation.updated_at < $1
AND (
candidate_operation.status = 'cancelled'
OR NOT EXISTS (
SELECT 1
FROM {table} parent
WHERE parent.operation_id = CASE
WHEN REGEXP_LIKE(
JSON_VALUE(
candidate_operation.result_metadata,
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
),
'^[0-9A-Fa-f]{{8}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{12}}$'
)
THEN HEXTORAW(REPLACE(
JSON_VALUE(
candidate_operation.result_metadata,
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
),
'-',
''
))
ELSE NULL
END
AND parent.bank_id = candidate_operation.bank_id
)
)
ORDER BY candidate_operation.updated_at, candidate_operation.operation_id
LIMIT $2
""",
cutoff,
effective_batch_size,
)
if not candidates:
return 0
candidate_ids = [row["operation_id"] for row in candidates]
locked = await conn.fetch(
f"""
SELECT candidate_operation.operation_id
FROM {table} candidate_operation
WHERE candidate_operation.operation_id = ANY($1)
AND candidate_operation.status IN ('completed', 'failed', 'cancelled')
AND candidate_operation.updated_at < $2
AND (
candidate_operation.status = 'cancelled'
OR NOT EXISTS (
SELECT 1
FROM {table} parent
WHERE parent.operation_id = CASE
WHEN REGEXP_LIKE(
JSON_VALUE(
candidate_operation.result_metadata,
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
),
'^[0-9A-Fa-f]{{8}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{12}}$'
)
THEN HEXTORAW(REPLACE(
JSON_VALUE(
candidate_operation.result_metadata,
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
),
'-',
''
))
ELSE NULL
END
AND parent.bank_id = candidate_operation.bank_id
)
)
ORDER BY candidate_operation.updated_at, candidate_operation.operation_id
FOR UPDATE OF candidate_operation.operation_id SKIP LOCKED
""",
candidate_ids,
cutoff,
)
if not locked:
return 0
operation_ids = [row["operation_id"] for row in locked]
await conn.execute(
f"""
UPDATE {table} parent
SET status = 'cancelled',
updated_at = now(),
completed_at = COALESCE(parent.completed_at, now()),
error_message = COALESCE(
parent.error_message,
'Cancelled because a child operation was cancelled'
)
WHERE parent.status = 'pending'
AND EXISTS (
SELECT 1
FROM {table} candidate_operation
WHERE candidate_operation.operation_id = ANY($1)
AND candidate_operation.status = 'cancelled'
AND candidate_operation.updated_at < $2
AND candidate_operation.bank_id = parent.bank_id
AND parent.operation_id = CASE
WHEN REGEXP_LIKE(
JSON_VALUE(
candidate_operation.result_metadata,
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
),
'^[0-9A-Fa-f]{{8}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{12}}$'
)
THEN HEXTORAW(REPLACE(
JSON_VALUE(
candidate_operation.result_metadata,
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
),
'-',
''
))
ELSE NULL
END
)
""",
operation_ids,
cutoff,
)
await conn.execute(
f"DELETE FROM {table} WHERE operation_id = ANY($1)",
operation_ids,
)
return len(operation_ids)
async def _claim_consolidation_tasks(
self,
conn,
@@ -1098,13 +1338,14 @@ class OracleOps(DataAccessOps):
else:
rows = await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type = $1
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY created_at
SELECT o.operation_id, o.operation_type, o.task_payload, o.retry_count
FROM {table} o
WHERE o.status = 'pending'
AND o.task_payload IS NOT NULL
AND o.operation_type = $1
AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW())
AND {graph_maintenance_bank_serialization_sql(table, "o")}
ORDER BY o.created_at
LIMIT $2
FOR UPDATE SKIP LOCKED
""",
@@ -1119,18 +1360,21 @@ class OracleOps(DataAccessOps):
# --- Phase 2: claim from shared pool ---
remaining_shared = shared_limit
if remaining_shared > 0:
# 2a. Non-consolidation tasks
# 2a. Non-consolidation tasks. graph_maintenance stays in this
# created_at-ordered query — see graph_maintenance_bank_serialization_sql
# for why it is a predicate rather than a phase of its own.
if claimed_ids:
rows = await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type != 'consolidation'
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
AND operation_id != ALL($1::uuid[])
ORDER BY created_at
SELECT o.operation_id, o.operation_type, o.task_payload, o.retry_count
FROM {table} o
WHERE o.status = 'pending'
AND o.task_payload IS NOT NULL
AND o.operation_type != 'consolidation'
AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW())
AND o.operation_id != ALL($1::uuid[])
AND {graph_maintenance_bank_serialization_sql(table, "o")}
ORDER BY o.created_at
LIMIT $2
FOR UPDATE SKIP LOCKED
""",
@@ -1140,13 +1384,14 @@ class OracleOps(DataAccessOps):
else:
rows = await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type != 'consolidation'
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY created_at
SELECT o.operation_id, o.operation_type, o.task_payload, o.retry_count
FROM {table} o
WHERE o.status = 'pending'
AND o.task_payload IS NOT NULL
AND o.operation_type != 'consolidation'
AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW())
AND {graph_maintenance_bank_serialization_sql(table, "o")}
ORDER BY o.created_at
LIMIT $1
FOR UPDATE SKIP LOCKED
""",
@@ -4,19 +4,77 @@ Uses unnest(), LATERAL, DISTINCT ON, and native array operations for
efficient batch operations.
"""
import json
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
import asyncio
from datetime import datetime
from .base import DatabaseConnection
from .ops import DataAccessOps, TagListingParts
from .ops import (
DataAccessOps,
LinkExpansionRows,
TagListingParts,
UpdatedWindow,
graph_maintenance_bank_serialization_sql,
)
from .result import ResultRow
def pg_search_vector_expr(
config,
*,
text_col: str = "text",
context_col: str = "context",
signals_col: str | None = "text_signals",
native_inline: bool = True,
) -> str | None:
"""SQL expression that builds ``search_vector`` for the configured PG text-search backend.
Single source of truth shared by ``memory_units`` (the batch insert over the
``input_data`` CTE columns and the curation-revert recompute) and
``mental_models`` (the knowledge-page writes), so the per-backend tokenization
can never drift between the two tables. Returns ``None`` for backends that
leave ``search_vector`` unpopulated — pgroonga / pg_textsearch / pg_search
index the base text columns directly and keep only a dummy column, so there is
nothing to build.
The ``*_col`` arguments are the SQL for each text source (a column name or a
bind placeholder); pass ``signals_col=None`` for a two-column table like
``mental_models`` (name + content). Pass ``native_inline=False`` when the
table's native ``search_vector`` is a GENERATED column that populates itself
(``mental_models``) — writing it inline would fail; only vchord's plain
bm25vector column then needs an explicit value.
``text_search_extension_native_language`` is validated as a PG identifier in
``HindsightConfig.validate()``, so embedding it as a SQL literal is safe.
"""
cols = [text_col, context_col] + ([signals_col] if signals_col is not None else [])
combined = " || ' ' || ".join(f"COALESCE({c}, '')" for c in cols)
if config.text_search_extension == "vchord":
return f"tokenize({combined}, 'llmlingua2')::bm25_catalog.bm25vector"
if config.text_search_extension == "native" and native_inline:
return f"to_tsvector('{config.text_search_extension_native_language}'::regconfig, {combined})"
return None
class PostgreSQLOps(DataAccessOps):
"""PostgreSQL-specific data access operations using unnest and LATERAL."""
def __init__(self) -> None:
# Per-table serialization of per-bank vector-index DDL within this
# process. Concurrent index DDL on one relation deadlocks by design:
# DROP INDEX CONCURRENTLY holds ShareUpdateExclusive while it waits out
# every transaction whose snapshot could still see the index — including
# other sessions' index DDL queued on that same lock — so many banks
# deleted at once form a wait cycle Postgres resolves by killing one.
# A session advisory lock would serialize this across processes too, but
# advisory locks are banned here (poolers hand sessions around; see the
# Database Locking standard). In-process the asyncio lock removes the
# cycle outright; across processes the callers' retry-with-backoff
# absorbs the (now much rarer) collisions.
self._index_ddl_locks: dict[str, asyncio.Lock] = {}
def _index_ddl_lock(self, table: str) -> asyncio.Lock:
return self._index_ddl_locks.setdefault(table, asyncio.Lock())
@property
def uses_observation_sources_table(self) -> bool:
return False # PG uses native array ops on source_memory_ids
@@ -98,101 +156,39 @@ class PostgreSQLOps(DataAccessOps):
config = get_config()
table = self._get_mu_table()
if config.text_search_extension == "vchord":
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals, search_vector)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals,
tokenize(
COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, ''),
'llmlingua2'
)::bm25_catalog.bm25vector
FROM input_data
RETURNING id
"""
elif config.text_search_extension == "native":
# search_vector is a regular tsvector column populated here using the
# configured native dictionary. It used to be GENERATED ALWAYS with
# a hardcoded 'english', which prevented per-deployment language
# configuration. text_search_extension_native_language is validated
# in HindsightConfig.validate() as a PG identifier, so embedding it
# as a SQL literal is safe.
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals, search_vector)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals,
to_tsvector(
'{config.text_search_extension_native_language}'::regconfig,
COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, '')
)
FROM input_data
RETURNING id
"""
else:
# pg_textsearch, pgroonga, and pg_search: search_vector is a dummy
# TEXT column; the actual full-text index operates on the base text
# columns directly, so we don't populate search_vector at insert time.
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals
FROM input_data
RETURNING id
"""
# search_vector is populated inline for backends that store a real vector
# (native tsvector, vchord bm25vector). pgroonga / pg_textsearch / pg_search
# index the base text columns directly and keep only a dummy column, so the
# expression is None and the column is left out of the insert entirely.
# Same expression is reused by curation revert (see pg_search_vector_expr).
sv_expr = pg_search_vector_expr(config)
sv_insert_col = ", search_vector" if sv_expr else ""
sv_select_val = f",\n {sv_expr}" if sv_expr else ""
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals{sv_insert_col})
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals{sv_select_val}
FROM input_data
RETURNING id
"""
results = await conn.fetch(
query,
@@ -290,12 +286,22 @@ class PostgreSQLOps(DataAccessOps):
bank_id: str,
entity_names: list[str],
entity_dates: list,
entity_kinds: list[str],
) -> dict[str, str]:
# ORDER BY LOWER(name) so every concurrent batch inserts in the same order
# as the conflict target (bank_id, LOWER(canonical_name)). ON CONFLICT DO
# NOTHING takes a ShareLock on the inserting transaction of any speculative
# row it collides with, so two batches with overlapping names inserting in
# different orders deadlock. The caller already sorts by Python's
# ``str.lower()``, which agrees with the index for ASCII but not for every
# locale (see the Turkish-İ note in entity_resolver) — ordering in SQL makes
# the database's own collation the single arbiter for all writers.
inserted_rows = await conn.fetch(
f"""
INSERT INTO {table} (bank_id, canonical_name, first_seen, last_seen, mention_count)
SELECT $1, name, COALESCE(event_date, now()), COALESCE(event_date, now()), 0
FROM unnest($2::text[], $3::timestamptz[]) AS t(name, event_date)
INSERT INTO {table} (bank_id, canonical_name, first_seen, last_seen, mention_count, entity_kind)
SELECT $1, name, COALESCE(event_date, now()), COALESCE(event_date, now()), 0, kind
FROM unnest($2::text[], $3::timestamptz[], $4::text[]) AS t(name, event_date, kind)
ORDER BY LOWER(name)
ON CONFLICT (bank_id, LOWER(canonical_name))
DO NOTHING
RETURNING id, LOWER(canonical_name) AS name_lower
@@ -303,6 +309,7 @@ class PostgreSQLOps(DataAccessOps):
bank_id,
entity_names,
entity_dates,
entity_kinds,
)
return {row["name_lower"]: row["id"] for row in inserted_rows}
@@ -315,7 +322,7 @@ class PostgreSQLOps(DataAccessOps):
) -> list[ResultRow]:
return await conn.fetch(
f"""
SELECT e.id, LOWER(e.canonical_name) AS name_lower, inputs.input_name
SELECT e.id, e.canonical_name, LOWER(e.canonical_name) AS name_lower, inputs.input_name
FROM {table} e
JOIN (
SELECT LOWER(n) AS input_name_lower, n AS input_name
@@ -327,6 +334,44 @@ class PostgreSQLOps(DataAccessOps):
missing_names,
)
async def bulk_reassert_entities(
self,
conn: DatabaseConnection,
table: str,
bank_id: str,
entity_ids: list[str],
canonical_names: list[str],
entity_kinds: list[str],
) -> None:
# One statement, one round-trip (same shape as bulk_insert_links):
# * the CTE takes FOR KEY SHARE on every parent that still exists,
# held to COMMIT, so a concurrent prune_orphan_entities DELETE blocks
# until the caller's unit_entities insert has committed;
# * the INSERT re-creates only the parents that were already pruned
# (NOT IN locked), carrying the canonical_name resolved in Phase 1.
# ON CONFLICT DO NOTHING (no target) keeps the rare case where another
# worker recreated the name under a new id from raising — that row stays
# absent and its unit link is the sole casualty, never the whole batch.
await conn.execute(
f"""
WITH locked AS (
SELECT id FROM {table}
WHERE id = ANY($2::uuid[])
ORDER BY id
FOR KEY SHARE
)
INSERT INTO {table} (id, bank_id, canonical_name, entity_kind)
SELECT t.entity_id, $1, t.canonical_name, t.entity_kind
FROM unnest($2::uuid[], $3::text[], $4::text[]) AS t(entity_id, canonical_name, entity_kind)
WHERE t.entity_id NOT IN (SELECT id FROM locked)
ON CONFLICT DO NOTHING
""",
bank_id,
entity_ids,
canonical_names,
entity_kinds,
)
async def bulk_insert_unit_entities(
self,
conn: DatabaseConnection,
@@ -353,14 +398,36 @@ class PostgreSQLOps(DataAccessOps):
) -> None:
if not unit_ids:
return
# Sort to enforce a global lock-acquisition order on the
# (bank_id, unit_id) unique-key. Without this, two concurrent
# transactions inserting overlapping unit_id sets in different
# orders can deadlock on the ON CONFLICT row locks — Postgres
# acquires a short-lived lock per row being checked, and cycle
# detection then aborts one transaction. Sorting gives every
# concurrent caller the same lock order, so conflicting inserts
# queue cleanly instead of cycling.
sorted_unit_ids = sorted(unit_ids)
# DO UPDATE (not DO NOTHING) on a duplicate enqueue — #3034. The SET is a
# deliberate no-op that preserves enqueued_at; its only purpose is to take
# the existing row's lock. DO NOTHING does NOT lock the conflicting row, so
# a mutation that re-enqueues an already-queued unit could not block a
# worker from concurrently claiming (deleting) that row and processing the
# unit's pre-mutation state; the re-enqueue signal was then silently lost
# and the unit's derived links stayed stale with an empty queue. Locking
# the row serialises the mutation against the worker's claim for that
# (bank_id, unit_id): the worker either waits for the committed post-mutation
# state, or (if it claimed first) this INSERT lands a fresh row after the
# worker's delete commits. Row locks are acquired in sorted unit_id order,
# matching claim_graph_maintenance_batch, so the two never cycle.
await conn.execute(
f"""
INSERT INTO {table} (bank_id, unit_id)
SELECT $1, v FROM unnest($2::uuid[]) AS t(v)
ON CONFLICT (bank_id, unit_id) DO NOTHING
ON CONFLICT (bank_id, unit_id)
DO UPDATE SET enqueued_at = {table}.enqueued_at
""",
bank_id,
unit_ids,
sorted_unit_ids,
)
async def claim_graph_maintenance_batch(
@@ -370,16 +437,35 @@ class PostgreSQLOps(DataAccessOps):
bank_id: str,
limit: int,
) -> list[str]:
# Ordered locking (#3034). Choose the oldest batch by enqueued_at, but
# acquire the row locks in (bank_id, unit_id) order — the same order the
# enqueue upsert takes them — so a foreground mutation re-enqueueing an
# overlapping unit set can never cycle against a worker draining it. The
# `chosen` CTE is MATERIALIZED so the enqueued_at pick is fenced from the
# locking clause; `FOR UPDATE OF q ... ORDER BY q.unit_id` then puts
# LockRows above the Sort, so locks are taken ascending by unit_id (same
# idiom as prune_stale_cooccurrences' #2529 ordered lock). A concurrent
# enqueue holding one of these rows blocks this claim until it commits, at
# which point the worker deletes and processes the committed state.
rows = await conn.fetch(
f"""
DELETE FROM {table}
WHERE (bank_id, unit_id) IN (
WITH chosen AS MATERIALIZED (
SELECT bank_id, unit_id FROM {table}
WHERE bank_id = $1
ORDER BY enqueued_at
LIMIT $2
),
locked AS (
SELECT q.bank_id, q.unit_id
FROM {table} q
JOIN chosen c ON c.bank_id = q.bank_id AND c.unit_id = q.unit_id
ORDER BY q.unit_id
FOR UPDATE OF q
)
RETURNING unit_id
DELETE FROM {table} q
USING locked l
WHERE q.bank_id = l.bank_id AND q.unit_id = l.unit_id
RETURNING q.unit_id
""",
bank_id,
limit,
@@ -421,19 +507,48 @@ class PostgreSQLOps(DataAccessOps):
# Scope by joining through entities.bank_id (entity_cooccurrences itself
# has no bank_id column — entities don't span banks, so scoping via
# entity_id_1 is sufficient).
#
# Ordered locking (deadlock avoidance, #2529): retain's concurrent
# cooccurrence upsert (entity_resolver._flush_pending) locks rows in
# sorted (entity_id_1, entity_id_2) order — sorted specifically to give
# every writer one consistent lock-acquisition order. A plain
# `DELETE ... USING` scans/locks in whatever order the join plan picks,
# so it could lock the same rows in the opposite order and cycle. We
# instead select the victims in that same sorted order `FOR UPDATE`
# first — the locking clause materialises the CTE and places LockRows
# above the Sort, so locks are acquired ascending, matching the upsert —
# then delete the already-locked rows. Same order on both sides ⇒ no
# cycle (the deadlock is prevented, not merely retried). The Pass 2/3
# retry wrap in run_graph_maintenance_job stays as a backstop for the
# residual paths (FK cascade from prune_orphan_entities, Oracle).
#
# The staleness predicate is an INTERSECT of the two entities' unit sets
# rather than the equivalent `unit_entities u1 JOIN u2 ON u1.unit_id =
# u2.unit_id` self-join (#2473): both INTERSECT branches resolve as Index
# Only Scans on idx_unit_entities_entity_unit (entity_id, unit_id), so the
# per-pair cost is bounded by the two entities' degrees. The self-join let
# the planner pick an anti-join that rescanned a high-degree hub entity's
# membership set for every pair — 28-30min on a bank with a ~100K-membership
# hub, even when zero rows were stale. Don't "simplify" it back.
result = await conn.execute(
f"""
WITH victims AS (
SELECT c.entity_id_1, c.entity_id_2
FROM {ec_table} c
JOIN {entities_table} e ON e.id = c.entity_id_1
WHERE e.bank_id = $1
AND NOT EXISTS (
SELECT unit_id FROM {ue_table} WHERE entity_id = c.entity_id_1
INTERSECT
SELECT unit_id FROM {ue_table} WHERE entity_id = c.entity_id_2
)
ORDER BY c.entity_id_1, c.entity_id_2
FOR UPDATE OF c
)
DELETE FROM {ec_table} c
USING {entities_table} e
WHERE e.id = c.entity_id_1
AND e.bank_id = $1
AND NOT EXISTS (
SELECT 1
FROM {ue_table} u1
JOIN {ue_table} u2 ON u1.unit_id = u2.unit_id
WHERE u1.entity_id = c.entity_id_1
AND u2.entity_id = c.entity_id_2
)
USING victims v
WHERE c.entity_id_1 = v.entity_id_1
AND c.entity_id_2 = v.entity_id_2
""",
bank_id,
)
@@ -445,11 +560,21 @@ class PostgreSQLOps(DataAccessOps):
mu_table: str,
unit_ids: list[str],
) -> list[ResultRow]:
# Cast only canonical UUID text inputs, never the indexed column. The old
# ``id::text`` predicate silently ignored malformed, uppercase, braced,
# and unhyphenated inputs; filtering before the cast preserves that
# behavior while allowing the primary-key index to serve the lookup.
return await conn.fetch(
f"""
SELECT id, event_date, fact_type
FROM {mu_table}
WHERE id::text = ANY($1)
WHERE id = ANY(
ARRAY(
SELECT input.unit_id::uuid
FROM unnest($1::text[]) AS input(unit_id)
WHERE input.unit_id ~ '^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$'
)
)
""",
unit_ids,
)
@@ -518,6 +643,7 @@ class PostgreSQLOps(DataAccessOps):
mu_table: str,
ue_table: str,
per_entity_limit: int,
window: UpdatedWindow,
) -> str:
return f"""
seed_entities AS (
@@ -537,11 +663,20 @@ class PostgreSQLOps(DataAccessOps):
FROM {ue_table} ue_target
WHERE ue_target.entity_id = se.entity_id
AND ue_target.unit_id != ALL($1::uuid[])
-- Filter before applying the cap: candidates from other fact
-- types, or outside the recall window, must not consume this
-- entity's bounded fan-out.
AND EXISTS (
SELECT 1
FROM {mu_table} mu_target
WHERE mu_target.id = ue_target.unit_id
AND mu_target.fact_type = $2
{window.clause("mu_target")}
)
ORDER BY ue_target.unit_id DESC
LIMIT {per_entity_limit}
) t
JOIN {mu_table} mu ON mu.id = t.unit_id
WHERE mu.fact_type = $2
GROUP BY mu.id
ORDER BY score DESC
LIMIT $3
@@ -551,6 +686,7 @@ class PostgreSQLOps(DataAccessOps):
self,
ml_table: str,
mu_table: str,
window: UpdatedWindow,
) -> str:
# Exact v0.5.6 query shape: GROUP BY + MAX(weight) for semantic,
# DISTINCT ON for causal.
@@ -574,6 +710,7 @@ class PostgreSQLOps(DataAccessOps):
AND ml.link_type = 'semantic'
AND mu.fact_type = $2
AND mu.id != ALL($1::uuid[])
{window.clause("mu")}
UNION ALL
SELECT
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
@@ -586,6 +723,7 @@ class PostgreSQLOps(DataAccessOps):
AND ml.link_type = 'semantic'
AND mu.fact_type = $2
AND mu.id != ALL($1::uuid[])
{window.clause("mu")}
) sem_raw
GROUP BY id, text, context, event_date, occurred_start,
occurred_end, mentioned_at,
@@ -605,6 +743,7 @@ class PostgreSQLOps(DataAccessOps):
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type IN ('causes', 'caused_by', 'enables', 'prevents')
AND mu.fact_type = $2
{window.clause("mu")}
ORDER BY mu.id, ml.weight DESC
LIMIT $3
)"""
@@ -618,9 +757,13 @@ class PostgreSQLOps(DataAccessOps):
seed_ids: list,
budget: int,
per_entity_limit: int,
) -> tuple[list[ResultRow], list[ResultRow], list[ResultRow]]:
window: UpdatedWindow,
) -> LinkExpansionRows:
# v0.5.6 array ops: unnest, &&, COUNT(DISTINCT) on source_memory_ids.
from ..schema import fq_table
#
# The window bounds the observations that come *back*, not the source facts
# traversed to reach them: an observation is in the window when it was itself
# written or refreshed there, regardless of how old the facts underneath it are.
entity_rows = await conn.fetch(
f"""
@@ -662,11 +805,13 @@ class PostgreSQLOps(DataAccessOps):
AND mu.id != ALL($1::uuid[])
AND ca.source_ids IS NOT NULL
AND mu.source_memory_ids && ca.source_ids
{window.clause("mu")}
ORDER BY score DESC
LIMIT $2
""",
seed_ids,
budget,
*window.params,
)
# Exact v0.5.6 query shape: GROUP BY + MAX(weight) for semantic,
@@ -688,6 +833,7 @@ class PostgreSQLOps(DataAccessOps):
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type = 'semantic' AND mu.fact_type = 'observation'
AND mu.id != ALL($1::uuid[])
{window.clause("mu")}
UNION ALL
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id,
@@ -696,6 +842,7 @@ class PostgreSQLOps(DataAccessOps):
WHERE ml.to_unit_id = ANY($1::uuid[])
AND ml.link_type = 'semantic' AND mu.fact_type = 'observation'
AND mu.id != ALL($1::uuid[])
{window.clause("mu")}
) sem_raw
GROUP BY id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, fact_type, document_id, chunk_id, tags, proof_count
@@ -710,6 +857,7 @@ class PostgreSQLOps(DataAccessOps):
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type IN ('causes', 'caused_by', 'enables', 'prevents')
AND mu.fact_type = 'observation'
{window.clause("mu")}
ORDER BY mu.id, ml.weight DESC LIMIT $2
)
SELECT * FROM semantic_expanded
@@ -718,11 +866,12 @@ class PostgreSQLOps(DataAccessOps):
""",
seed_ids,
budget,
*window.params,
)
semantic_rows = [r for r in sem_causal_rows if r["source"] == "semantic"]
causal_rows = [r for r in sem_causal_rows if r["source"] == "causal"]
return list(entity_rows), semantic_rows, causal_rows
return LinkExpansionRows(entity=list(entity_rows), semantic=semantic_rows, causal=causal_rows)
def build_tag_listing_parts(self, mu_table: str) -> TagListingParts:
return TagListingParts(
@@ -742,14 +891,15 @@ class PostgreSQLOps(DataAccessOps):
fact_types: dict[str, str],
) -> None:
escaped = bank_id.replace("'", "''")
for ft, suffix in fact_types.items():
uid = str(internal_id).replace("-", "")[:16]
idx = f"idx_mu_emb_{suffix}_{uid}"
await conn.execute(
f"CREATE INDEX IF NOT EXISTS {idx} "
f"ON {table} {index_clause} "
f"WHERE fact_type = '{ft}' AND bank_id = '{escaped}'"
)
async with self._index_ddl_lock(table):
for ft, suffix in fact_types.items():
uid = str(internal_id).replace("-", "")[:16]
idx = f"idx_mu_emb_{suffix}_{uid}"
await conn.execute(
f"CREATE INDEX IF NOT EXISTS {idx} "
f"ON {table} {index_clause} "
f"WHERE fact_type = '{ft}' AND bank_id = '{escaped}'"
)
async def drop_bank_vector_indexes(
self,
@@ -758,10 +908,19 @@ class PostgreSQLOps(DataAccessOps):
internal_id: str,
fact_types: dict[str, str],
) -> None:
for ft, suffix in fact_types.items():
uid = str(internal_id).replace("-", "")[:16]
idx = f"idx_mu_emb_{suffix}_{uid}"
await conn.execute(f"DROP INDEX IF EXISTS {schema}.{idx}")
# CONCURRENTLY so the drop takes ShareUpdateExclusive, not ACCESS
# EXCLUSIVE, on the shared memory_units table. A plain DROP INDEX blocks
# (and deadlocks with) every other bank's concurrent reads/writes on the
# table; CONCURRENTLY does not conflict with DML. The caller
# (delete_bank) runs this on an autocommit connection after its delete
# transaction has committed — CONCURRENTLY cannot run inside a tx.
# The lock key must match create_bank_vector_indexes', whose `table`
# is the fq name this reconstructs from `schema`.
async with self._index_ddl_lock(f"{schema}.memory_units"):
for ft, suffix in fact_types.items():
uid = str(internal_id).replace("-", "")[:16]
idx = f"idx_mu_emb_{suffix}_{uid}"
await conn.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}.{idx}")
def get_entity_resolution_strategy(self) -> str:
return "trigram"
@@ -891,6 +1050,93 @@ class PostgreSQLOps(DataAccessOps):
# -- Task claiming operations ------------------------------------------
async def prune_terminal_operations(
self,
conn: DatabaseConnection,
table: str,
cutoff: datetime,
*,
batch_size: int,
) -> int:
# Lock only the bounded candidate set. SKIP LOCKED lets multiple
# workers prune disjoint batches without waiting or double-deleting.
# Cancelled children cannot complete parent aggregation, so retain the
# parent guard only for completed/failed children. Before removing a
# cancelled child, preserve its signal by cancelling a pending parent
# in this transaction and refreshing the parent's retention window.
candidates = await conn.fetch(
f"""
SELECT candidate_operation.operation_id
FROM {table} candidate_operation
WHERE candidate_operation.status IN ('completed', 'failed', 'cancelled')
AND candidate_operation.updated_at < $1
AND (
candidate_operation.status = 'cancelled'
OR NOT EXISTS (
SELECT 1
FROM {table} parent
WHERE parent.operation_id = CASE
WHEN candidate_operation.result_metadata->>'parent_operation_id'
~* '^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$'
THEN (candidate_operation.result_metadata->>'parent_operation_id')::uuid
ELSE NULL
END
AND parent.bank_id = candidate_operation.bank_id
)
)
ORDER BY candidate_operation.updated_at, candidate_operation.operation_id
LIMIT $2
FOR UPDATE OF candidate_operation SKIP LOCKED
""",
cutoff,
batch_size,
)
if not candidates:
return 0
candidate_ids = [row["operation_id"] for row in candidates]
await conn.execute(
f"""
UPDATE {table} parent
SET status = 'cancelled',
updated_at = now(),
completed_at = COALESCE(parent.completed_at, now()),
error_message = COALESCE(
parent.error_message,
'Cancelled because a child operation was cancelled'
)
WHERE parent.status = 'pending'
AND EXISTS (
SELECT 1
FROM {table} candidate_operation
WHERE candidate_operation.operation_id = ANY($1)
AND candidate_operation.status = 'cancelled'
AND candidate_operation.updated_at < $2
AND candidate_operation.bank_id = parent.bank_id
AND parent.operation_id = CASE
WHEN candidate_operation.result_metadata->>'parent_operation_id'
~* '^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$'
THEN (candidate_operation.result_metadata->>'parent_operation_id')::uuid
ELSE NULL
END
)
""",
candidate_ids,
cutoff,
)
rows = await conn.fetch(
f"""
DELETE FROM {table}
WHERE operation_id = ANY($1)
AND status IN ('completed', 'failed', 'cancelled')
AND updated_at < $2
RETURNING operation_id
""",
candidate_ids,
cutoff,
)
return len(rows)
async def _claim_consolidation_tasks(
self,
conn,
@@ -1181,13 +1427,14 @@ class PostgreSQLOps(DataAccessOps):
else:
rows = await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type = $1
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY created_at
SELECT o.operation_id, o.operation_type, o.task_payload, o.retry_count
FROM {table} o
WHERE o.status = 'pending'
AND o.task_payload IS NOT NULL
AND o.operation_type = $1
AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW())
AND {graph_maintenance_bank_serialization_sql(table, "o")}
ORDER BY o.created_at
LIMIT $2
FOR UPDATE SKIP LOCKED
""",
@@ -1202,18 +1449,21 @@ class PostgreSQLOps(DataAccessOps):
# --- Phase 2: claim from shared pool ---
remaining_shared = shared_limit
if remaining_shared > 0:
# 2a. Non-consolidation tasks
# 2a. Non-consolidation tasks. graph_maintenance stays in this
# created_at-ordered query — see graph_maintenance_bank_serialization_sql
# for why it is a predicate rather than a phase of its own.
if claimed_ids:
rows = await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type != 'consolidation'
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
AND operation_id != ALL($1::uuid[])
ORDER BY created_at
SELECT o.operation_id, o.operation_type, o.task_payload, o.retry_count
FROM {table} o
WHERE o.status = 'pending'
AND o.task_payload IS NOT NULL
AND o.operation_type != 'consolidation'
AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW())
AND o.operation_id != ALL($1::uuid[])
AND {graph_maintenance_bank_serialization_sql(table, "o")}
ORDER BY o.created_at
LIMIT $2
FOR UPDATE SKIP LOCKED
""",
@@ -1223,13 +1473,14 @@ class PostgreSQLOps(DataAccessOps):
else:
rows = await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type != 'consolidation'
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY created_at
SELECT o.operation_id, o.operation_type, o.task_payload, o.retry_count
FROM {table} o
WHERE o.status = 'pending'
AND o.task_payload IS NOT NULL
AND o.operation_type != 'consolidation'
AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW())
AND {graph_maintenance_bank_serialization_sql(table, "o")}
ORDER BY o.created_at
LIMIT $1
FOR UPDATE SKIP LOCKED
""",
@@ -106,6 +106,15 @@ SCHEMAS_WITH_PENDING_WORK = OptionalRoutine(
deployment.
* Should be cheap and idempotent — called every poll cycle (~30s).
The poller trusts the result wholesale: any schema the routine does
not return is treated as having no work this cycle. It does NOT
second-guess omissions with a per-schema scan — that would re-run the
exact queries this routine exists to avoid. Consequently the routine
is *only* appropriate for multi-tenant deployments. Single-schema
(default/public only) installs should NOT create it: the per-schema
fallback below is a single cheap EXISTS check that covers ``public``
correctly and cannot starve.
Fallback when the routine is absent: per-schema ``EXISTS`` queries
from Python (~4ms per schema). The server-side path is a single-
round-trip optimisation worth ~200ms in deployments with thousands
@@ -23,6 +23,8 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any, NamedTuple
from .pool_instrumentation import PoolStats, acquire_conn
class _OracleJSONEncoder(json.JSONEncoder):
"""JSON encoder that handles datetime and UUID objects."""
@@ -78,7 +80,9 @@ _LIKE_ANY_RE = re.compile(r"(\w+)\s+LIKE\s+ANY\s*\(\s*:(\d+)\s*\)", re.IGNORECAS
_NOT_LIKE_ALL_RE = re.compile(r"(\w+)\s+NOT\s+LIKE\s+ALL\s*\(\s*:(\d+)\s*\)", re.IGNORECASE)
_JSON_ARROW_TEXT_RE = re.compile(r'("?\w+"?)\s*->>\s*\'(\w+)\'') # handles both col and "col"
_JSON_HAS_KEY_RE = re.compile(r"(\w+)\s*\?\s*'(\w+)'")
# Reserved-word columns ("trigger") are already quoted by the time this runs, so the
# column group must accept the quoted form too — same shape as the arrow regex above.
_JSON_HAS_KEY_RE = re.compile(r"(\"?\w+\"?)\s*\?\s*'(\w+)'")
_JSONB_CONTAINS_RE = re.compile(r"(\w+)\s*@>\s*:(\d+)")
# ---------------------------------------------------------------------------
@@ -146,6 +150,7 @@ _JSON_COL_NAMES = {
"config",
"observation_scopes",
"source_memory_ids",
"causal_links",
"trigger",
"http_config",
"event_types",
@@ -444,9 +449,6 @@ def _rewrite_pg_to_oracle(query: str) -> RewriteResult:
if has_for_update:
# FOR UPDATE path: use ROWNUM instead of FETCH FIRST.
# Extract and remove LIMIT clause, inject ROWNUM into WHERE.
def _limit_to_rownum(m):
return "" # Remove the LIMIT clause; we'll add ROWNUM below
limit_val = None
limit_match = re.search(r"\bLIMIT\s+(\d+|:\w+)\b", query, re.IGNORECASE)
if limit_match:
@@ -687,7 +689,6 @@ class OracleConnection(DatabaseConnection):
"max_tokens",
"priority",
"proof_count",
"access_count",
"importance_score",
"decay_factor",
"chunk_index",
@@ -1242,6 +1243,11 @@ class OracleBackend(DatabaseBackend):
def __init__(self) -> None:
self._pool: Any = None
self._oracledb: Any = None
# Oracle pooled sessions retain CURRENT_SCHEMA across checkouts. Cache
# SESSION_USER so default-schema acquisitions can explicitly reset a
# connection that was previously used for a tenant schema.
self._default_schema: str | None = None
self._acquire_warn_threshold_s: float = 1.0
async def initialize(
self,
@@ -1257,6 +1263,10 @@ class OracleBackend(DatabaseBackend):
oracledb = _import_oracledb()
self._oracledb = oracledb
from ...config import get_config
self._acquire_warn_threshold_s = get_config().db_acquire_warn_threshold_ms / 1000.0
# Parse URL-format DSN (oracle://user:pass@host:port/service)
from urllib.parse import urlparse
@@ -1277,11 +1287,17 @@ class OracleBackend(DatabaseBackend):
logger.info(f"Oracle pool created (min={min_size}, max={max_size})")
async def shutdown(self) -> None:
if self._pool is not None:
await self._pool.close(force=True)
self._pool = None
# Drop the reference before awaiting close() so is_ready flips False for
# the whole teardown, not just after it completes (see PostgreSQLBackend).
pool, self._pool = self._pool, None
if pool is not None:
await pool.close(force=True)
logger.info("Oracle pool closed")
@property
def is_ready(self) -> bool:
return self._pool is not None
async def _set_session_schema(self, conn: Any) -> None:
"""Set the session schema on an Oracle connection.
@@ -1294,15 +1310,41 @@ class OracleBackend(DatabaseBackend):
from ..memory_engine import get_current_schema
schema = get_current_schema()
if schema and schema != "public":
cursor = conn.cursor()
await cursor.execute(f'ALTER SESSION SET CURRENT_SCHEMA = "{schema}"')
await cursor.close()
cursor = conn.cursor()
try:
if self._default_schema is None:
await cursor.execute("SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM DUAL")
row = await cursor.fetchone()
if not row or not row[0]:
raise RuntimeError("Oracle did not return SESSION_USER while resetting CURRENT_SCHEMA")
self._default_schema = str(row[0])
target_schema = self._default_schema if not schema or schema == "public" else schema
safe_schema = target_schema.replace('"', '""')
await cursor.execute(f'ALTER SESSION SET CURRENT_SCHEMA = "{safe_schema}"')
finally:
# oracledb's AsyncCursor.close() is synchronous (not a coroutine);
# awaiting it raises "object NoneType can't be used in 'await'
# expression" and aborts every acquire().
cursor.close()
def _pool_stats(self) -> PoolStats | None:
"""Snapshot for slow-acquire logs, from oracledb pool attributes."""
pool = self._pool
if pool is None:
return None
try:
busy = pool.busy
return PoolStats(in_use=busy, max=pool.max, idle=pool.opened - busy)
except Exception:
return None
@asynccontextmanager
async def acquire(self) -> AsyncIterator[OracleConnection]:
pool = self._ensure_pool()
conn = await pool.acquire()
conn = await acquire_conn(
pool.acquire(), pool_stats=self._pool_stats, warn_threshold_s=self._acquire_warn_threshold_s
)
try:
await self._set_session_schema(conn)
yield OracleConnection(conn)
@@ -1318,7 +1360,9 @@ class OracleBackend(DatabaseBackend):
@asynccontextmanager
async def transaction(self) -> AsyncIterator[OracleConnection]:
pool = self._ensure_pool()
conn = await pool.acquire()
conn = await acquire_conn(
pool.acquire(), pool_stats=self._pool_stats, warn_threshold_s=self._acquire_warn_threshold_s
)
try:
await self._set_session_schema(conn)
yield OracleConnection(conn)
@@ -0,0 +1,137 @@
"""Instrumentation for database connection-pool acquisition.
asyncpg exposes pool *size* and *idle* counts, but not how many callers are
currently **queued waiting** for a connection and that queue depth is the
signal that actually distinguishes a saturated pool from a healthy one. When the
pool is exhausted, ``/health`` (which itself acquires a connection to run
``SELECT 1``) blocks in ``pool.acquire()`` until a connection frees or the acquire
times out, so a liveness probe can fail **with the event loop completely idle**.
This module tracks the process-wide count of in-flight acquisitions that have not
yet obtained a connection, and times each acquire so a slow one logs with full
pool stats. It is the DB-side counterpart to ``loop_watchdog`` (which covers loop
stalls); together, a stuck ``/health`` can be attributed to either a blocked loop
or pool exhaustion from the logs alone.
The counter is a plain int mutated only from the event-loop thread (asyncpg
acquisitions are awaited on the loop), so no lock is needed.
"""
from __future__ import annotations
import logging
import time
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger("hindsight.db.pool")
_waiting = 0 # callers currently blocked in pool.acquire(), process-wide
@dataclass(frozen=True, slots=True)
class PoolStats:
"""Point-in-time connection-pool utilization snapshot."""
in_use: int
max: int
idle: int
def waiting_count() -> int:
"""Number of callers currently blocked waiting to acquire a pooled connection."""
return _waiting
@asynccontextmanager
async def instrument_acquire(
acquire_cm: Any,
*,
pool_stats: Callable[[], PoolStats | None] | None = None,
warn_threshold_s: float,
) -> AsyncIterator[Any]:
"""Wrap a pool's ``acquire()`` context manager with wait tracking + slow-acquire logging.
Args:
acquire_cm: an async context manager yielding a connection (e.g. the object
returned by ``asyncpg.Pool.acquire()``).
pool_stats: optional zero-arg callable returning a ``PoolStats`` snapshot for
the slow-acquire log line.
warn_threshold_s: log a warning when the acquire itself takes at least this long.
Yields:
The acquired connection.
"""
global _waiting
_waiting += 1
start = time.monotonic()
acquired = False
try:
async with acquire_cm as conn:
acquired = True
_waiting -= 1
_record_acquire_wait(time.monotonic() - start, pool_stats, warn_threshold_s)
yield conn
finally:
# If __aenter__ raised (acquire timeout / cancellation), we never
# decremented above — do it here so the waiter count can't leak.
if not acquired:
_waiting -= 1
async def acquire_conn(
acquire_awaitable: Any,
*,
pool_stats: Callable[[], PoolStats | None] | None = None,
warn_threshold_s: float,
) -> Any:
"""Await a pool acquire that returns a connection, with wait tracking + slow log.
For pools whose acquire is ``conn = await pool.acquire()`` (oracledb) rather than
an async context manager (asyncpg use ``instrument_acquire`` for those). The
caller is responsible for releasing the returned connection.
"""
global _waiting
_waiting += 1
start = time.monotonic()
try:
conn = await acquire_awaitable
finally:
_waiting -= 1
_record_acquire_wait(time.monotonic() - start, pool_stats, warn_threshold_s)
return conn
def _record_acquire_wait(
wait_s: float,
pool_stats: Callable[[], PoolStats | None] | None,
warn_threshold_s: float,
) -> None:
try:
from ...metrics import get_metrics_collector
get_metrics_collector().record_db_acquire_wait(wait_s)
except Exception:
pass
if wait_s < warn_threshold_s:
return
stats: PoolStats | None = None
if pool_stats is not None:
try:
stats = pool_stats()
except Exception:
stats = None
logger.warning(
"slow DB pool acquire: waited %.3fs for a connection "
"(in_use=%s max=%s idle=%s waiting=%s). The pool is likely saturated; "
"/health can stall on connection acquisition while the event loop is free.",
wait_s,
stats.in_use if stats else None,
stats.max if stats else None,
stats.idle if stats else None,
_waiting,
)
@@ -15,6 +15,7 @@ from typing import Any
import asyncpg # noqa: F401
from .base import DatabaseBackend, DatabaseConnection
from .pool_instrumentation import PoolStats, instrument_acquire
logger = logging.getLogger(__name__)
@@ -76,6 +77,8 @@ class PostgreSQLBackend(DatabaseBackend):
def __init__(self) -> None:
self._pool: asyncpg.Pool | None = None
self._acquire_warn_threshold_s: float = 1.0
self._acquire_timeout_s: float | None = None
async def initialize(
self,
@@ -88,6 +91,16 @@ class PostgreSQLBackend(DatabaseBackend):
statement_cache_size: int = 0,
init_callback: Any | None = None,
) -> None:
from ...config import get_config
self._acquire_warn_threshold_s = get_config().db_acquire_warn_threshold_ms / 1000.0
# Kept for acquire() below: asyncpg's ``timeout`` create_pool kwarg is a
# *connect* kwarg (how long establishing a new connection may take), and
# ``Pool.acquire()`` defaults to waiting for a free connection forever.
# Passing it here alone made HINDSIGHT_API_DB_ACQUIRE_TIMEOUT a no-op for
# the wait it names: a pool-exhaustion stall never surfaced as an error,
# it just hung (#3002). 0 restores the unbounded behaviour.
self._acquire_timeout_s = acquire_timeout if acquire_timeout > 0 else None
self._pool = await asyncpg.create_pool(
dsn,
min_size=min_size,
@@ -95,7 +108,12 @@ class PostgreSQLBackend(DatabaseBackend):
command_timeout=command_timeout,
statement_cache_size=statement_cache_size,
timeout=acquire_timeout,
# init runs once per new connection; setup runs on every acquire,
# after asyncpg's release-time RESET ALL. Passing init_callback as
# both keeps the per-connection session GUCs (hnsw.ef_search, etc.)
# applied after a connection is reused, not just on first creation.
init=init_callback,
setup=init_callback,
)
logger.info(
f"PostgreSQL pool created (min={min_size}, max={max_size}, "
@@ -103,21 +121,45 @@ class PostgreSQLBackend(DatabaseBackend):
)
async def shutdown(self) -> None:
if self._pool is not None:
await self._pool.close()
self._pool = None
# Drop the reference *before* awaiting close(): closing is not
# instantaneous, and anything acquiring during that window would
# otherwise get an asyncpg "pool is closing" error rather than seeing
# is_ready False.
pool, self._pool = self._pool, None
if pool is not None:
await pool.close()
logger.info("PostgreSQL pool closed")
@property
def is_ready(self) -> bool:
return self._pool is not None
def _pool_stats(self) -> PoolStats | None:
"""Snapshot for slow-acquire logs. in_use = live connections minus idle ones."""
pool = self._pool
if pool is None:
return None
idle = pool.get_idle_size()
return PoolStats(in_use=pool.get_size() - idle, max=pool.get_max_size(), idle=idle)
@asynccontextmanager
async def acquire(self) -> AsyncIterator[PostgresConnection]:
pool = self._ensure_pool()
async with pool.acquire() as conn:
async with instrument_acquire(
pool.acquire(timeout=self._acquire_timeout_s),
pool_stats=self._pool_stats,
warn_threshold_s=self._acquire_warn_threshold_s,
) as conn:
yield PostgresConnection(conn)
@asynccontextmanager
async def transaction(self) -> AsyncIterator[PostgresConnection]:
pool = self._ensure_pool()
async with pool.acquire() as conn:
async with instrument_acquire(
pool.acquire(timeout=self._acquire_timeout_s),
pool_stats=self._pool_stats,
warn_threshold_s=self._acquire_warn_threshold_s,
) as conn:
async with conn.transaction():
yield PostgresConnection(conn)
@@ -164,35 +164,6 @@ class BudgetedOperation:
"""
return BudgetedPool(pool, self)
async def acquire_many(
self,
pool: Any,
count: int,
) -> AsyncIterator[list[Any]]:
"""
Acquire multiple connections within the budget.
Note: This acquires connections sequentially to respect the budget.
For parallel acquisition, use multiple acquire() calls with asyncio.gather().
This method is intended for use with raw asyncpg pools only, not DatabaseBackend.
Args:
pool: asyncpg connection pool (raw pool only)
count: Number of connections to acquire
Yields:
List of database connections
"""
connections = []
try:
for _ in range(count):
conn = await pool.acquire()
connections.append(conn)
yield connections
finally:
for conn in connections:
await pool.release(conn)
# Global default manager instance
_default_manager: ConnectionBudgetManager | None = None
@@ -4,6 +4,7 @@ Database utility functions for connection management with retry logic.
import asyncio
import logging
import random
import time
from collections.abc import AsyncIterator
from contextlib import AsyncExitStack, asynccontextmanager
@@ -16,6 +17,20 @@ DEFAULT_MAX_RETRIES = 3
DEFAULT_BASE_DELAY = 0.5 # seconds
DEFAULT_MAX_DELAY = 5.0 # seconds
def _backoff_delay(attempt: int, base_delay: float, max_delay: float) -> float:
"""Exponential backoff with equal jitter.
Deterministic backoff makes concurrent retriers wake in lock-step and
re-collide on the very same rows, re-triggering the deadlock they just
backed off from. "Equal jitter" half the window fixed, half random
keeps a floor (so we don't hot-spin) while decorrelating the wake-ups, so
two contenders that deadlocked together are very unlikely to retry in sync.
"""
ceil = min(base_delay * (2**attempt), max_delay)
return ceil / 2 + random.uniform(0, ceil / 2)
# Retryable exception types (checked by class name to avoid hard imports)
_RETRYABLE_EXCEPTION_NAMES = frozenset(
{
@@ -78,7 +93,7 @@ async def retry_with_backoff(
raise
last_exception = e
if attempt < max_retries:
delay = min(base_delay * (2**attempt), max_delay)
delay = _backoff_delay(attempt, base_delay, max_delay)
if type(e).__name__ == "DeadlockDetectedError" or _is_oracle_deadlock(e):
logger.warning(
"Deadlock detected during parallel document processing — "
@@ -136,7 +151,7 @@ async def acquire_with_retry(backend_or_pool: Any, max_retries: int = DEFAULT_MA
if not _is_retryable(e):
raise
if attempt < max_retries:
delay = min(DEFAULT_BASE_DELAY * (2**attempt), DEFAULT_MAX_DELAY)
delay = _backoff_delay(attempt, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY)
logger.warning(
f"Database acquire failed (attempt {attempt + 1}/{max_retries + 1}): {e}. "
f"Retrying in {delay:.1f}s..."
@@ -26,11 +26,8 @@ from ..config import (
DEFAULT_EMBEDDINGS_GEMINI_MODEL,
DEFAULT_EMBEDDINGS_LITELLM_MODEL,
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL,
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU,
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
DEFAULT_EMBEDDINGS_OPENAI_MODEL,
DEFAULT_EMBEDDINGS_PROVIDER,
DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE,
DEFAULT_EMBEDDINGS_ZEROENTROPY_DIMENSIONS,
DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT,
@@ -40,13 +37,6 @@ from ..config import (
DEFAULT_ZEROENTROPY_BASE_URL,
ENV_EMBEDDINGS_COHERE_API_KEY,
ENV_EMBEDDINGS_GEMINI_API_KEY,
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
ENV_EMBEDDINGS_LOCAL_MODEL,
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
ENV_EMBEDDINGS_ONNX_DIMENSIONS,
ENV_EMBEDDINGS_ONNX_MODEL_ID,
ENV_EMBEDDINGS_ONNX_MODEL_PATH,
ENV_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH,
ENV_EMBEDDINGS_OPENAI_API_KEY,
ENV_EMBEDDINGS_OPENAI_BASE_URL,
ENV_EMBEDDINGS_OPENAI_MODEL,
@@ -57,6 +47,13 @@ from ..config import (
ENV_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT,
ENV_LLM_API_KEY,
)
from .bank_attribution import apply_bank_attribution
from .local_device import (
release_local_inference_memory,
resolve_model_device_type,
select_local_device,
)
from .tei_retry import tei_retry_delay
logger = logging.getLogger(__name__)
@@ -145,7 +142,13 @@ class LocalSTEmbeddings(Embeddings):
The embedding dimension is auto-detected from the model.
"""
def __init__(self, model_name: str | None = None, force_cpu: bool = False, trust_remote_code: bool = False):
def __init__(
self,
model_name: str | None = None,
force_cpu: bool = False,
trust_remote_code: bool = False,
allow_mps: bool = False,
):
"""
Initialize local SentenceTransformers embeddings.
@@ -157,12 +160,17 @@ class LocalSTEmbeddings(Embeddings):
trust_remote_code: Allow loading models with custom code (security risk).
Required for some models with custom architectures.
Default: False (disabled for security)
allow_mps: Opt in to the Apple Silicon MPS GPU. Disabled by default
because MPS leaks memory under variable-length workloads
(see engine/local_device.py). Default: False
"""
self.model_name = model_name or DEFAULT_EMBEDDINGS_LOCAL_MODEL
self.force_cpu = force_cpu
self.trust_remote_code = trust_remote_code
self.allow_mps = allow_mps
self._model = None
self._dimension: int | None = None
self._device_type: str = "cpu"
@property
def provider_name(self) -> str:
@@ -189,28 +197,11 @@ class LocalSTEmbeddings(Embeddings):
logger.info(f"Embeddings: initializing local provider with model {self.model_name}")
# Determine device based on hardware availability.
# We always set low_cpu_mem_usage=False to prevent lazy loading (meta tensors)
# which can cause issues when accelerate is installed but no GPU is available.
import torch
# Force CPU mode if configured (used in daemon mode to avoid MPS/XPC issues on macOS)
if self.force_cpu:
device = "cpu"
logger.info("Embeddings: forcing CPU mode")
else:
# Check for GPU (CUDA) or Apple Silicon (MPS)
# Wrap in try-except to gracefully handle any device detection issues
# (e.g., in CI environments or when PyTorch is built without GPU support)
device = "cpu" # Default to CPU
try:
has_gpu = torch.cuda.is_available() or (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
)
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
# Determine device based on hardware availability. We always set
# low_cpu_mem_usage=False to prevent lazy loading (meta tensors) which can
# cause issues when accelerate is installed but no GPU is available.
# MPS is opt-in (allow_mps) — see engine/local_device.py for why.
device = select_local_device(self.force_cpu, self.allow_mps)
# Suppress verbose transformers warnings during model loading
# This suppresses the "UNEXPECTED" warnings from BertModel which are harmless
@@ -237,7 +228,8 @@ class LocalSTEmbeddings(Embeddings):
transformers_logger.setLevel(original_level)
self._dimension = self._model.get_sentence_embedding_dimension()
logger.info(f"Embeddings: local provider initialized (dim: {self._dimension})")
self._device_type = resolve_model_device_type(self._model)
logger.info(f"Embeddings: local provider initialized (dim: {self._dimension}, device: {self._device_type})")
def encode(self, texts: list[str]) -> list[list[float]]:
"""
@@ -249,11 +241,49 @@ class LocalSTEmbeddings(Embeddings):
Returns:
List of embedding vectors
"""
return self._encode_local(texts)
def encode_query(self, texts: list[str]) -> list[list[float]]:
return self._encode_local(texts, input_type="query")
def encode_documents(self, texts: list[str]) -> list[list[float]]:
return self._encode_local(texts, input_type="document")
def _encode_local(
self, texts: list[str], input_type: Literal["query", "document"] | None = None
) -> list[list[float]]:
if self._model is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
embeddings = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
return [emb.tolist() for emb in embeddings]
try:
# Delegate to SentenceTransformers' own asymmetric entry points rather than
# prefixing here: they apply whatever prompts the model ships with (and route
# the task for models exposing a Router module), so asymmetric models such as
# Qwen3-Embedding get their configured query prompt without Hindsight carrying
# per-model prefix config the way the ONNX provider has to. Models that declare
# no prompts are unaffected — SentenceTransformers defaults them to empty
# strings and skips prompt handling entirely, so this is byte-identical to
# encode() for e.g. the default BAAI/bge-small-en-v1.5.
# encode_query/encode_document exist only in sentence-transformers >= 5.0,
# which is why local-ml pins that floor.
if input_type == "query":
encode = self._model.encode_query
elif input_type == "document":
encode = self._model.encode_document
else:
encode = self._model.encode
embeddings = encode(texts, convert_to_numpy=True, show_progress_bar=False)
return [emb.tolist() for emb in embeddings]
finally:
# Only reclaim the GPU allocator pool here, and only when actually on a
# GPU (opt-in MPS/CUDA/XPU). encode() runs in tight retain loops, so a
# gc.collect()/malloc_trim on every call is too costly on the CPU default
# — and unnecessary: refcounting frees the small transient buffers
# immediately and the allocator reuses them for the next batch. (The
# reranker keeps its per-batch heap trim for the #1717 CPU case; it runs
# on the lighter recall path.) See engine/local_device.py.
if self._device_type != "cpu":
release_local_inference_memory(self._device_type)
class OnnxEmbeddings(Embeddings):
@@ -484,7 +514,7 @@ class RemoteTEIEmbeddings(Embeddings):
response = self._client.post(url, **kwargs)
response.raise_for_status()
return response
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as e:
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.WriteTimeout) as e:
last_error = e
if attempt < self.max_retries:
logger.warning(
@@ -493,13 +523,20 @@ class RemoteTEIEmbeddings(Embeddings):
time.sleep(delay)
delay *= 2 # Exponential backoff
except httpx.HTTPStatusError as e:
# Retry on 5xx server errors
if e.response.status_code >= 500 and attempt < self.max_retries:
# TEI uses 429 as normal overload backpressure. Retry it with
# the same bounded budget as transient server errors.
if (e.response.status_code == 429 or e.response.status_code >= 500) and attempt < self.max_retries:
last_error = e
logger.warning(
f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s..."
sleep_delay = tei_retry_delay(
e.response,
delay,
request_timeout=self.timeout,
)
time.sleep(delay)
logger.warning(
f"TEI transient error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. "
f"Retrying in {sleep_delay:.2f}s..."
)
time.sleep(sleep_delay)
delay *= 2
else:
raise
@@ -705,6 +742,7 @@ class OpenAIEmbeddings(Embeddings):
}
if self.dimensions is not None:
request["dimensions"] = self.dimensions
apply_bank_attribution(request)
response = self._client.embeddings.create(**request)
@@ -717,7 +755,8 @@ class OpenAIEmbeddings(Embeddings):
class CodexOAuthEmbeddings(OpenAIEmbeddings):
"""
OpenAI embeddings using the Codex/ChatGPT OAuth token from ``~/.codex/auth.json``.
OpenAI embeddings using the Codex/ChatGPT OAuth token from the Codex
``auth.json`` (``$CODEX_HOME/auth.json``, or ``~/.codex/auth.json`` when unset).
Codex OAuth is an LLM-provider auth path in Hindsight, but the same bearer token
can also authenticate against the standard OpenAI embeddings endpoint. This keeps
@@ -1347,6 +1386,21 @@ class LiteLLMSDKEmbeddings(Embeddings):
return all_embeddings
# Gemini Embedding 2+ multimodal models return a SINGLE aggregated embedding
# for a multi-input request instead of one vector per input (see
# https://ai.google.dev/gemini-api/docs/embeddings#embedding-aggregation). For
# these models we must embed one input per call to preserve the 1:1 input→vector
# alignment the rest of the pipeline relies on. The marker matches preview and GA
# names (e.g. "gemini-embedding-2-preview", "gemini-embedding-2"), with or
# without a "google/" or "models/" prefix.
_GEMINI_AGGREGATING_MODEL_MARKER = "gemini-embedding-2"
def _gemini_model_aggregates_inputs(model: str) -> bool:
"""Whether the model aggregates a multi-input request into one embedding."""
return _GEMINI_AGGREGATING_MODEL_MARKER in model.lower()
class GeminiEmbeddings(Embeddings):
"""
Google embeddings via the google.genai SDK.
@@ -1356,6 +1410,10 @@ class GeminiEmbeddings(Embeddings):
2. Vertex AI with service account or Application Default Credentials (ADC)
Uses the embed_content API: client.models.embed_content(model, contents)
Gemini Embedding 2+ multimodal models aggregate a multi-input request into a
single embedding, so for those the batch size is forced to 1 (one input per
call) to keep one vector per input.
"""
def __init__(
@@ -1510,9 +1568,13 @@ class GeminiEmbeddings(Embeddings):
all_embeddings = []
# Gemini Embedding 2+ multimodal models return one aggregated vector for a
# multi-input request, so embed one input per call to keep 1:1 alignment.
batch_size = 1 if _gemini_model_aggregates_inputs(self.model) else self.batch_size
# Process in batches
for i in range(0, len(texts), self.batch_size):
batch = texts[i : i + self.batch_size]
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
embed_kwargs = {"model": self.model, "contents": batch}
if self._embed_config is not None:
@@ -1520,7 +1582,13 @@ class GeminiEmbeddings(Embeddings):
result = self._client.models.embed_content(**embed_kwargs)
all_embeddings.extend([emb.values for emb in result.embeddings])
embeddings = result.embeddings or []
if len(embeddings) != len(batch):
raise RuntimeError(
f"Gemini embeddings backend returned {len(embeddings)} vectors for "
f"{len(batch)} input texts (model {self.model}); expected exact 1:1 alignment"
)
all_embeddings.extend([emb.values for emb in embeddings])
# L2-normalize when output_dimensionality is set — Gemini only returns
# normalized vectors at full 3072 dims; truncated dims need re-normalization
@@ -1560,6 +1628,7 @@ def create_embeddings_from_env() -> Embeddings:
model_name=config.embeddings_local_model,
force_cpu=config.embeddings_local_force_cpu,
trust_remote_code=config.embeddings_local_trust_remote_code,
allow_mps=config.embeddings_local_allow_mps,
)
elif provider == "onnx":
return OnnxEmbeddings(
@@ -1613,6 +1682,20 @@ def create_embeddings_from_env() -> Embeddings:
batch_size=config.embeddings_openai_batch_size,
dimensions=config.embeddings_openai_dimensions,
)
elif provider == "requesty":
api_key = config.embeddings_requesty_api_key
if not api_key:
raise ValueError(
"HINDSIGHT_API_EMBEDDINGS_REQUESTY_API_KEY, HINDSIGHT_API_REQUESTY_API_KEY, "
f"or {ENV_LLM_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'requesty'"
)
return OpenAIEmbeddings(
api_key=api_key,
model=config.embeddings_requesty_model,
base_url="https://router.requesty.ai/v1",
batch_size=config.embeddings_openai_batch_size,
dimensions=config.embeddings_openai_dimensions,
)
elif provider == "zeroentropy":
api_key = config.embeddings_zeroentropy_api_key
if not api_key:
@@ -1676,6 +1759,6 @@ def create_embeddings_from_env() -> Embeddings:
else:
raise ValueError(
f"Unknown embeddings provider: {provider}. "
f"Supported: 'local', 'onnx', 'tei', 'openai', 'openai-codex', 'openrouter', 'cohere', 'google', "
f"Supported: 'local', 'onnx', 'tei', 'openai', 'openai-codex', 'openrouter', 'requesty', 'cohere', 'google', "
f"'zeroentropy', 'litellm', 'litellm-sdk'"
)
File diff suppressed because it is too large Load Diff
@@ -5,48 +5,54 @@ Three reconciliation passes run together on every worker invocation:
1. **Relink top-up.** Drain ``graph_maintenance_queue`` (units whose
outgoing temporal/semantic links lost a neighbour to a delete). For
each, count current outgoing links per type; if below cap, run the
same probes retain uses (:func:`fetch_temporal_neighbors`,
:func:`compute_semantic_links_ann`) and insert the missing links.
``bulk_insert_links`` has ``ON CONFLICT DO NOTHING`` on the uniqueness
key, so we can re-probe freely and the DB de-dupes.
same probes retain uses and insert the missing links.
2. **Orphan entity prune.** Delete ``entities`` rows in the bank that no
longer have any ``unit_entities`` references. FK ON DELETE CASCADE on
longer have any live memory references. FK ON DELETE CASCADE on
``entity_cooccurrences`` then removes any cooccurrence row pointing
at the pruned entities.
3. **Stale cooccurrence prune.** Defensive sweep for cooccurrence rows
where both endpoints still exist but no current memory_unit references
where both endpoints still exist but no current memory references
both of them the cooccurrence was real at the time it was recorded,
but every unit that witnessed it has since been deleted.
All three passes run on every invocation. The queue is the only source
of work for pass 1; passes 2 and 3 are bank-wide sweeps backed by indexes
on ``entities(bank_id)`` and ``unit_entities(entity_id)``, so they're
cheap when there's nothing to do.
Each pass is work the *memories store* owns, because each is a query over
`memory_links`, `unit_entities` and `entities` the slice the store carves
out. This module orchestrates them (drain the queue, wrap the sweep in a
deadlock-retry) and asks the store to do the part that touches storage. A store
whose links travel inside its memories has no `memory_links` to dangle and no
join table to sweep, so its relink and cooccurrence passes are no-ops and the
job simply prunes the orphan `entities` rows, which stay in Postgres regardless.
The worker dedupes on bank: a second job for the same bank is dropped
while one is pending. Once processing starts, a new job becomes the
*next* pending slot so work enqueued during processing gets picked up
by the follow-up run.
That follow-up run is *deferred*, not parallel: ``claim_tasks`` will not claim a
graph_maintenance row for a bank that already has one in flight (#3230). Two
concurrent runs would do no extra work anyway each is this same bank-wide
sweep while convoying on each other's row locks and holding a worker slot
each.
"""
from __future__ import annotations
import logging
import time
import uuid as uuid_module
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from ..models import RequestContext
from .db.base import DatabaseConnection
from .retain.link_utils import (
MAX_TEMPORAL_LINKS_PER_UNIT,
_bulk_insert_links,
_normalize_datetime,
compute_semantic_links_ann,
)
# Re-exported for callers and tests that import the link caps from here; the caps
# themselves live with the link builders the relink pass mirrors — the temporal one
# with the retain-time builders, the semantic one with the store's relink pass — so
# there is a single definition of each and the two cannot drift.
from .memories.pg.graph import MAX_SEMANTIC_LINKS_PER_UNIT # noqa: F401
from .retain.link_utils import MAX_TEMPORAL_LINKS_PER_UNIT # noqa: F401
from .schema import fq_table
if TYPE_CHECKING:
@@ -54,17 +60,19 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Mirrors the ``top_k`` default in ``compute_semantic_links_ann`` at retain
# time. If you change one, change the other — otherwise victims would either
# never reach the cap (probe returns less than the cap) or stay perpetually
# under it (cap is higher than retain creates).
MAX_SEMANTIC_LINKS_PER_UNIT = 50
# Retry budget for the idempotent Pass 2/3 entity/cooccurrence sweep. Higher
# than db_utils' default (3) because the sweep has no client waiting on it and
# is safe to rerun, so we'd rather spend a longer jittered-backoff tail than
# drop a maintenance pass and leak stale graph rows (see run_graph_maintenance_job).
_SWEEP_MAX_RETRIES = 8
# Worker fetches this many rows per relink-loop iteration. Bounds
# per-iteration probe/insert latency so a 10k-row backlog doesn't hold a
# worker slot for minutes. Chosen so the typical iteration runs in well
# under 1s.
_DRAIN_BATCH_SIZE = 50
@dataclass
class _SweepCounts:
"""Prune counts returned by the Pass 2/3 sweep (avoids a bare tuple return)."""
orphan_entities_pruned: int
stale_cooccurrences_pruned: int
@dataclass
@@ -88,67 +96,52 @@ class JobResult:
async def enqueue_relink_victims(
conn: DatabaseConnection,
bank_id: str,
deleted_unit_ids: list[str],
ops: Any,
affected_unit_ids: list[str],
include_affected_units: bool = False,
) -> int:
"""Enqueue surviving units whose outgoing temporal/semantic links pointed at
``deleted_unit_ids`` for later link top-up.
``affected_unit_ids`` for later link top-up.
Must run inside the same transaction that deletes the units, *before* the
cascade fires once the rows are gone, the join that finds the victims
returns nothing.
Must run inside the same transaction that drops those links, *before* the
delete (or cascade) fires once the rows are gone, the join that finds the
victims returns nothing.
``include_affected_units`` covers the case where the affected units are NOT
being removed: an edit deletes every link incident to the edited unit but
leaves it live, so the unit needs its own outgoing adjacency rebuilt too.
Passing it for a unit that will be gone at commit is harmless but pointless
the drain skips queue rows with no live unit so callers should only set
it when the unit survives the transaction.
Delegated to the memories store: finding the victims is a `memory_links`
query, and a store whose links are inline has none, so it returns 0 and the
relink pass has nothing to do. The store resolves the dialect it needs from
``conn``.
Args:
conn: Database connection inside the active delete transaction.
bank_id: Bank owning the deleted units.
deleted_unit_ids: Memory_unit IDs about to be (or being) deleted.
ops: ``DataAccessOps`` instance, supplies the dialect-specific
bulk-insert path.
conn: Database connection inside the active transaction.
bank_id: Bank owning the affected units.
affected_unit_ids: Memory_unit IDs whose incident temporal/semantic
links are about to be (or are being) removed.
include_affected_units: Also enqueue ``affected_unit_ids`` themselves,
for callers that leave them live.
Returns:
Number of distinct victim units enqueued (after dedup against rows
already in the queue).
Number of distinct victim units enqueued (0 for a store with no links).
"""
if not deleted_unit_ids:
if not affected_unit_ids:
return 0
deleted_uuids = [uuid_module.UUID(uid) if isinstance(uid, str) else uid for uid in deleted_unit_ids]
deleted_str_set = {str(uid) for uid in deleted_uuids}
from .memories import get_memories
# Find units (other than the ones being deleted) that have an outgoing
# temporal/semantic link pointing at a doomed unit. Entity links are
# intentionally excluded — they're scheduled for removal and would only
# add noise to the recompute job.
victim_rows = await conn.fetch(
f"""
SELECT DISTINCT from_unit_id
FROM {fq_table("memory_links")}
WHERE to_unit_id = ANY($1::uuid[])
AND bank_id = $2
AND link_type IN ('temporal', 'semantic')
""",
deleted_uuids,
bank_id,
return await get_memories().enqueue_relink_victims(
conn=conn,
fq_table=fq_table,
bank_id=bank_id,
affected_unit_ids=affected_unit_ids,
include_affected_units=include_affected_units,
)
victim_ids = [row["from_unit_id"] for row in victim_rows if str(row["from_unit_id"]) not in deleted_str_set]
if not victim_ids:
return 0
await ops.enqueue_graph_maintenance(
conn,
fq_table("graph_maintenance_queue"),
bank_id,
victim_ids,
)
logger.debug(
f"[GRAPH_MAINT] Enqueued {len(victim_ids)} relink victims in "
f"bank={bank_id} (deleted {len(deleted_unit_ids)} units)"
)
return len(victim_ids)
async def run_graph_maintenance_job(
memory_engine: "MemoryEngine",
@@ -163,195 +156,69 @@ async def run_graph_maintenance_job(
Per-pass counters from :class:`JobResult`.
"""
del request_context # accepted for symmetry with other run_*_job helpers
from ..config import get_config
from .memories import get_memories
backend = await memory_engine._get_backend()
ops = backend.ops
store = get_memories()
config = get_config()
result = JobResult()
job_start = time.time()
# --- Pass 1: relink ---
# Per-iteration loop: claim → top up → commit. We rely on submit-time
# dedup to keep at most one job per bank running, so no need for
# SKIP LOCKED.
iterations = 0
while True:
from .memory_engine import acquire_with_retry
async with acquire_with_retry(backend) as conn:
async with conn.transaction():
unit_ids = await ops.claim_graph_maintenance_batch(
conn,
fq_table("graph_maintenance_queue"),
bank_id,
_DRAIN_BATCH_SIZE,
)
if not unit_ids:
break
result.relink_links_added += await _relink_batch(conn, bank_id, unit_ids, ops, backend)
result.relink_units_processed += len(unit_ids)
iterations += 1
if iterations > 10000:
# Defensive guard against runaway loops — at 50 units/iter that's
# 500k targets, far beyond any realistic single-bank backlog.
logger.error(
f"[GRAPH_MAINT] bank={bank_id} hit iteration cap ({iterations}); aborting relink ({result.as_dict()})"
)
break
# The store owns the whole drain loop: it is a claim → top-up → commit over
# its own link table, so how it batches and re-probes is its business — including
# the #3034 serialisation (the claim takes queue rows FOR UPDATE in (bank_id,
# unit_id) order against a concurrent re-enqueue), which lives in the store's
# claim (`ops.claim_graph_maintenance_batch`). A store with no links returns an
# empty dict and this is a no-op.
relink = await store.relink_pass(backend=backend, fq_table=fq_table, bank_id=bank_id, config=config)
result.relink_units_processed = relink.get("relink_units_processed", 0)
result.relink_links_added = relink.get("relink_links_added", 0)
# --- Pass 2 & 3: entity / cooccurrence sweeps ---
# Bank-wide single-statement deletes. Cheap when there's nothing to do.
#
# Unlike Pass 1's queue claim, these DELETEs aren't protected by any
# consistent lock-ordering guarantee: the stale-cooccurrence prune scans
# entity_cooccurrences via a join/NOT EXISTS plan, while retain's concurrent
# cooccurrence upserts (entity_resolver._flush_pending) lock the same rows in
# sorted (entity_id_1, entity_id_2) order. When a sweep and a concurrent
# upsert touch overlapping rows in opposite orders, Postgres detects a
# genuine circular wait and aborts one side with DeadlockDetectedError. Both
# prunes are idempotent bank-wide sweeps — rerunning only deletes what's
# still stale — so retrying the whole transaction on deadlock is safe.
#
# The prunes themselves are the store's: the orphan-`entities` sweep applies
# to every store (that registry stays in Postgres), while the cooccurrence
# sweep is a no-op for a store that never wrote `unit_entities`.
from .db_utils import retry_with_backoff
from .memory_engine import acquire_with_retry
async with acquire_with_retry(backend) as conn:
async with conn.transaction():
result.orphan_entities_pruned = await ops.prune_orphan_entities(
conn,
fq_table("entities"),
fq_table("unit_entities"),
bank_id,
)
# The orphan prune above cascades cooccurrences via FK. The
# explicit cooccurrence pass below catches the *stale-count*
# case: both entities still exist but no current unit witnesses
# them together.
result.stale_cooccurrences_pruned = await ops.prune_stale_cooccurrences(
conn,
fq_table("entity_cooccurrences"),
fq_table("unit_entities"),
fq_table("entities"),
bank_id,
)
async def _run_sweep() -> _SweepCounts:
async with acquire_with_retry(backend) as conn:
async with conn.transaction():
orphan_pruned = await store.prune_orphan_entities(conn=conn, fq_table=fq_table, bank_id=bank_id)
# The orphan prune above cascades cooccurrences via FK. The
# explicit cooccurrence pass below catches the *stale-count*
# case: both entities still exist but no current unit witnesses
# them together.
stale_pruned = await store.prune_stale_cooccurrences(conn=conn, fq_table=fq_table, bank_id=bank_id)
return _SweepCounts(orphan_entities_pruned=orphan_pruned, stale_cooccurrences_pruned=stale_pruned)
# A larger retry budget than the default (3): this is idempotent background
# maintenance with no client waiting on it, so a longer retry tail costs
# nothing, whereas a dropped sweep silently leaks orphan entities / stale
# cooccurrences until the next run. With jittered backoff a single sweep
# contending against continuous retain upserts effectively never exhausts
# this budget (each retry independently clears with high probability).
sweep = await retry_with_backoff(_run_sweep, max_retries=_SWEEP_MAX_RETRIES)
result.orphan_entities_pruned = sweep.orphan_entities_pruned
result.stale_cooccurrences_pruned = sweep.stale_cooccurrences_pruned
elapsed = time.time() - job_start
logger.info(
f"[GRAPH_MAINT] bank={bank_id} done: {result.as_dict()}, elapsed={elapsed:.2f}s, operation_id={operation_id}"
)
return result.as_dict()
async def _relink_batch(
conn: DatabaseConnection,
bank_id: str,
victim_ids: list[str],
ops: Any,
backend: Any,
) -> int:
"""Top up temporal/semantic links for a batch of victim units. Returns rows inserted."""
# Load each victim's metadata. Victims whose units were deleted between
# enqueue and now silently drop out — exactly the no-op behaviour we want
# for stale queue rows.
victim_uuids = [uuid_module.UUID(vid) for vid in victim_ids]
victim_rows = await conn.fetch(
f"""
SELECT id::text AS id, event_date, fact_type, embedding::text AS embedding
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
AND bank_id = $2
AND fact_type IN ('experience', 'world')
""",
victim_uuids,
bank_id,
)
if not victim_rows:
return 0
alive_uuids = [uuid_module.UUID(row["id"]) for row in victim_rows]
# Count current outgoing temporal/semantic links per victim so we only
# probe for the ones genuinely below cap. Saves the bulk of the work when
# most victims still have plenty of links.
count_rows = await conn.fetch(
f"""
SELECT from_unit_id, link_type, COUNT(*) AS cnt
FROM {fq_table("memory_links")}
WHERE from_unit_id = ANY($1::uuid[])
AND bank_id = $2
AND link_type IN ('temporal', 'semantic')
GROUP BY from_unit_id, link_type
""",
alive_uuids,
bank_id,
)
counts: dict[tuple[str, str], int] = {}
for row in count_rows:
counts[(str(row["from_unit_id"]), row["link_type"])] = int(row["cnt"])
# --- Temporal top-up ---
temporal_needs = [r for r in victim_rows if counts.get((r["id"], "temporal"), 0) < MAX_TEMPORAL_LINKS_PER_UNIT]
new_links: list[tuple] = []
if temporal_needs:
lateral_unit_ids = [uuid_module.UUID(r["id"]) for r in temporal_needs if r["event_date"] is not None]
lateral_event_dates = [
_normalize_datetime(r["event_date"]) for r in temporal_needs if r["event_date"] is not None
]
lateral_fact_types = [r["fact_type"] for r in temporal_needs if r["event_date"] is not None]
if lateral_unit_ids:
rows = await ops.fetch_temporal_neighbors(
conn,
fq_table("memory_units"),
bank_id,
lateral_unit_ids,
lateral_event_dates,
lateral_fact_types,
MAX_TEMPORAL_LINKS_PER_UNIT,
)
for row in rows:
time_diff_h = float(row["time_diff_hours"])
# Mirror the 24h window enforced at retain time. The bidirectional
# index scan returns the K closest neighbours regardless of
# window, so we filter here.
if time_diff_h > 24:
continue
weight = max(0.3, 1.0 - (time_diff_h / 24))
new_links.append((row["from_id"], str(row["id"]), "temporal", weight, None))
# --- Semantic top-up ---
# ANN must run on its own connection: it opens a nested transaction with
# SET LOCAL hnsw.ef_search + CREATE TEMP TABLE ON COMMIT DROP, and nesting
# that inside our current write transaction would commit our writes early.
semantic_needs = [
r
for r in victim_rows
if counts.get((r["id"], "semantic"), 0) < MAX_SEMANTIC_LINKS_PER_UNIT and r["embedding"] is not None
]
if semantic_needs:
from .memory_engine import acquire_with_retry
seed_ids = [r["id"] for r in semantic_needs]
seed_embs = [r["embedding"] for r in semantic_needs]
seed_ftypes = [r["fact_type"] for r in semantic_needs]
async with acquire_with_retry(backend) as ann_conn:
try:
ann_links = await compute_semantic_links_ann(
ann_conn,
bank_id,
seed_ids,
seed_embs,
fact_types=seed_ftypes,
)
# Strip self-links (rare but possible because the ANN probe
# has no exclude list — see the comment in compute_semantic_links_ann).
ann_links = [lnk for lnk in ann_links if lnk[0] != lnk[1]]
new_links.extend(ann_links)
except Exception as e:
# ANN uses PG-specific HNSW syntax; on dialects/configs where
# it isn't available we still want the temporal top-up to land.
logger.warning(f"[GRAPH_MAINT] Semantic top-up failed for bank={bank_id}: {type(e).__name__}: {e}")
if not new_links:
return 0
await _bulk_insert_links(
conn,
new_links,
bank_id=bank_id,
skip_exists_check=False,
ops=ops,
)
return len(new_links)
@@ -6,16 +6,34 @@ authentication when a TenantExtension is configured.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.memory_engine import BankLlmHealthInfo, Budget
from hindsight_api.engine.response_models import RecallResult, ReflectResult
from hindsight_api.engine.search.tags import TagsMatch
from hindsight_api.extensions import BankWriteOperation
from hindsight_api.models import RequestContext
@dataclass(frozen=True)
class BankConfigState:
"""Resolved bank configuration and its bank-level overrides."""
config: dict[str, Any]
overrides: dict[str, Any]
@dataclass(frozen=True)
class BankTemplateImportWrite:
"""One bank-write decision reserved for a specific imported resource."""
operation: "BankWriteOperation"
target: str | None = None
class MemoryEngineInterface(ABC):
"""
Abstract interface for the Memory Engine.
@@ -180,6 +198,37 @@ class MemoryEngineInterface(ABC):
"""
...
@abstractmethod
async def get_bank_config(
self,
bank_id: str,
*,
request_context: "RequestContext",
) -> BankConfigState:
"""Return resolved configuration after authenticating and authorizing the read."""
...
@abstractmethod
async def update_bank_config(
self,
bank_id: str,
updates: dict[str, Any],
*,
request_context: "RequestContext",
) -> BankConfigState:
"""Create a bank if needed and persist validated configuration overrides."""
...
@abstractmethod
async def reset_bank_config(
self,
bank_id: str,
*,
request_context: "RequestContext",
) -> BankConfigState:
"""Remove all bank configuration overrides after authorization."""
...
@abstractmethod
async def update_bank_disposition(
self,
@@ -275,6 +324,8 @@ class MemoryEngineInterface(ABC):
*,
fact_type: str | None = None,
search_query: str | None = None,
entity_id: str | None = None,
created_before: datetime | None = None,
limit: int = 100,
offset: int = 0,
request_context: "RequestContext",
@@ -286,6 +337,8 @@ class MemoryEngineInterface(ABC):
bank_id: The memory bank ID.
fact_type: Filter by fact type.
search_query: Full-text search query.
entity_id: Filter to memory units linked to this entity ID.
created_before: Keep units with ``created_at`` before this instant.
limit: Maximum results.
offset: Pagination offset.
request_context: Request context for authentication.
@@ -449,6 +502,7 @@ class MemoryEngineInterface(ABC):
bank_id: str,
*,
request_context: "RequestContext",
force_refresh: bool = False,
) -> dict[str, Any]:
"""
Get statistics about memory nodes and links for a bank.
@@ -456,6 +510,8 @@ class MemoryEngineInterface(ABC):
Args:
bank_id: The memory bank ID.
request_context: Request context for authentication.
force_refresh: Bypass the cached value and recompute (also refreshes
the cache for subsequent callers).
Returns:
Dict with node_counts, link_counts, link_counts_by_fact_type
@@ -475,11 +531,29 @@ class MemoryEngineInterface(ABC):
Get consolidation freshness for a bank.
Cheap alternative to get_bank_stats when callers only need
last_consolidated_at / pending_consolidation / failed_consolidation.
last_consolidated_at / last_memory_write_at / pending_consolidation /
failed_consolidation.
Returns:
Dict with last_consolidated_at (ISO-8601 string or None),
pending_consolidation (int), and failed_consolidation (int).
Dict with last_consolidated_at and last_memory_write_at (ISO-8601
strings or None), pending_consolidation (int), and
failed_consolidation (int). last_memory_write_at is the newest write
across the bank's memories — a mental model refreshed at or after it
cannot be stale, whatever its scope.
"""
...
@abstractmethod
async def check_bank_llm(
self,
bank_id: str,
*,
request_context: "RequestContext",
) -> "BankLlmHealthInfo":
"""
Probe the LLM consolidation would use for this bank. Deliberate connectivity
test (one real minimal call); never returns the API key. See
MemoryEngine.check_bank_llm.
"""
...
@@ -548,6 +622,30 @@ class MemoryEngineInterface(ABC):
"""
...
@abstractmethod
async def delete_operation(
self,
bank_id: str,
operation_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Delete a terminal async operation record.
Args:
bank_id: The memory bank ID.
operation_id: The operation ID to delete.
request_context: Request context for authentication.
Returns:
Dict with success status and message.
Raises:
ValueError: If operation not found.
"""
...
@abstractmethod
async def update_bank(
self,
@@ -555,6 +653,8 @@ class MemoryEngineInterface(ABC):
*,
name: str | None = None,
mission: str | None = None,
config_updates: dict[str, Any] | None = None,
create_if_missing: bool = True,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
@@ -564,6 +664,9 @@ class MemoryEngineInterface(ABC):
bank_id: The memory bank ID.
name: New bank name (optional).
mission: New mission text (optional, replaces existing).
config_updates: Bank configuration overrides to apply with the profile update.
create_if_missing: Create a missing bank when True; otherwise raise
a 404 operation error.
request_context: Request context for authentication.
Returns:
@@ -6,9 +6,52 @@ enabling support for multiple LLM backends (OpenAI, Anthropic, Gemini, Codex, et
"""
from abc import ABC, abstractmethod
from typing import Any
from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from typing import Any, Callable, Self
from .response_models import LLMToolCallResult, TokenUsage
from .response_models import LLMToolCallResult
class LLMToolChoiceMode(StrEnum):
"""Canonical tool-selection modes shared by every LLM provider."""
AUTO = "auto"
NONE = "none"
REQUIRED = "required"
NAMED = "named"
@dataclass(frozen=True, slots=True)
class LLMToolChoice:
"""Typed internal tool selection serialized only at provider boundaries."""
mode: LLMToolChoiceMode
function_name: str | None = None
def __post_init__(self) -> None:
if self.mode is LLMToolChoiceMode.NAMED:
if self.function_name is None or not self.function_name or self.function_name != self.function_name.strip():
raise ValueError("Named tool choice requires a non-empty canonical function name")
elif self.function_name is not None:
raise ValueError(f"Tool choice mode {self.mode.value!r} cannot include a function name")
@classmethod
def named(cls, function_name: str) -> Self:
return cls(mode=LLMToolChoiceMode.NAMED, function_name=function_name)
@property
def selected_function_name(self) -> str:
if self.function_name is None:
raise ValueError("Tool choice does not select a named function")
return self.function_name
LLM_TOOL_CHOICE_AUTO = LLMToolChoice(mode=LLMToolChoiceMode.AUTO)
LLM_TOOL_CHOICE_NONE = LLMToolChoice(mode=LLMToolChoiceMode.NONE)
LLM_TOOL_CHOICE_REQUIRED = LLMToolChoice(mode=LLMToolChoiceMode.REQUIRED)
class LLMInterface(ABC):
@@ -70,6 +113,7 @@ class LLMInterface(ABC):
strict_schema: bool = False,
return_usage: bool = False,
cached_prefix: str | None = None,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> Any:
"""
Make an LLM API call with retry logic.
@@ -91,6 +135,11 @@ class LLMInterface(ABC):
cached_prefix: Opaque handle from ``get_or_create_cached_prefix`` for the
cacheable system prefix, or None. Providers without explicit prompt
caching ignore it (and the wrapper only forwards it when set).
attempt_context: Factory for an async context manager holding the shared
concurrency permits. Passed only when the provider declares
``supports_attempt_scoped_concurrency()``; the provider must enter it
around each individual upstream request so retry backoff never
occupies a permit.
Returns:
If return_usage=False: Parsed response if response_format is provided, otherwise text content.
@@ -113,8 +162,10 @@ class LLMInterface(ABC):
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
cached_prefix: str | None = None,
cached_prefix_message_count: int = 0,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support.
@@ -128,7 +179,9 @@ class LLMInterface(ABC):
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or specific function.
tool_choice: Canonical tool-selection policy.
attempt_context: Factory for an async context manager holding the shared
concurrency permits see ``call``.
Returns:
LLMToolCallResult with content and/or tool_calls.
@@ -144,6 +197,10 @@ class LLMInterface(ABC):
"""
return False
def supports_attempt_scoped_concurrency(self) -> bool:
"""Whether retries can acquire concurrency permits per upstream attempt."""
return False
# ── Prompt prefix caching (optional, per-provider) ─────────────────────────
def supports_prompt_caching(self) -> bool:
@@ -184,6 +241,45 @@ class LLMInterface(ABC):
"""
return None
# ── Step-by-step incremental prompt caching (optional) ─────────────────────
#
# For agentic loops (reflect) the dominant cost is the conversation prefix
# re-sent every turn, not the static system prefix. Providers that can cache
# a *growing* prefix implement these: the caller rolls one cache per step
# (each covering the previous step's full input), passes its handle plus the
# message count it covers to ``call_with_tools`` so only the new turns are
# sent fresh, and tears the caches down when the loop ends. Default no-ops so
# non-supporting providers transparently run uncached.
def supports_incremental_prompt_cache(self) -> bool:
"""Whether this provider can cache a growing multi-turn conversation prefix."""
return False
async def create_incremental_cache(
self,
*,
session_id: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
) -> str | None:
"""Cache ``system + tools + messages`` and return an opaque handle, or None.
The handle is passed back to ``call_with_tools(cached_prefix=...,
cached_prefix_message_count=len(messages))``. Caches are grouped under
``session_id`` for teardown via ``delete_cache_session``. Returns None
when caching is unavailable or the prefix is too small caller falls
back to an uncached call.
"""
return None
async def delete_cached_prefix(self, name: str) -> None:
"""Best-effort delete of a single cache handle (a superseded step)."""
return None
async def delete_cache_session(self, session_id: str) -> None:
"""Best-effort teardown of every cache created under ``session_id``."""
return None
async def submit_batch(
self,
requests: list[dict[str, Any]],
@@ -252,3 +348,11 @@ class OutputTooLongError(Exception):
"""
pass
class ProviderRateLimitResetError(Exception):
"""Raised when an upstream provider says quota will reopen at a known time."""
def __init__(self, retry_at: datetime, message: str = "") -> None:
self.retry_at = retry_at
super().__init__(message)
@@ -36,6 +36,21 @@ from .db_utils import acquire_with_retry
logger = logging.getLogger(__name__)
def _llm_requests_persistable() -> bool:
"""Whether the ``llm_requests`` table exists on the active backend.
``llm_requests`` is PostgreSQL-only: its migration is ``run_for_dialect(pg=...)``
with the Oracle slot intentionally absent, and MaintenanceLoop skips its
retention sweep on Oracle for the same reason. On Oracle the table does not
exist, so best-effort trace writes must be skipped rather than attempted
otherwise every LLM call fires an INSERT that fails with ORA-00903 and spams
the error log. Mirrors the ``_is_oracle()`` gate in MaintenanceLoop.start.
"""
from .schema import _is_oracle
return not _is_oracle()
# ── bank/operation attribution (carried across the async call chain) ──────────
@@ -76,6 +91,51 @@ _request_ctx: ContextVar[dict[str, Any] | None] = ContextVar("hindsight_llm_requ
_call_metadata_ctx: ContextVar[dict[str, Any] | None] = ContextVar("hindsight_llm_call_metadata_ctx", default=None)
@dataclass
class LLMResponseUsage:
"""Provider-reported token usage for the in-flight LLM call.
Stashed by provider implementations as soon as a response is received
*before* local JSON parsing / schema validation, which may still fail. The
wrapper reads it to attach real token counts to an error trace when the
provider call itself succeeded but the structured output couldn't be parsed
or validated (providers charge for those tokens regardless). See #2387.
"""
input_tokens: int = 0
output_tokens: int = 0
cached_tokens: int = 0
# Per-call provider usage, set by providers right after a response is received.
_response_usage_ctx: ContextVar[LLMResponseUsage | None] = ContextVar("hindsight_llm_response_usage_ctx", default=None)
def set_response_usage(usage: LLMResponseUsage | None) -> Token:
"""Bind provider-reported usage for the current call. Returns a reset token."""
return _response_usage_ctx.set(usage)
def stash_response_usage(usage: LLMResponseUsage | None) -> None:
"""Record provider-reported usage so an error trace can attach it later.
Called by provider implementations once a response (with usage) is in hand,
before parsing/validation that may raise. Overwrites any prior value from an
earlier retry attempt so the last attempt's usage wins.
"""
_response_usage_ctx.set(usage)
def reset_response_usage(token: Token) -> None:
"""Unwind a binding made by :func:`set_response_usage`."""
_response_usage_ctx.reset(token)
def current_response_usage() -> LLMResponseUsage | None:
"""Return the active call's provider-reported usage, or None."""
return _response_usage_ctx.get()
def set_trace_context(ctx: LLMTraceContext | None) -> Token:
"""Bind trace attribution to the current context. Returns a reset token."""
return _trace_ctx.set(ctx)
@@ -331,10 +391,32 @@ class LLMTraceRecorder:
# INSERTs it patches — but it must not block on unrelated operations).
self._pending: dict[str | None, set[asyncio.Task]] = {}
def _writable(self) -> Any | None:
"""Return the pool to write through, or None if writing isn't possible.
Covers the two lifecycle windows in which best-effort trace writes must
be skipped rather than attempted: before the backend pool is created
(``initialize()`` verifies the LLM before the DB is up) and during/after
shutdown. Writes already in flight need no handling the pools close
gracefully, waiting for their connections to be released.
"""
pool = self._pool_getter()
if pool is None:
return None
# Backends declare readiness explicitly; a raw pool (some callers pass
# one directly) has no lifecycle flag and is assumed usable.
from .db.base import DatabaseBackend
if isinstance(pool, DatabaseBackend) and not pool.is_ready:
return None
return pool
def is_enabled(self, scope: str) -> bool:
"""Whether tracing is active for the given call scope."""
if not self._enabled:
return False
if not _llm_requests_persistable():
return False
if self._allowed_scopes is not None:
return scope in self._allowed_scopes
return True
@@ -428,7 +510,7 @@ class LLMTraceRecorder:
async def _safe_write(self, record: LLMRequestRecord) -> None:
"""Write a trace row. Errors are logged, never raised."""
pool = self._pool_getter()
pool = self._writable()
if pool is None:
logger.debug("LLM trace skipped: pool not available")
return
@@ -501,7 +583,7 @@ class LLMTraceRecorder:
ids are snapshotted synchronously here because the caller may reset the
context immediately after.
"""
if not self._enabled or trace_ctx is None or not trace_ctx.trace_id:
if not self._enabled or not _llm_requests_persistable() or trace_ctx is None or not trace_ctx.trace_id:
return
created_ids = list(dict.fromkeys([*(created or []), *trace_ctx.created_memory_ids]))
source_ids = list(dict.fromkeys([*(source or []), *trace_ctx.source_memory_ids]))
@@ -523,8 +605,9 @@ class LLMTraceRecorder:
# so the UPDATE patches rows that already exist rather than racing ahead
# of them (without blocking on unrelated operations' pending writes).
await self._flush_pending(trace_id)
pool = self._pool_getter()
pool = self._writable()
if pool is None:
logger.debug("LLM trace memory_id attach skipped: pool not available")
return
try:
schema = self._schema_getter()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,172 @@
"""Device selection and post-inference memory release for local (in-process)
SentenceTransformer / CrossEncoder models.
Two concerns live here, both about keeping a local API instance's memory flat:
**1. Device selection MPS is opt-in.**
On Apple Silicon the PyTorch **MPS** (Metal) backend caches a distinct compiled
kernel graph *and* allocator pool per unique input tensor shape, and never
releases them. Under the variable-length, high-volume recall/rerank/embed traffic
the engine generates (documents and candidate sets of every size), that per-shape
cache grows without bound. A local instance was observed idling at ~20 GB ~9.4 GB
of Metal graphics memory plus ~8 GB of native heap, essentially all of it stale
per-shape MPS cache. CPU inference has no per-shape cache: the same workload holds
flat at a few hundred MB, with negligible latency cost for the small default
models (and MPS actually *slows down* over time as it recompiles graphs for new
shapes). So MPS is excluded from auto-detection and must be opted into explicitly;
CUDA and Intel XPU still auto-select.
This is a confirmed, still-open PyTorch bug in the MPSGraph compilation cache
(keyed on tensor shape, no eviction path). We are tracking it upstream:
- https://github.com/pytorch/pytorch/issues/181213
([MPS] unbounded RSS growth with varying-shape inference our exact case)
- https://github.com/pytorch/pytorch/issues/164299 (graphCache identified as
the primary leak culprit)
- https://github.com/pytorch/pytorch/issues/182815 (proposes, but has not yet
shipped, a torch.mps.invalidate_graph_cache() API / PYTORCH_MPS_DISABLE_GRAPH_CACHE
env var that would let us keep MPS)
No released mitigation exists today: empty_cache(), synchronize(),
PYTORCH_MPS_HIGH_WATERMARK_RATIO, and autorelease pools were all confirmed
ineffective upstream. Revisit MPS-as-default once one of those knobs lands.
**2. Memory release after each batch.**
Local CPU inference allocates large transient numpy/tensor buffers per call. The
allocator keeps those freed pages as a high-water mark, so RSS grows monotonically
across many calls (issue #1717). We return them to the OS after each batch —
``malloc_trim`` on glibc/Linux, ``malloc_zone_pressure_relief`` on macOS (the
original #1717 fix covered only Linux). When the model ran on a GPU we also empty
that backend's allocator pool via ``torch.<backend>.empty_cache()``.
"""
from __future__ import annotations
import ctypes
import ctypes.util
import gc
import logging
import sys
logger = logging.getLogger(__name__)
def select_local_device(force_cpu: bool, allow_mps: bool) -> str | None:
"""Choose the device for a local SentenceTransformer / CrossEncoder.
Returns a value suitable to pass as the model's ``device`` argument:
- ``"cpu"`` forced CPU, or the only accelerator is MPS and it is not allowed.
- ``None`` let sentence-transformers auto-detect (picks CUDA / XPU,
handling multi-GPU correctly).
- ``"mps"`` Apple Silicon GPU, only when ``allow_mps`` is set.
MPS is never auto-selected because its per-shape cache leaks unbounded memory
under the engine's variable-length workload (see the module docstring). Set the
matching ``*_ALLOW_MPS`` config flag to opt back in.
"""
if force_cpu:
return "cpu"
try:
import torch
if torch.cuda.is_available():
return None # auto-detect CUDA
if hasattr(torch, "xpu") and torch.xpu.is_available():
return None # auto-detect Intel XPU
mps_available = hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
if mps_available:
if allow_mps:
return "mps"
logger.info(
"Local model: MPS (Apple Silicon GPU) is available but disabled by "
"default because its per-shape cache leaks memory under variable-length "
"workloads; running on CPU. Set the *_ALLOW_MPS flag to opt in."
)
return "cpu"
return "cpu"
except Exception as e: # pragma: no cover - defensive
logger.warning("Local device detection failed, falling back to CPU: %s", e)
return "cpu"
def resolve_model_device_type(model: object) -> str:
"""Best-effort device *type* ("cpu" / "cuda" / "mps" / "xpu") of a loaded model.
Used to decide which GPU allocator pool to empty after inference. Falls back to
``"cpu"`` (the safe no-op choice for release) if the device can't be read.
"""
device = getattr(model, "device", None)
if device is None:
inner = getattr(model, "model", None) # CrossEncoder wraps the HF model
device = getattr(inner, "device", None)
try:
return device.type if device is not None else "cpu"
except Exception: # pragma: no cover - defensive
return "cpu"
def _resolve_heap_trim():
"""Return a callable that asks the C allocator to release freed pages to the OS.
glibc (Linux) exposes ``malloc_trim``; macOS exposes
``malloc_zone_pressure_relief``. Resolved once at import; returns a no-op on
platforms where neither is available (musl, Windows).
"""
if sys.platform == "linux":
libc_path = ctypes.util.find_library("c")
if libc_path is None:
return lambda: None
try:
libc = ctypes.CDLL(libc_path)
trim = libc.malloc_trim
except (OSError, AttributeError):
# Not glibc (musl has no malloc_trim) or libc lookup failed.
return lambda: None
trim.argtypes = [ctypes.c_size_t]
trim.restype = ctypes.c_int
return lambda: trim(0)
if sys.platform == "darwin":
try:
libc = ctypes.CDLL("/usr/lib/libSystem.dylib")
default_zone = libc.malloc_default_zone
default_zone.restype = ctypes.c_void_p
relief = libc.malloc_zone_pressure_relief
relief.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
relief.restype = ctypes.c_size_t
except (OSError, AttributeError):
return lambda: None
# pressure_relief(zone, goal=0) reclaims as much as possible.
return lambda: relief(default_zone(), 0)
return lambda: None
_heap_trim = _resolve_heap_trim()
def _empty_gpu_cache(device_type: str | None) -> None:
"""Empty the allocator pool of the GPU backend the model ran on, if any."""
if not device_type or device_type == "cpu":
return
try:
import torch
backend = getattr(torch, device_type, None) # torch.cuda / torch.mps / torch.xpu
if backend is not None and hasattr(backend, "empty_cache"):
backend.empty_cache()
except Exception: # pragma: no cover - defensive
pass
def release_local_inference_memory(device_type: str | None = None) -> None:
"""Release transient heap (and GPU allocator) memory after a local inference batch.
Frees Python objects, returns freed native pages to the OS, and empties the GPU
allocator pool when the model ran on a GPU. Safe to call on every platform and
device; the pieces that don't apply are cheap no-ops.
"""
gc.collect()
_heap_trim()
_empty_gpu_cache(device_type)

Some files were not shown because too many files have changed in this diff Show More