2572 Commits
Author SHA1 Message Date
Sanderhoff-alt e4e5f8b285 fix(embed): inherit unset LLM settings instead of overwriting them (#3253) (#3359)
HindsightEmbedded forwarded every LLM/daemon setting on every construction, using
placeholder defaults for the ones the caller never mentioned. The embed manager
merges the caller's config over the profile .env and copies any non-None
HINDSIGHT_* entry into the daemon environment, so a client built without
credentials overwrote a key inherited from the profile or the parent shell --
and _register_profile then persisted the placeholders back into the profile's
.env file, leaving a profile configured for anthropic recorded as groq on disk.

llm_provider, llm_api_key, llm_model, log_level and idle_timeout now default to
None and are omitted when not passed, so the daemon resolves them from the
profile .env, then the parent environment, then its own defaults. An explicit
empty string remains an override, which is how a local LLM service with no
authentication clears an inherited key.
2026-08-19 12:09:57 +02:00
Nicolò Boschi 6582e26ef9 feat(mcp): expose knowledge-base CRUD as native MCP tools (#3486) (#3611)
The knowledge base was reachable only over HTTP, so an MCP client had to fall
back to a second integration path to browse or maintain it. Register the seven
agent-facing operations as native MCP tools, with the same bank scoping, tenant
auth and operation-validator behaviour as the existing tools:

  get_knowledge_base_tree, search_knowledge_base, get_knowledge_page,
  create_knowledge_folder, create_knowledge_page, update_knowledge_node,
  delete_knowledge_node

export_knowledge_base stays HTTP/CLI-only — it returns the whole bank as one
markdown bundle, which does not belong in an agent's context window.

Two places where the MCP surface cannot mirror the HTTP one, both commented at
the call site:

- MCP arguments cannot express an explicit null, so update_knowledge_node reads
  parent_id="root" as "move to the top level". Node ids are prefixed kf-/kp-, so
  the literal cannot collide with a real folder id.
- The page refresh trigger is flattened to a single refresh_after_consolidation
  flag, matching how create/update_mental_model already expose it. It is sent as
  a patch, so an unstated flag leaves the knowledge-page defaults (delta mode,
  observation-only) intact — the regression #3506 fixed.

get_knowledge_page returns the rendered markdown document once instead of the
HTTP body+markdown pair, which would double the tokens for no new information.
search_knowledge_base clamps limit instead of rejecting it: an agent that asked
for 500 pages wants results, not a 422.

Also adds a structural guard that the three hand-maintained tool allowlists
(_ALL_TOOLS, register_mcp_tools()'s default set, and the single-bank set in
create_mcp_server) agree with what is actually registered — a name added to one
but not the others silently drops the tool from the endpoint.
2026-08-19 11:58:18 +02:00
Nicolò Boschi 188eaa3dc7 fix(coding-agents): honor retainSessions in the hook harnesses (#3596) (#3607)
* fix(coding-agents): honor retainSessions in the hook harnesses (#3596)

`retainSessions` was parsed, defaulted, env-mapped and accepted as a known
config key, but only `RuntimeCore` (opencode, Kilo, Cline, Prime Agent, dsh)
ever read it. The shared Stop-hook flow behind every hook harness — claude-code,
codex, cursor-cli, copilot-cli, devin-cli, grok-build, antigravity-cli — checked
`disabled` twice and `retainSessions` never, so `retainSessions: false` (global,
per-harness or in a `banks.<id>` section) wrote the transcript back anyway. The
comment claimed this was deliberate while the docs sold the flag as a general
write-back opt-out, including a per-bank example.

Gate the write-back in `runRetainHook`, after `applyBankConfig` so a bank
section can flip it either way, and before `ensureDaemon` — a session that
writes nothing has no reason to bring a server up. A `retain_disabled` diag
record replaces the `retain_ok` that used to appear, so the opt-out is
verifiable in the diagnostic log.

`deepen`'s conversation-history import is the same door one session later: it
reads the harness's own history files and files them as `chat:<id>`. Honoring
the flag in only one of the two places would have left the opt-out cosmetic, so
it skips the import too. Git ingest, seeding, knowledge pages, recall and the
memory tools are all untouched — that separation is what distinguishes this flag
from the `disabled` kill switch.

Tests: four end-to-end `runRetainHook` cases (default writes; global false
writes nothing and builds no client; a bank override opts one repo out; a bank
override re-enables under a global opt-out), two of which fail against the
pre-fix code. Plus a family-wide structural guard in the shape of
`daemon.test.ts`'s "every harness entrypoint reaches a daemon": every module
calling `retainLiveSession` or `ingestChats` must consult the flag. The path
that forgot is by definition the one with no test, so the guard is asserted over
the whole family rather than per-harness.

* chore(docs): re-sync the coding-agents page after the README reflow
2026-08-19 11:51:25 +02:00
Nicolò Boschi 39de3974ea fix(coding-agents): keep a long-lived host's credential live, and say which one it used (#3600) (#3606)
* fix(coding-agents): keep a long-lived host's credential live, and say which one it used (#3600)

`HindsightClient` copied `apiToken` at construction and never re-read it, so a
host that outlives its credential — dsh, Cline, Kilo, Prime Agent, opencode,
the MCP server — kept signing with a key the operator had already replaced.
Enabling auth or rotating the key mid-session 401'd every call until the whole
host restarted, while `hindsight_diagnose` re-read the file and reported the
situation as healthy. The one-shot hook binaries were immune, which is why the
same machine showed working hooks alongside dead in-session tools.

The credential is now resolved through a provider on a 401 and the request
replayed once, but only if the re-resolved token actually CHANGED — a genuinely
wrong key still surfaces as one 401 rather than doubling every failing request.
The happy path never touches the filesystem.

All three fetch paths go through one signing helper. `reflect` and the drain
poll fetched directly, so a recovery wired into `req()` alone would have left
them failing forever.

Both #3600 and the two drifts below come from the same shape: five hosts each
carried their own copy of loadConfig -> deriveBankId -> applyBankConfig ->
new HindsightClient. So the fix is one shared builder (core/host-client.ts)
rather than a sixth line pasted into each. Hoisting it fixes two settings that
had already gone missing that way:

- dsh and Prime Agent never passed `maxParallelRetains`, so both silently
  ignored it and always used the default 10.
- dsh never passed the directory to `applyBankConfig`, so `optInOnly` was not
  enforced there at all: an unapproved repo still got a bank.

`hindsight_diagnose` now reports the credential IN USE next to the one on disk
(booleans only, never the value), resolved through the same pipeline the host
used — including a per-bank `banks.<id>.apiToken`, which a bare loadConfig()
comparison would have reported as a permanent false mismatch. Without this the
drift stays invisible to the one tool whose purpose is to explain it.

A 401 also now says whether a credential was even sent. The server answers
identically for "no key" and "wrong key"; only the client knows which it was.

Behaviour change worth naming: `disabled: true` now wins uniformly. It already
did for every host except the MCP server, which applied the `banks.<id>`
section first and so could be re-enabled per bank; `optInOnly`/`optInPaths` is
the supported way to run memory in only some projects. Resolution also stops
before bank derivation when disabled, since that shells out to git and the
disabled path exists to be a zero-overhead baseline.

Reported with a verified local patch and a full root-cause analysis by
@allenliang2022 in #3600; this implements that approach.

* docs(coding-agents): say when a config change takes effect, and that the token is the exception

Nothing in the README or the skill said when an edit to
~/.hindsight/coding-agent.json actually applies — and the answer differs per
host: a hook harness re-reads the file on every invocation and picks a change
up on the next prompt, a persistent plugin holds it for the life of the agent
process, and the MCP server for the session.

That gap got worse, not better, with the credential fix: the apiToken row now
says it is picked up without a restart, which reads as "config is live" unless
the rule it is an exception to is written down somewhere.
2026-08-19 11:40:41 +02:00
Nicolò Boschi efef4fa398 docs(readme): track hindsight-client downloads, cover the missing concepts (#3608)
* docs(readme): track hindsight-client downloads, cover the missing concepts

The PyPI downloads badge tracked hindsight-api; point it at hindsight-client
(1.6M/month) and make both download badges real links — the NPM one passed
`link=` as a shields param, which is inert in an image.

Relabel the "CI" badge to "Release": it points at release.yml, which only runs
on v* tags, so green meant "the last release published", not "tests pass".
test.yml (the actual CI) has no push trigger, so there is no main history to
badge without changing its triggers.

The LLM Wrapper was pitched as the easiest way in and shown only as a PNG —
uncopyable, unreadable to search engines and coding agents, with no
`pip install hindsight-litellm` and no link to the integration. It is now real
code, and passes hindsight_api_url explicitly because the wrapper defaults to
Cloud, which would otherwise silently send a local-Docker reader to
api.hindsight.vectorize.io.

Add the concepts the README never mentioned: integrations (60+, none were
listed), coding agents, MCP, observations, mental models, knowledge pages,
banks/dispositions, multilingual, Memory Defense, and production concerns.
Add Helm, bare-metal pip and Cloud install paths, plus the Go and CLI clients.

Fix stale facts: link the live benchmarks site next to the January 2026 chart,
and replace the hand-maintained 8-provider list with 25+ and the headline names.

Structure: add a Contents block, move Supported Platforms next to Quick Start
(it sat in the footer, ~200 lines below the anchor pointing at it), and collapse
the three repeated client-setup preambles in the operations section.

* docs(readme): point Cloud mentions at the pricing page

The Cloud links went straight to signup, so a reader had to start an account
to find out what the hosted option actually includes. Point them at
https://vectorize.io/pricing instead, which is Hindsight-specific and compares
self-hosted, Cloud and Enterprise side by side, and summarise what Cloud gives
you (managed scaling, dashboard, backups, 99.9% SLA, usage-based billing with
free credits). Signup stays as the action link next to it.

No prices in the README on purpose — they would go stale here, which is the
same failure mode as the hand-maintained provider list this branch removed.

* docs(readme): drop Pricing from the header nav

The pricing links in the Managed install path and the production table are
where a reader is actually deciding between hosting options; the nav row does
not need a sixth item.
2026-08-19 11:31:43 +02:00
Nicolò Boschi e11a59ff64 fix(coding-agents): timestamp the aggregated git-log document (#3602) (#3605)
`ingestGitLog` retained the commit-message history with no `timestamp`, so retain
stamped "now", the extraction prompt got `Event Date: Unknown`, and every fact
extracted from those messages landed with a null occurred_start/occurred_end —
invisible to temporal search and neutral for recency scoring.

Anchor the document on the newest commit it actually contains (`git log -n 1
--no-merges --format=%aI`, matching gitLogText's traversal, so a merge HEAD does
not misdate it). Null on an empty repo/non-repo, in which case the timestamp is
omitted as before.
2026-08-19 11:01:22 +02:00
Nicolò Boschi 0de91b8b73 fix(coding-agents): let hindsight_reflect wait as long as it is configured to (#3590) (#3592)
The `hindsight_reflect` MCP tool aborted every call at a hardcoded 120s, no
matter what `reflectTimeoutMs` was set to: the handler passed no `timeoutMs`,
so `HindsightClient.reflect()` fell back to its own 120s default. On a
populated bank, `budget: "high"` synthesis routinely runs longer than that —
the identical direct API call succeeded — so the tool was unusable and the
config field was dead.

Both paths that build the tools dropped the setting, not just the one filed:
`selectTools()` (MCP server) and `RuntimeCore.toolSpecs()` (the persistent
plugin harnesses — opencode, Kilo, Cline, dsh, Prime Agent).

The tool's window is now its own knob, `reflectToolTimeoutMs`, defaulting to
330s — above the server's own reflect wall timeout (300s), so the server
decides when to give up rather than an arbitrary client deadline. It inherits
an explicitly raised `reflectTimeoutMs` (the field users already reach for),
but a short one never lowers it: that value bounds an automatic hook which
must fit the host's 25s window, not a call the agent is waiting on.

`reflectBudget` makes the hardcoded `budget: "high"` configurable too, for
large banks where high-budget synthesis exceeds the server's wall timeout.

To stop this recurring, `reflect()`'s `timeoutMs` is now required — the right
deadline differs by an order of magnitude between the hook and the tool, so
there is no sensible default to fall back to silently.
2026-08-19 10:47:31 +02:00
Nicolò Boschi 31c1aaf213 fix(recall): make the recall budget reach the vector index (#3541)
The semantic arm asked the index for `max(limit * 5, 100)` rows, but every pool
connection runs with a fixed `hnsw.ef_search = 200`. In pgvector the candidate list is
the result set — the ground-layer search runs once and the scan ends when that list
drains — so a scan returned at most ~200 rows however large the LIMIT above it, and the
recall budget moved the SQL and nothing else (low/mid/high all got ~200).

Enable hnsw.iterative_scan (strict_order) on recall connections. The drained list is
refilled in ef_search-sized rounds until the query's LIMIT is met, so depth follows each
query's budget with no per-query statement — which matters behind a transaction-mode
pooler, where a session GUC issued between statements can land on a different backend.
Retain-side link probing pins it off; it is tuned for latency, not depth.

Measured on a 40k-row bank, EXPLAIN confirming an ANN index scan:

  MID  unfiltered   200 -> 300 rows,   5.0 ->  4.9ms
  HIGH unfiltered   200 -> 1000 rows,  4.9 ->  6.8ms
  HIGH + filter     200 -> 324 rows,   5.9 -> 14.2ms

+8ms worst case against a ~2.6s recall; the perf suite puts it at +1.3% mean latency /
-1.3% throughput end to end. Memory is not the constraint it looks like: pgvector caps a
resumed scan at work_mem * hnsw.scan_mem_multiplier, but max_scan_tuples binds first —
squeezing work_mem from 64MB to 256kB changes neither rows nor latency.

Two controls, both static server-level config:

- HINDSIGHT_API_ANN_ITERATIVE_SCAN (default true) — the kill switch. False drops the
  resume GUCs rather than sending iterative_scan=off, so a connection is left exactly as
  it was before this existed and the revert lands on the behaviour already in production.
- HINDSIGHT_API_ANN_MAX_SCAN_TUPLES (default 4000, pgvector's own is 20000) — the dial
  that governs the cost. The initial scan is not counted, so even 1 leaves the
  pre-existing depth intact; it interpolates rather than switches.

Separately, the row over-fetch is deleted rather than tuned. It never did anything: each
arm's rows arrive already ordered by distance, so keeping the first N of 5N returns
precisely what LIMIT N would have. Invisible on pgvector, real on backends with no such
bound. The LIMIT is now max(limit, GRAPH_SEED_LIMIT), since the graph arm reads its entry
points from the same rows.

Also fixed: a GUC the server rejects as unknown is remembered and dropped from later
batches, instead of costing a failed batch plus one statement per setting on every
acquire — reachable via pg_trgm on a cluster without it, and via hnsw.iterative_scan on a
pgvector older than 0.8, which reserves the "hnsw." prefix and rejects it outright.
Retain's link probing skips such a GUC too: it applies these with SET LOCAL inside its own
transaction, where an erroring statement would abort the link computation.

Not measured: whether the extra candidates improve answers. Everything above is cost.
2026-08-19 10:46:04 +02:00
github-actions[bot] 9db22115a4 chore: update star history 2026-08-19 03:33:48 +00:00
Nicolò Boschi 8b78b4ac04 test: assert memory state via the engine read API, not raw SQL (#3591)
The suite asserted memory state by querying `memory_units` / `memory_links` /
`unit_entities` directly. That couples tests to the physical schema and makes
them unable to run against any store that keeps memory rows outside Postgres.
This ports what the read API can answer, extends it where it could not, and
marks the residue that is Postgres-shaped by nature.

**Ported to the engine API.** The "unconsolidated count" assertions spelled out
`consolidated_at IS NULL AND consolidation_failed_at IS NULL AND fact_type IN
('experience','world')` — character-for-character what
`list_memory_units(consolidation_state='pending')` already means, so seven sites
across five files became one call. Observation lineage, entity/tag checks and
chunk provenance moved to `list_memory_units` / `get_memory_unit` /
`list_document_chunks`; where the old query was an inner join, the port keeps
the same filtering and says why.

**Read model extended** so the rest could follow: `updated_at` and
`source_memory_ids` on list items, `entity_kind` on entity items, and a
list-valued `fact_type` that matches any of them. Every field is a column of the
row the query already fetched — the projection grows, the plan does not — so no
opt-in flag was needed and production paths pay nothing. Covered by a new test
module driving it all through the engine on retain-written units.

**260 of 6297 tests marked `memory_backend_incompatible`**, in two passes: those
that assert Postgres-internal state (raw `memory_links` counts, `embedding` /
`search_vector`), and those whose fixtures only exist in Postgres. The second set
was chosen on evidence — each both failed against a non-SQL store and touches
those tables in its body, a helper, or a fixture — never by grep alone. Postgres
runs are unchanged; the marker only takes effect behind
`-m 'not memory_backend_incompatible'`.

Also green-lights two checks that were red before this branch: the repo formatter
over five test files, and the coding-agents docs generator, which now rewrites our
own doc links to site-relative the way it already did for assets — the naive
regeneration would have degraded the docs-skill reference's file-relative links.
2026-08-19 00:23:44 +02:00
Nicolò Boschi edd0d0c5bb fix(db): earn per-bank vector indexes by size instead of creating three per bank (#3485) (#3561)
* feat(db): earn per-bank vector indexes by size instead of creating them per bank

Every bank got three partial vector indexes on the shared memory_units table at
creation time. PostgreSQL locks and builds an IndexOptInfo for every index on a
relation at plan time, and opens every one of them for each DML statement, so
each index is a cost paid by queries belonging to every *other* bank. Past a few
thousand banks that exhausts the lock-manager pool: recall and bank deletion
both fail cluster-wide, and deletion failing is what removes the recovery path
(#3485).

A (bank, fact_type) now earns an index once it holds
HINDSIGHT_API_VECTOR_INDEX_MIN_ROWS rows (default 10_000, 0 disables). Below
that the planner serves the same ANN query from idx_memory_units_bank_fact_type
plus a top-N sort — exact rather than approximate, and faster, because sorting a
few thousand rows by distance beats descending an ANN graph. Index count becomes
proportional to the banks large enough to benefit, so bank count stops being a
ceiling.

No request path issues vector-index DDL any more. Bank creation, retain and
import all drop their CREATE INDEX, which also takes that ShareLock on
memory_units off the retain hot path. A new bounded sweep on the MaintenanceLoop
converges the index set instead, with separate build and drop budgets (a build
is an ANN construction; a drop is a catalog operation, and a deployment
recovering from #3485 has tens of thousands to shed) and a short retry interval
while a backlog remains. Bank deletion keeps its drop — it is the only place
that still knows the internal_id the index names derive from.

Cross-tenant discovery goes through a new banks_needing_vector_index() routine,
following the sibling sweeps: one round-trip instead of a per-schema query
storm, with the vanished-schema and lock_timeout arms from c7e9f1a3b5d2 and
c8b4e2a71f95. Concurrency is handled by idempotency, never an advisory lock —
leaning on one is why #2803's version of this sweep was rejected.

The migration deliberately issues no index DDL. An instance already at the wall
cannot plan a statement against memory_units, so a migration that counted rows
to decide what to drop would fail before it could help; DROP INDEX is a utility
statement that locks its own index plus the table, which is why the sweep can
shed indexes while everything else on that relation is failing.

Refs #3485, #2645

* test(vector-index): serialize the reconcile tests onto one xdist worker

Every test in the reconcile suite issues CREATE/DROP INDEX CONCURRENTLY against
the single shared public.memory_units. Concurrent index DDL on one relation
deadlocks by design — CONCURRENTLY holds ShareUpdateExclusive while waiting out
every session whose snapshot could still see the index, including other
sessions' queued index DDL — and eight xdist workers doing that to one table
outlast any retry budget (the storm f9cef24cb was written for). The tests passed
in isolation and deadlocked in the full run, taking test_maintenance_routines
with them.

Advisory locks are banned and an in-process lock cannot reach across xdist
processes, so the isolation has to come from the scheduler, as it already does
for backup_restore and worker_tests.

Also refresh a stale comment in migrations.py: per-bank partial indexes are
owned by the maintenance sweep now, not by bank creation.

* refactor(vector-index): one home for the size policy, and test the sweep body

Two findings from reviewing the branch, both in code this branch added.

should_have_per_bank_index() was reachable only from tests: the reconcile
re-implemented the same predicate inline, so the policy existed twice and only
one copy was load-bearing. Collapsed into qualifies_for_per_bank_index(), which
the reconcile now calls. Its extension argument goes with it — the backend
question is already settled by _vector_index_sweep_enabled and
_vector_index_clause before any reconcile runs, so re-asking it there was a
second, weaker copy of a decision already made.

_run_vector_index_sweep was mocked in every test that mentioned it, so its body
never executed. That body decides which schemas get reconciled, and reconcile
drops every index a schema's partition list does not account for — pointing it
at a schema this deployment does not serve, or one discovery could not read,
destroys indexes it knows nothing about. Now covered: served-but-undiscovered
schemas are skipped and set the backlog, unserved-but-discovered schemas are
never touched, budgets come from config, and a sweep with no DDL connection is a
quiet no-op.

The threshold fixture patches qualifies_for_per_bank_index rather than the old
name, and keeps monkeypatch's default raising=True: if the reconcile ever stops
calling it, the fixture must fail loudly rather than silently leave the
production 10,000-row threshold in place and turn every test below it into a
vacuous pass.

* refactor(db): default to today's coverage, and let writes drive it

Three changes to the size-gated layout, all narrowing it.

The threshold now defaults to off (0 = no minimum). Every bank holding memories
is indexed, exactly as before, so an existing deployment upgrades without
changing behaviour; only a deployment holding thousands of banks sets it. The
knob went in to remove a ceiling on bank count, not to change what a normal
deployment does. An empty partition is excluded explicitly rather than by
arithmetic: at a threshold of 0 the bare `row_count >= minimum` is true for zero
rows, which would have entitled every bank to three indexes over nothing the
moment it was created — the index explosion the threshold exists to prevent,
reintroduced by its own default.

The periodic sweep is gone, replaced by a per-bank async operation. Only a write
moves a bank across the threshold, so there was nothing for a sweep to discover
that the writer did not already know; polling every tenant schema on a timer to
find that out was the wrong shape. submit_async_vector_index_maintenance hangs
off _submit_post_insert_maintenance (retain and import) and off
_handle_consolidation, which is the other writer of memory_units rows —
observations are their own indexed partition. It follows
submit_async_graph_maintenance exactly: plan first and short-circuit with
no_work=True so an unconditional caller pays only a cheap indexed pre-check, and
dedupe by bank including *processing*, since the job re-plans from live counts
when it starts. A failed build is logged, never raised: it leaves the bank on
the exact B-tree path (slower on a large bank, never wrong), the usual cause is
transient DDL contention, and the next write re-queues the work — failing the
operation would surface routine contention as a broken async op.

That removes the cross-tenant discovery routine and its migration, the
MaintenanceLoop job, and the sentinel-row protocol that told a skipped schema
apart from an empty one. It also makes the scoping bug found in review
structurally impossible: planning starts from one bank's internal_id and can only
ever name that bank's three indexes.

The per-pass build/drop budgets are gone. They bounded a sweep that could touch
every bank in the database; one bank's reconcile has nothing to bound.

One capability had to be re-homed. An index whose bank row is gone cannot be
planned from — there is no internal_id left to derive its name — so no
bank-scoped path can reach it. That is exactly the state a deployment at the
#3485 wall is in, because it could not run delete_bank at all. `repair-bank
--all` now also runs a catalog-only orphan sweep, keyed off the live internal_id
set so it cannot mistake a live bank's index for a leftover.

A new operation type has three registries to satisfy, and a guard test caught
the first: WORKER_SLOT_TYPE_DEFAULTS (without it no RESERVED_SLOTS env var
exists, so capacity cannot be reserved for it), and the control plane's
OPERATION_TYPE_VALUES + label map across all ten locales (without those the
operations list shows a raw snake_case string and cannot filter on it).

Tests raise the threshold out of reach suite-wide. At the shipped default every
throwaway bank the suite creates would queue an index build, and eight xdist
workers issuing CREATE INDEX CONCURRENTLY against one shared memory_units
deadlock each other by design — landing on whatever unrelated test happens to be
writing. Tests that exercise coverage patch the threshold themselves, and the
two staging helpers now build CONCURRENTLY with retry: a plain CREATE INDEX
takes ShareLock and closes a three-way cycle with another worker's DROP INDEX
CONCURRENTLY.

* fix(db): drop coverage when a bank loses rows, not just when it gains them

Three defects, all on the drop side, all found by asking what happens when
documents are removed rather than when the threshold moves.

**The drop check was dead at the shipped default.** It read
`row_count < per_bank_index_drop_rows()`, and at a threshold of 0 the drop floor
is also 0 — so `0 < 0`, never true. In the configuration almost everyone runs,
an emptied partition kept its ANN index forever. Replaced with an explicit
should_keep_per_bank_index() whose `row_count > 0` term is not an optimisation:
without it every bank ever written to and then cleared accumulates three indexes
over nothing, which is the accumulation the threshold exists to prevent.

**No delete path queued a reconcile.** Retain, import and consolidation did;
document deletion, memory deletion and bulk deletion did not, though each sits
next to a submit_async_graph_maintenance call. A bank pruned below the threshold
kept indexes it no longer earned until the next *write* — and an emptied bank is
exactly the one nobody writes to again. All five row-count-changing paths now go
through one _submit_vector_index_maintenance_quietly helper rather than five
copies of the same try/except.

**`DELETE /memories` was not wired at all.** It routes to
delete_bank(delete_bank_profile=False), which deliberately does not drop indexes
— the full-delete path drops them by name while it still knows the internal_id,
and this path keeps the bank. So clearing a bank left three indexes over zero
rows, permanently. That path now queues a reconcile too.

The handler also hands off to a successor when it finishes, mirroring graph
maintenance's re-submit for work landing between a job's final claim and its
completion. Bounded twice over: the successor's own pre-check short-circuits
once coverage matches, and the hand-off is skipped when a build failed, so a
permanently failing index cannot spin submits.

Tests come at the drop side from the row direction, which is the blind spot that
let all three through — every previous drop test raised the threshold and left
the rows alone, so `0 < 0` was never evaluated. Each new test was verified by
reintroducing its bug and watching it fail.

Also adds the control-plane guard that was missing. Python already asserts every
operation type has worker slot-reservation config (it caught that omission when
this operation type was added); nothing asserted the UI's OPERATION_TYPE_VALUES
and label map cover the same set, so a new type silently renders as raw
snake_case with no filter. Verified by breaking the parity and watching it fail.

* chore(vector-index): refresh a comment the redesign left stale

per_bank_index_min_rows() still explained itself in terms of "the sweep",
which no longer exists — coverage is decided by the write path's pre-check,
the maintenance operation and the admin command. A comment describing a
design that was removed is worse than none: the next reader takes it as
current. Plus ruff-format collapsing two wrapped calls.

* fix(vector-index): reconcile against the engine's own database, not config's

The maintenance job needs a connection of its own — CREATE/DROP INDEX
CONCURRENTLY cannot run on a pooled one inside a transaction — and it got one by
reading HINDSIGHT_API_DATABASE_URL back out of config. That is only correct when
the engine was configured from that env var. Hand the engine a DSN directly and
the two diverge: embedders do exactly that, and so does the test suite, which
resolves pg0 in a fixture and never exports the variable. Every reconcile then
opens a connection to some other database and dies on
`relation "public.banks" does not exist`.

PostgreSQLBackend now keeps the DSN its pool was opened with and exposes it, and
the handler uses `migration_database_url or backend.dsn`. The env var stays
first: CONCURRENTLY needs a real backend session for the whole statement, which a
transaction-pooled URL cannot give, and that variable is the documented direct
connection escape hatch — migrations prefer it for the same reason.

CI caught this where local runs structurally could not. The scratch pytest plugin
used locally exports HINDSIGHT_API_DATABASE_URL to work around a port collision,
so config and the real DSN agreed on the dev machine and disagreed in CI. The
regression test therefore reproduces CI's condition rather than the local one: it
points config at a database that does not exist and asserts the indexes are built
anyway.

* style: ruff-format the DSN regression test's assertion message
2026-08-18 18:03:00 +02:00
Nicolò Boschi 5f137bf391 fix(recall): keep the observation graph arm out of a nested-loop plan (#3510) (#3588)
`expand_observations`' scoring join is O(C + U) as a hash join and O(U x C) as a
nested loop, where C is the connected-source set and U the unnested candidate
source ids. PostgreSQL picks between them from its row estimate for the
`connected_sources` CTE, and that estimate was 1 against an actual ~3,700: the
capped column came out of a LATERAL + LIMIT subquery, which carries no
n_distinct statistic, so DISTINCT over it was estimated at 2 and the NOT EXISTS
anti-join took that to 1. A 1-row inner side makes the nested loop look free, so
it won on cost and lost by four orders of magnitude at runtime — 15s and ~15M
rejected join rows on a realistically-shaped bank, matching the plans reported
in the issue.

Rank with row_number() instead. Identical output — same cap, same ordering,
unit_id is unique — but the capped column now traces to unit_entities.unit_id, so
the estimate comes from real statistics (207-3,449 against 2,242-4,193 actual)
and the nested loop is priced honestly. Measured over 12 seed sets on the fixture
below: p50 15,013ms -> 217ms, with the full scored set identical.

The set-difference rewrite proposed in #3512 also clears the reported bank, but
it leaves the estimate at 2 and survives only because a set-op prices the nested
loop just above the hash join: 1.0-1.2x headroom against 1.6-1.8x here.

The trade is that ranking reads every unit_entities row of a matched entity where
the LATERAL stopped at per_entity_limit off the index: O(sum of degree) rather
than O(entities x per_entity_limit). At parity up to ~12k-degree hubs, +50%
traversal cost at 38k.

Why the perf suite never caught it
----------------------------------

`recall-with-observations` measured 0.45s on the same query a realistically
shaped bank runs in 15s. Two fixture properties were wrong, and neither alone
reproduces the bug — measured on the suite's own bank:

                          sources=113   sources=mean 2
    old vocabulary          450ms          270ms
    new vocabulary          951ms       15,013ms

- The entity vocabulary was a fixed 145 names at every scale, so degree grew with
  bank size instead of the entity count growing: 142 entities at median degree 40
  with not one entity mentioned once, and every seed reaching 142 of 142
  entities. It now grows with the corpus (1,354 entities, median degree 3, 449
  mentioned once, seeds reaching 54).
- Sources per observation was a constant. Real counts are long-tailed — the
  reported bank ran mean 1.7 / p95 4 — so it is now the mean of a Pareto draw.
  At mean 2 the fixture still emits observations carrying several hundred
  sources, keeping the array-length path from #3085 exercised.

`recall-with-observations` at scale=large will step up when this lands: the
suite can finally see this query. The SQL fix is in the same change so the
dashboard moves once, not twice.

Oracle's expand_observations has the same DISTINCT-over-LATERAL shape and is
deliberately left alone — no Oracle instance was available to measure it, and its
cardinality estimation differs. Documented in ops_oracle.py.

Tests
-----

- test_per_entity_cap_bounds_hub_traversal pins that the window ranks the same
  rows the LATERAL selected; it fails if the cap is widened or dropped.
- test_perf_fixture_shape asserts the post-resolution entity graph keeps a long
  tail. It simulates the entity resolver's intra-batch fuzzy merge, because the
  tail names have to stay under the 0.5 pg_trgm threshold: a tail generated as
  "<stem> <counter>" scores 0.73 and the resolver collapsed 2,814 names to 159,
  silently restoring the flat graph the vocabulary exists to avoid.
2026-08-18 17:38:24 +02:00
Nicolò Boschi 6692e38c80 fix(api): paginate the bank list (#3586)
* fix(api): paginate the bank list

GET /v1/default/banks returned every bank in the system: no limit, no
offset, and a query with no LIMIT clause. Beyond the unbounded payload, the
per-bank work — config resolution and a live store count for banks whose
memories live outside SQL — ran for every bank rather than the ones being
shown.

The endpoint now takes limit/offset (defaults 100/0, matching
list_documents) plus a `q` substring filter on bank id and name, and
returns total/limit/offset alongside `banks`. Paging happens after
filter_bank_list rather than in SQL: that extension hook can drop any bank,
so a SQL page would hand back short pages and a total counting banks the
caller can't see.

Consumers page instead of taking the first 100: the control-plane bank
selector scrolls infinitely and searches server-side, the CLI walks every
page, and the Zapier bank dropdown became canPaginate.

* fix(api): bound the bank-list probes and keep the selected bank's name

Follow-ups from reviewing the pagination change:
- the control-plane health probe and the Zapier credential test only need to
  know the endpoint answers, so they ask for limit=1 instead of a default page
- the header showed the raw bank id whenever the selected bank sat past the
  first page, so its name is fetched directly
- limit/offset are clamped in the engine: the page is a Python slice, and the
  MCP tool takes both straight from a model with no HTTP-layer validation

* docs(mcp): document list_banks query/limit/offset

* fix(control-plane): make the bank selector actually page and report empty searches

Verified against a 130-bank instance: the infinite scroll never fired. The
observer effect read listRef.current/sentinelRef.current on the commit that
flips the popover open, but Radix mounts the content in a portal afterwards, so
both refs were null and nothing re-ran the effect — the selector sat on its
first 50 banks forever. Tracking the nodes as state through callback refs
re-runs the effect when they attach; paging now walks offset 0/50/100 and stops
at the total.

An empty result also read "No memory banks yet." after a search that simply
matched nothing, so searches get their own message.

* feat(control-plane): smooth the bank selector as pages land and searches narrow

The list is paged and searched server-side now, so rows appear and vanish in
batches — every page landed as a hard 50-row pop, and a search that narrowed to
one bank snapped the popover shut from 300px.

- rows fade and lift in, staggered within their page and capped so the tail of a
  50-row page doesn't crawl; only rows that actually mount animate, so appending
  page 2 leaves page 1 still
- the list height follows cmdk's --cmdk-list-height, easing down to the filtered
  set instead of jumping
- the previous results hold their place and dim while the next set is in flight,
  rather than blanking on every keystroke

The animations are defined in globals.css next to the existing logo keyframes:
tailwindcss-animate is a Tailwind v3 plugin declared in tailwind.config.ts, but
this app runs Tailwind v4 with the CSS-first config, so `animate-in` and friends
compile to nothing here.

* refactor(control-plane): tidy the bank row className and import
2026-08-18 17:27:27 +02:00
Nicolò Boschi df8ac42b52 fix(memory): add resolve_entities flag to update_memory and retain (#3576)
* fix(curation): resolve edited entity names exactly, not fuzzily (#3479)

update_memory ran the caller's entity names through the same fuzzy resolver
retain uses, so an entity name was a *guess* to be reconciled against the graph
rather than an instruction. Name identity is worth at most 0.5 of the 0.6 match
threshold, while co-occurrence (0.3) and recency (0.2) make up the rest, so a
similar-but-wrong entity that is well connected to the other names in the same
edit outscores the one the caller actually named — with a 200 and no warning.

Curation now resolves exactly: an existing entity is reused only when its
canonical name matches case-insensitively, any other name creates its own
entity, and same-batch names are never merged with each other. Retain keeps
fuzzy resolution, which is right for names that came out of extraction.

The exact path skips the trigram/UTL_MATCH probe and the co-occurrence fetch
entirely and reuses the existing find-or-create pass, so it is dialect-agnostic
and strictly less work than the fuzzy one.

* fix(curation): add entity_resolution_mode to update_memory, default fuzzy

Make the exact/fuzzy choice the caller's, rather than changing what an edit
does. `entity_resolution_mode` defaults to "fuzzy" — retain's behaviour, so
every existing caller is unaffected — and "exact" opts into literal matching for
hand-authored corrections.

Plumbed through the HTTP request model, the MCP tool, the control-plane proxy
route and its client, with the engine validating the value for direct callers.
The control-plane memory editor sends "exact": a person typing an entity list
into the admin UI is naming the entity they mean.

* refactor(curation): make the flag a boolean, resolve_entities

Replaces the entity_resolution_mode enum with a plain boolean on update_memory.
`resolve_entities` defaults to True — retain's behaviour, so existing callers are
unaffected — and False takes the submitted names literally.

Carries the same change through the resolver, which now takes `fuzzy_matching`
rather than a mode string. The engine's value guard goes away with the enum: a
bool needs no validation, so the invalid-value test goes too (the HTTP boundary
still 422s a non-boolean, which the HTTP test covers).

* feat(retain): honour resolve_entities for caller-supplied entities too

Same flag, same default, on the retain item — a caller passing explicit entity
names there has the same exposure as one correcting a memory: a name close to an
existing entity can be matched onto it and quietly replaced.

Retain resolves caller-supplied and LLM-extracted names in ONE batch, so a
per-batch flag would have turned resolution off for the extractor's names too and
filled the bank with near-duplicate entities. The flag is therefore carried per
mention: extracted names always resolve, supplied names follow the item's flag,
and a supplied name the extractor also produced keeps the caller's intent. The
in-batch dedup pass (#3107) skips the literal names for the same reason.

Also renames the resolver's `fuzzy_matching` parameter to the per-mention
`resolve` key, so one name is used end to end.

* fix(clients): carry resolve_entities through the maintained wrappers

Code review found two gaps the generated SDKs hide.

The TypeScript and Python convenience wrappers rebuild each retain item field by
field, so `resolve_entities` was silently dropped for every wrapper caller — the
same class of gap #2975/#3042 closed for the mental-model methods, and here it
would have quietly restored the substitution the flag prevents. Both wrappers now
forward it, with mapping tests on each side.

Intake also lost the flag when normalization collapsed two spellings into one:
entity_processing dedups caller-supplied against extracted names on the RAW text,
so a caller's literal "Acme Corp" and the extractor's "Acme\nCorp" both reach
_prepare_entities_for_resolution and only merge there. Keeping the first entry
verbatim dropped the caller's resolve=False with it; the merge now keeps the
stricter flag.

* fix: build the Rust CLI, and keep pg_trgm detection on empty batches

Two CI breaks from the retain change.

MemoryItem gained a field, and the CLI builds it with a struct literal, so every
Rust job failed to compile. The CLI supplies no entities, so `true` (the server
default) is the right value there.

The skip-the-probe guard also fired on an *empty* batch — `any([])` is False — so
_resolve_entities_batch_impl returned before the pg_trgm auto-detection that
hangs off the strategy dispatch. Only shortcut when there is data and none of it
resolves.

* fix(rust): add resolve_entities to the remaining MemoryItem literals

The first pass only fixed the CLI's src/ literal — `cargo build` does not compile
test targets, so the ones in hindsight-cli/tests/integration_test.rs and
hindsight-clients/rust/src/lib.rs went unnoticed until CI. Verified with
`cargo check --all-targets` in both crates this time.
2026-08-18 16:29:33 +02:00
Nicolò Boschi d98990b46e fix(embed): harden daemon and UI lifecycle (#3099, #3100, #3517, #3520, #3527) (#3585)
* fix(embed): harden daemon and UI lifecycle (#3099, #3100, #3517, #3520, #3527)

Five open hindsight-embed issues all sit in the same two files and share one
root theme: the manager decides who to talk to, and who to kill, from evidence
that isn't good enough.

#3520 — `_clear_port`/`stop`/`stop_ui` picked their victim purely by "who holds
the port" and SIGTERMed it. On a host where an unrelated service shared the
port, that service died with no indication of what killed it. A listener is now
only signalled once its command line identifies it as our daemon (or our control
plane); otherwise we log and refuse, and startup fails with "port in use"
instead. A failed start is recoverable; killing someone else's service is not.

#3517 — `_find_pid_on_port` shelled out to `lsof` only, so on Linux hosts
without it (minimal containers, Arch-based distros) every daemon stop logged
"Could not find PID for port" and stopped nothing. PID discovery now falls back
to `ss` (iproute2). It also returns every listener rather than an arbitrary
first PID, which is what lets the ownership check above pick the right one.

#3527 — `is_ui_running` health-checked 127.0.0.1 regardless of the bind
hostname. Next.js started with `--hostname localhost` binds ::1 only, so
`ui start` always timed out after 30s on a UI that was up and serving, and
`ui status` reported it as down. Both loopback families are now probed, in
`_is_port_in_use` and the Windows netstat parse as well, and user-facing URLs
say `localhost` so they resolve whichever way the server bound.

#3099 — the 2s /health client timeout classified a busy daemon as dead. /health
is served from the same event loop as the daemon's LLM calls, so a slow
provider stalls it. The probe budget is now 10s by default (aligned with the
worker-side liveness threshold) and configurable via
HINDSIGHT_EMBED_HEALTH_PROBE_TIMEOUT. The 30s reclaim grace window is unchanged.

#3100 — the Windows lock used `msvcrt.LK_LOCK`, which retries exactly 10 times
internally and then raises, so a concurrent start of the same profile failed
non-deterministically with an opaque OSError. Both platforms now drive the
non-blocking primitive from one bounded retry loop with backoff; on timeout the
error names the lock file and the PID holding it, and `_start_daemon` turns that
into a normal startup failure.

Not included: #3253 (empty llm_api_key clobbering an inherited env var) already
has a fix in the open PR #3359.

* fix(embed): keep the UI probe short and clean up the lock-owner sidecar

Two defects from the previous commit, found in review.

The UI health probe inherited HEALTH_PROBE_TIMEOUT (10s). That budget exists
for the daemon, whose /health sits behind the event loop its LLM calls run on
(#3099); the control plane's /api/health has nothing blocking behind it. Since
start_ui polls the probe inside a 30s budget and now probes two loopback
families, a listener that binds but does not answer would burn the whole budget
in two probes and report a false "UI failed to start (timeout)" — the exact
symptom #3527 is about. The UI keeps its own 2s probe.

delete_profile removed <name>.lock but not the <name>.lock.owner sidecar the
new locking writes, so a crash while holding the lock orphaned a file that
outlived the profile.

Also adds direct coverage for _process_command_line, which decides every kill
but was only reached through tests that patch it out, and documents that
_wait_for_port_health bounds when the last probe starts rather than when it
returns.

* fix(embed): scope the long health budget to the reclaim probe only

test-embed-windows failed on test_delete_profile_over_http with a client-side
ReadTimeout. The control center's delete handler asks is_running once and the
UI probe once per loopback family; at the 10s budget those three serial probes
could reach 14s against httpx's 5s default client timeout. The same path was
~4s before, so a slow connect that Windows already had was being masked.

The budgets are now split by what a wrong answer costs. HEALTH_PROBE_TIMEOUT
(10s, configurable) applies only to _port_health_ok — the probe whose false
negative gets the listener killed, which is what #3099 is actually about.
is_running and the UI probe use LIVENESS_PROBE_TIMEOUT (2s, the pre-existing
value): they only answer "is it up?", and a false negative there costs a re-run
of ensure_running, which consults the long probe before doing anything
destructive. #3099's real harm — a busy daemon being reclaimed as stale — stays
fixed.

Connect is capped separately at 1s. An address that swallows the SYN hangs in
connect rather than read, so this is what actually bounds the handler: three
probes at 1s is 3s, below both the 5s client default and the 4s the two
uncapped probes could reach before this branch.
2026-08-18 15:44:02 +02:00
Nicolò BoschiandSanderhoff-alt 2e2dfe1309 fix(consolidation): keep source dates when observations merge (#3500)
* fix(consolidation): keep source dates when observations merge

Both semantic-dedup folds rewrote only text/sources/proof_count, so an
observation could end up citing dated source facts while reporting no
event interval at all (#3477): the CREATE fold dropped the dates of the
CREATE it skipped, and the UPDATE fold dropped everything the row it
deletes knew. Both now widen the survivor's bounds, and the ordinary
UPDATE carries event_date through like the other three fields.

Diagnosis and the min/max contract come from #3482.

Co-authored-by: Sanderhoff-alt <[email protected]>

* fix(consolidation): make the observation date merge Oracle-safe

The LEAST/GREATEST widening in _execute_update_action also runs on Oracle
(writes_memory_rows_in_sql_for is true for the default store on both backends,
and the observation_sources sync below it is the Oracle-only branch of the same
function). Oracle returns NULL from LEAST/GREATEST as soon as ANY argument is
NULL, where PostgreSQL ignores NULL arguments.

COALESCE($n, col) only guards a NULL parameter, not a NULL column — so on Oracle
an observation with no occurred interval yet computed LEAST(NULL, <source date>)
= NULL and silently dropped the date it was told to inherit. That is exactly the
#3477 scenario, which meant the fix landed on PostgreSQL only.

Wrap each assignment in one more COALESCE. Provably a no-op on PostgreSQL: the
outer COALESCE fires only when LEAST/GREATEST yields NULL, which there requires
both operands to be NULL, and the fallback is then NULL too. The inner
COALESCE($n, col) is kept spelled exactly as before because the Oracle driver
shim keys its TIMESTAMP-TZ input-size hint off that pattern.

event_date and mentioned_at were never actually at risk (_create_observation_-
directly stamps both), but they take the same form so all four columns read
alike. The two dedup folds keep the plain idiom: _dedup_active disables dedup on
Oracle, so they are PostgreSQL-only by construction — noted in _TemporalBounds.

Adds an oracle-marked regression test for the NULL-column widening; the PG side
is already covered by test_consolidation_temporal_merge.py. Also tightens
_retain_fact to assert it stamped exactly one fact instead of returning an
arbitrary row from a multi-row UPDATE.

* test(ci): let the Oracle bootstrap run as a non-admin user

Every Oracle test has been erroring in session setup with ORA-01031. The
workflow provisions hindsight_test with a privileged account in a 'Setup Oracle
test user' step, then sets ORACLE_TEST_DSN to that same unprivileged user — so
the oracle_db_url fixture connects as hindsight_test and tries to CREATE USER
HINDSIGHT_TEST, which it has no privilege to do. Oracle checks privileges before
name conflicts, so it raises ORA-01031 rather than the ORA-01920 the fixture
tolerates, and the whole session dies before a single test runs.

Tolerate ORA-01031 the same way: it means the user was provisioned externally,
which is exactly what CI does. The GRANTs below already swallow it. If the user
really is missing, run_migrations() still fails loudly on login.

Unblocks test-api-oracle, which is label-gated and so had gone unnoticed.

* test(oracle): compare timestamps as instants, not naive vs aware

The new Oracle regression test proved the fix works — occurred_start came back
as 2020-03-01 where the unfixed statement would have left it NULL — but then
failed anyway: oracledb returns that column without a tzinfo, and comparing a
naive datetime to a UTC-aware one is silently False rather than an error.

Normalise through _as_utc before comparing.

---------

Co-authored-by: Sanderhoff-alt <[email protected]>
2026-08-18 15:33:09 +02:00
19318ac088 fix(engine): drop null metadata values at retain and recall (#3209) (#3531)
* fix(engine): drop null metadata values at retain and recall

Retain accepts arbitrary JSON metadata; a null value (e.g. {"ocr_engine":
null}) stored verbatim made every read path that returns MemoryFact fail
dict[str, str] validation — recall, consolidation, and mental-model refresh
errored for the affected bank. Normalize on both ends: RetainContent drops
null-valued keys at construction (canonical storage) and
MemoryFact.parse_metadata drops them for legacy rows, preserving the
existing string coercion for other non-string values.

Closes #3209

* fix(engine): normalize null metadata on the delta and export paths too (#3209)

Follow-up to the retain/recall normalization on this branch, which left two
gaps.

Delta retain never went through RetainContent: all three call sites of
update_memory_units_metadata_and_tags pass the raw retain_params bag straight
to the UPDATE, so a re-retain left the units it preserved carrying null-valued
keys while the units re-extracted beside them did not. Drop the nulls inside
that storage function — the one chokepoint every delta path goes through —
leaving documents.retain_params holding the caller's input verbatim.

Bank export was the other read path that validates stored metadata as
dict[str, str]: TransferFact is built directly from the row, so a bank already
holding a null (or a raw integer, e.g. {"original_id": 348}) failed export with
the same ValidationError the issue reports — locking an operator out of the one
operation that gets them off the bad data. It now applies the same read
contract as recall.

Both rules now live in engine/metadata_utils.py rather than being spelled out
at each site, and RetainContent normalizes an explicit "metadata": null to {}
so the field always matches its declared type.

Regression coverage on a real database: test_delta_retain_drops_null_metadata_values
walks a full retain, a metadata-only delta and a partial delta with surviving
units; test_export_tolerates_legacy_null_and_numeric_fact_metadata exports a
bank whose rows were poisoned before the fix existed. Both fail without their
respective fix.

---------

Co-authored-by: Nova Lux <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-18 15:19:11 +02:00
Nicolò Boschi e256a7409b feat(embeddings): asymmetric query/passage prefixes for text-in providers (#3570)
Asymmetric embedding models (E5, google/embeddinggemma-300m, ...) expect a different instruction in front of a search than in front of stored text. Providers that are plain text-in/vector-out have no other channel to carry that distinction, so the client has to prepend it.

Adds HINDSIGHT_API_EMBEDDINGS_QUERY_PREFIX / _PASSAGE_PREFIX, applied by the Embeddings base class and handed to every text-in provider: tei, openai, openai-codex, openrouter, requesty, litellm, litellm-sdk. Both default to empty, so unset means byte-identical payloads.

local and zeroentropy opt out by construction (they override encode_query/encode_documents with their native mechanism); onnx keeps its own pair with non-empty E5 defaults. A family guard test walks every text-in provider so a future branch can't silently drop the prefix on one of them.

Fixes #3514
2026-08-18 12:32:57 +02:00
Nicolò Boschi 0dd32a3d6d feat(knowledge-base): expose a page's refresh trigger on the tree (#3572)
* feat(knowledge-base): expose a page's refresh trigger on the tree

A page's trigger decides when it rebuilds itself and what that costs, and
it was write-only: nothing in the knowledge-base API returned it, so a
client that speaks only that surface -- the control-plane tree, the
coding-agents plugin -- could neither show a page's refresh policy nor
tell whether its own settings still applied. The only way to read one was
the mental-models API, one call per page.

`_KP_PAGE_SELECT` now carries `mm.trigger` (the join was already there,
for tags/source_query) and `KnowledgeNode` returns it: null on folders,
which have no backing mental model, and on a page with no trigger stored.

This closes the loop opened by making the trigger patchable: a client can
compare what a page has against what it wants and skip the write when they
already agree.

* fix(test): a folder's absent trigger is absent, not null

ExcludeNoneRoute drops null fields from every response whose model has no
required-nullable field, so a folder's trigger never reaches the client as
`null` -- exactly how is_stale behaves on folders one assertion above. The
field description says "absent" now rather than "null".

* refactor(knowledge-base): type the page trigger, don't hand back a dict

`KnowledgeNode.trigger` was the only `dict[str, Any]` trigger in the API.
Every other one -- including MentalModelResponse, which reads the same
column -- is a MentalModelTrigger, and a raw dict for structured data is
against the project's own type rules besides. Generated clients now get
MentalModelTriggerOutput instead of an untyped object.

* fix(test): assert the effective trigger, not an exact dict

Typing the field changed what a client sees: serializing through
MentalModelTrigger reports every setting the page never stored at that
model's default -- keep_trace=False, and refresh_after_consolidation=False
on a page moved onto a cron schedule (the engine stores no such key; false
is the same policy stated a different way, and TestPageDefaults still
asserts the storage-level shape).

So assert the fields that carry meaning instead of the whole object, which
would otherwise pin every future MentalModelTrigger field into this test.
The field description now says the returned trigger is the effective one,
so nobody compares it whole against a patch they sent.
2026-08-18 12:19:20 +02:00
Nicolò Boschi 27e4b188d7 fix(coding-agents): consolidate one set of observations per bank (#3575)
Every document this integration writes carries provenance tags (`source:chat`,
`harness:<id>`, `knowledge:<kind>`, anything from `retainTags`). Consolidation's
default `combined` scoping groups observations by a memory's WHOLE tag set, so
those tags become a consolidation boundary: work one repo with two agents and
the `harness:<id>` tag alone yields two parallel sets of beliefs that never
merge, each blind to the other, at double the consolidation cost (#3564).

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

New `observationScopes` config field (default `"shared"`) sets it, per bank via
`banks.<id>` like any behavioral field, or `HINDSIGHT_OBSERVATION_SCOPES` for
the scalar modes. `"combined"` restores the previous behaviour.
2026-08-18 12:01:50 +02:00
Nicolò Boschi a04f4eed3a fix(coding-agents): pin the bank to where the session started in a non-git tree (#3573)
Fixes #3563.

Outside a git repository `{gitProject}` fell through to the basename of the
agent's LIVE working directory. Inside a repo that is harmless — every
subdirectory resolves back to the root — but a plain directory tree has no root
to resolve to, so the bank id followed the agent as it `cd`d and ONE session was
retained into a bank per directory it stepped into: the same document, its facts
split across `coding-agent::2026-07-22` and `coding-agent::analysis`, and a
recall against either seeing only part of the history.

Resolve it from the directory the SESSION started in instead. `sessionRootDir`
records the first cwd any of a session's hooks reports and returns it for the
rest of the session, keyed on the session id rather than a harness-exported
project-root variable — every hook harness reports a session id, only Claude
Code exports a root, so this covers all seven the way `nearestExistingDir` does
rather than guessing at env var names. It lives in its own temp file for the
same reason the retain cursor does: the prompt hook rewrites the session cache
wholesale.

Deliberate boundaries, each pinned by a test: git resolution still runs first,
so a session started in a plain directory that then works inside a repo keeps
that repo's bank; `mapPathToBank` still overrides everything; and `{project}`
stays on the live cwd, since it is documented as the working-directory basename
and is the escape hatch for anyone who wants a bank per directory.

The persistent-plugin harnesses (dsh, opencode, Kilo, Cline, Prime Agent) need
no change — RuntimeCore captures projectDir once at construction. The Antigravity
status line cannot use a session root (its payload carries no conversation id);
that is commented at the call site and is display-only. `retainTags`
/`retainMetadata` take the session root too, so a stamped `{gitProject}` names
the project its bank id does.
2026-08-18 11:45:00 +02:00
Nick Old e181fb75c3 fix(memory-defense): use ASCII token boundaries so CJK-adjacent secrets are redacted (#3569)
Python's `re` compiles `\b` with Unicode semantics, so a CJK character counts
as a word character. There is no word boundary between `为` and `s`, which
meant `\bsk_test_...\b` silently failed to match in `凭证为sk_test_ABC…` and
secrets embedded in Chinese/Japanese/Korean prose reached memory units
unredacted.

Every boundary-based built-in pattern now uses explicit ASCII token
lookarounds instead. These are strictly more permissive than `\b`
(`[A-Za-z0-9_]` is a subset of `\w`), so no previously-detected secret stops
being detected, while a partial ASCII token still isn't matched. Patterns that
never used token boundaries (URLs, PEM blocks, named AWS assignments) are
unchanged.

A regression guard rejects `\b`/`\B`/`\w`/`\W` in any built-in pattern, so a
future detector can't reintroduce the bug.

Fixes #3566.
2026-08-18 11:35:25 +02:00
Ferran Vidal 3e6a812f71 fix(deps): bump google-genai past the Vertex AI eu/us multi-region fix (#3567) (#3568)
google-genai 1.53.0 built `{location}-aiplatform.googleapis.com` for every Vertex AI location, a host that does not exist for the multi-region codes "eu" and "us", so those regions 404'd on every call. Raise the floor to >=1.72.0 (the upstream fix, googleapis/python-genai#2498) and re-lock to 2.18.1, plus a regression test on the resolved endpoint per region kind.
2026-08-18 11:28:57 +02:00
Nicolò Boschi 6ea34d830f fix(coding-agents): scope knowledge pages to the repository they are about (#3571)
A bank collects everything said IN a repository, which is not the same as
everything said ABOUT it. A repo that reads its dependency's source, drafts its
upstream issues or documents how it configures a service files those facts here
too — correctly, since that is where the work happened.

Nothing downstream could tell the two apart. Attribution tags (project:,
harness:, workspace:) record where a fact ARRIVED from, never what it is ABOUT,
and the knowledge:<tier> labels say what KIND of knowledge it is, never whose.
By synthesis time the source document is gone and the fact reads as a bare
technical decision. So "what are this project's key decisions?" was answered
over everything the bank held, and a dependency's decisions were presented as
the repo's own — upstream commit SHAs and all (#3476).

Name the repository in every seeded page's source query and state the
exclusion, so the synthesizer can make that call while it still has the fact's
text in front of it. seedPages() already PATCHes a drifted query, so this
re-syncs onto banks seeded by an earlier version rather than only new ones —
which is why it rides on source_query and not the bank's reflect_mission, which
is seeded ONCE and then belongs to whoever set it (#2492).

Note the queries change, so the next refresh falls out of delta into one full
rebuild per page — which is what re-cleans already-polluted pages.
2026-08-18 11:20:30 +02:00
Nicolò Boschi f8b3988cf8 feat(coding-agents): make the knowledge-page refresh trigger configurable (#3545)
* feat(coding-agents): make the knowledge-page refresh trigger configurable

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

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

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

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

Closes #3506.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two CI misses from the paging change:

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

Four changes, none of which add a coordination mechanism:

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

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

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

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

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

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

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

* docs: state why Azure hosts opt out of prompt_cache_key

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #3499

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

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

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

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

Default is true — unchanged behaviour.

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

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

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

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

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

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

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

Committed with --no-verify: the pre-commit hook's eslint step cannot run in a
fresh worktree without node_modules. The Python lints it would have run pass
(ruff check, ruff format, ty).
2026-08-14 16:11:28 +02:00