You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This commit was created on GitHub.com and signed with GitHub’s verified signature.
Added
The Z0 runner now guards every memory surface and saves each graded cell for regrading. Before each session it hashes every memory surface into ledger.jsonl, and a plan-limit retry puts all of them back, store and auto memory included, with surfaceRestored on the record. A session that runs out of time is a graded record priced from its transcript, with turnsSource naming the unit. Gate G1 sets void when the agent reads another run's or the operator's memory, or when memory reached an arm that should have none. Gate G3 searches every surface, the workspace and the prompt for the key phrase of a lesson not yet taught; a hit voids the rest of that sequence and seed in every arm, and preflight refuses phrases in rules, reasons and allowed prompts. Valid applies carry the failure chain (stored, shown, followed, captured), and A2 and A5 records count the hippo rows the hook injected by source. Each valid cell writes a git bundle, reader diffs without instruction files, grade.json and, for applies, the surface text to <out>/grading/.
The delivery ledger now records the two compaction hooks. With deliveryLedger.enabled on, hippo pre-compact and hippo compact-resume each write one boundary row to delivery_events, so a reader sees where the context was reset between two prompt rows. A repeat fire of one hook within two seconds becomes a duplicate row. A missing boundary row is a gap, not proof that no compaction happened. The delivery ledger stays off by default, and what both hooks print, save and exit with is unchanged.
Changed
The compactions SQL now lives in src/store/compactions.ts. Internal only: compaction-record.ts and project-merge.ts call named store functions instead of preparing statements; behaviour is unchanged.
Internal: eleven SQL statements in doctor, audit-prune, reject, half-life migration, invalidation and dormant restore now live in the store modules that own their tables.
The token ledger and pilot arm SQL now lives in src/store/token-ledger-rows.ts. Statements, order and results are unchanged. Internal only.
Internal: connector writes run through a store group. A Slack or GitHub write and its event log row go through an optional connectorWrites group on the store port, so no database handle crosses the api contract. No behaviour changes on hippo.db.
Internal: connector ingest and deletion run on the request's store. The Slack and GitHub entry points read the event log, tenant routing and the dead-letter queue through an optional connectorEvents group on the store port, so a served store that has both connector groups takes webhook traffic. No behaviour changes on hippo.db.
Internal: fifteen graph, reranker, hook, eval and API helpers that only tests used are no longer exported.
Internal: the test-only export ratchet no longer counts exports that production code uses.scripts/check-test-only-exports.mjs now skips an export that a file under scripts/ or benchmarks/ imports, and a pure function or class that its own module calls; the baseline falls from 178 to 104. No runtime behaviour changes.
Thirteen src/store helpers are no longer exported only for tests. Internal only: the tests now enter through the public store functions; behaviour is unchanged.
Internal: 12 CLI, MCP, dashboard and capture helpers that only tests imported are no longer exported; no behaviour change.
Internal: a new push to a pull request now cancels the CI and token-eval runs of the commit it replaced.
Dependabot now watches the website's packages too.website/ gets the same monthly grouped pull request for minor and patch releases as the root and ui/. extensions/openclaw-plugin/ names only a peer dependency on hippo-memory itself, so it needs no entry.
The layer order is now enforced in CI.layers.json maps every src/ folder and root file to one of six layers, and scripts/check-layers.mjs fails when a file imports from a higher layer than its own. Existing upward imports are frozen in .layers-baseline.json and can only shrink. No runtime behaviour changes.
The size ratchet now scans scripts/ as well as src/. Existing offenders under scripts/ are frozen in .size-baseline.json and can only shrink. No runtime behaviour changes.
selftestLabeller in scripts/z6-supersession-eval.mjs is split into nine named steps.--selftest output is byte-identical. No runtime behaviour changes.
New production exports that only tests use are now blocked in CI.scripts/check-test-only-exports.mjs lists every src/ export that no other src/ file names but a test does, and fails on any not in .test-only-exports-baseline.json. The baseline can only shrink. No runtime behaviour changes.
The CLI object verbs share one list, status and flag path. Internal only: process, policy, skill, brief, note, decide and incident now use src/cli/object-verbs.ts, so the "Invalid --status" message and the empty-string flag read each live in one place. Output is unchanged.
Fact extraction, DAG summaries and hippo refine now send their Anthropic Messages requests through one client in src/util/anthropic-messages.ts. Request bytes, results and failure messages are unchanged. Internal only.
A flag that a command does not use now prints a warning.hippo recall "deploy" --archive still runs and exits as before, and prints hippo: ignoring unknown flag --archive. A later release will reject it. on stderr. Nothing is refused that was accepted before, with one exception: hippo last-sleep --keep=<value> and hippo embed --status=<value> (or a bare true or false after either flag) now stop with takes no value, as every other switch already did. Before, --keep=false kept the log and --status=false showed the status.
Internal: gave a name to the bare numeric defaults and text widths in src/ (page sizes, preview widths, id and digest lengths, decimals). No behaviour change; every value is the same.
Internal: CI now runs CodeQL and gitleaks on every push and pull request, and the dependency audit gate is at moderate.
The session-end and post-compact hooks now read their payload through the capture contract's payload readers, with conformance fixtures. A session-end payload that starts with a byte-order mark is now read instead of dropped, and hippo post-compact logs that no payload arrived when stdin timed out.
delivery_events.ledger_version is now 2. It means a binary that can write boundary rows wrote the row, and event_type has four values: prompt-submit, pinned-manual, pre-compact and compact-resume. There is no schema change.
The Slack and GitHub connectors now park failed webhooks through one dead-letter module.src/connectors/dlq.ts holds the payload redaction, the unroutable-tenant value, the defaults and the replay result that each connector used to copy. Internal only, no behaviour change.
Internal: shared helpers instead of hand-written copies. Caught-error text, JSON type guards and the path canonicaliser each have one definition; no user-visible change.
Internal: more caught-error text goes through one helper. Five more call sites use errorMessage(), four config guards use the shared JSON guard, and a CI check stops new hand-spelled error text. No user-visible change.
Internal: CLI flags are read through named readers in src/cli/shared.ts instead of inline typeof ternaries and casts; behaviour is unchanged.
Long functions in the CLI are split into named steps. Thirty-two functions in src/cli.ts and src/cli/ that ran past 50 lines are cut into smaller same-file helpers. Output, exit codes and side-effect order are unchanged.
Internal: no function in db, store, graph, server, mcp or dashboard runs past 50 lines, apart from four that other work is editing. Migration SQL moved into module-level constants with the text unchanged; the stored schema is identical. No behaviour change.
Seventeen long functions in sleep, capture, the GitHub connector, the hooks, the eval harness and the API are split into named steps. Each was over 50 lines and now reads as load, decide, act, so a reviewer can check one step at a time. Internal only, no behaviour change.
Eleven more long functions in the importers, the hook installers, the Slack and GitHub connectors, the context read and the sleep physics pass are split into named steps. Each was over 50 lines. The physics pass and the GitHub dead-letter replay had no test, so tests now run each on a real store and pin what it writes. Internal only, no behaviour change.
Internal: ten long functions in src/ are split into named steps and the size ratchet now holds src/ to 50 lines per function; no behaviour change.
Internal: ranking weights are named constants, and the last long positional signatures take options objects. The scope boost is one shared function; audit, transitionCard, rescueSet, applyGraphOps, renameEntrySourceAndOriginAt and the server request handler take options objects. No behaviour change.
hippo serve now answers every /v1 and /mcp request by a deadline. A request still running after 120 s gets 504 with { "error", "code": "deadline_exceeded", "requestId" }. Set HIPPO_REQUEST_DEADLINE_MS to change the deadline, or to 0 to turn it off. POST /v1/sleep and GET /mcp/stream keep their own limits. For a predictions write the error text says whether the write was stopped with nothing saved, or whether it may or may not be saved.
A full store queue now refuses new work at once. Each store worker thread lets 256 calls wait (HIPPO_STORE_QUEUE_MAX). The next call gets the same 503 with Retry-After: 1 as a busy store, and so does a call still waiting in the queue at its request's deadline.
POST /v1/sleep now refuses a third concurrent sleep. One sleep runs and one waits. A further call gets 503 with Retry-After: 30 at once.
The loopback GET /health body has four new counts:store_queue_refusals, store_jobs_expired, store_workers_replaced and handler_deadlines.
hippo serve now runs the SQLite work of nine more routes on worker threads, and reads the API key row for every request on a reader thread. The routes are POST /v1/memories, the archive, supersede and delete routes under /v1/memories/:id, GET /v1/sessions/:id/assemble, the three /v1/auth/keys routes and GET /v1/audit. A write that waits for the write lock no longer stops the server answering other requests. Statuses, bodies, headers, audit rows and mirror files are the same for a request that finishes inside its deadline. These routes and the key read now come under the store queue bound (a 503 with Retry-After) and the request deadline (a 504) that the predictions routes already had. Internal otherwise.
hippo serve now answers recall, the graph and the quarantine routes from worker threads. The routes are GET /v1/memories, GET /v1/graph, GET /v1/quarantine and the approve and reject routes under /v1/quarantine/:id. Their reads run on reader threads and their writes on the writer thread, so the server thread runs no SQL for them and one that waits for the write lock no longer stops the server answering other requests. The store's first-open setup (the half-life base record, the import of legacy markdown, the mirror folders) now runs once on the writer thread before any reader answers. Statuses, bodies, headers, audit rows and mirror files are the same for a request that finishes inside its deadline. These routes now come under the store queue bound (a 503 with Retry-After) and the request deadline (a 504). Legacy markdown that appears in a still empty store after the server has started is imported by the next write, not by a read. Internal otherwise.
hippo serve now runs the SQLite work of the five /v1/predictions routes on worker threads. One writer thread and two reader threads start on first use, so a predictions write that waits for the write lock no longer stops the server answering other requests. Statuses, bodies, headers and audit rows are the same. Internal only, no runtime behaviour changes.
Internal: nine places in the API, MCP, consolidate and vault-import code that opened hippo.db for one read or one audit row now call the store layer, which opens it for them.
Internal: reject, unreject and the rejections list no longer open hippo.db from the reject flow; one store module opens it, and each still runs on a single handle and write scope.
Internal: the token ledger rows of the prompt hook, hippo context and the session hooks, the CLI ranking core's goal boost and the session owner binding now open hippo.db inside the store modules that own their tables.
Internal: hippo doctor's database checks, hippo support-bundle, the dashboard snapshot service and hippo audit prune no longer open hippo.db themselves; the data layer opens it and hands back plain data. No output or behaviour changes.
Internal: seven hippo CLI functions (auth list, embed --reset-physics, the status physics line, slack workspaces add|list|remove and the CLI audit write) no longer open hippo.db themselves; the data layer opens it for them. No output or behaviour changes.
Internal: sleep, dedup and the half-life migration no longer open hippo.db themselves. The chunked flush, dormant expiry, rescue audit rows, tombstone checks, dedup deletes and the half-life move call store functions that own the handle; behaviour is unchanged.
Internal: hippo import and the vault import no longer open the database themselves; they write and probe the rejection guard through store functions, on one shared handle per import as before.
Internal: invalidation no longer opens the database itself; its batch write, its churn tagging and its confirmed-outcome read are store functions in src/store/entry-writes.ts and src/store/audit.ts, and its three functions over 50 lines are split into named helpers. No behaviour change.
Internal: the token and failure summaries and the dormant list, restore, forget and check no longer open hippo.db from the API modules; store modules open it, and a restore still commits on a single handle and write scope.
hippo learn reads the store once per batch, on one open handle. It reloaded every row of the tenant for each lesson that names a migration and opened the store again for each write; lessons, invalidations and counts are unchanged.
hippo remember, hippo capture and hippo watch no longer load every row of the tenant on each write. Schema fit reads tag counts from one aggregate and streams one column, the salience gate reads only its window, and capture's copy check reads only rows holding an item's longest word; output is unchanged.
Strengthening recalled memories reads their rows in one query. It ran one read and one prepared update per id.
The nearest-vector scan no longer holds the event loop for its whole run. It scores 256 rows, then lets other work run, so a server answering a hybrid recall over 10,000 vectors blocks for about 2 ms at a time instead of about 35 ms. Ids, order, scores and tie-breaks are unchanged.
A query that matches nothing now costs the same in a store of any size. The substring (LIKE) match behind FTS5 tested every row of the tenant; it now tests the newest 2,000 admitted rows. At 10,000 rows a no-match query tests 2,000 rows instead of 10,000.
Behaviour change: in a store with more than 2,000 admitted rows, a row that matches only as a part-word (caf in cafe) is found only if it is among the newest 2,000. Whole-word matches go through FTS5 and still cover the whole store. A store where FTS5 cannot run searches its newest 2,000 rows only.
A new connection no longer scans the whole archive for leftover mirror files. It reads the archive's highest row id, which is one index seek, and runs the scan only on a store's first connection in the process, when a row was archived since, or while an earlier clean-up of a mirror file failed.
openStore makes the four mirror folders on a store's first open in the process, where it made them on every open. A writer still makes its own folder if one is missing, now owner-only (0700) like the folders made at open; this includes trace/, which was made with the process default mode.
A DAG cluster's members are re-linked in one transaction. Each member still gets its audit row and its mirror file. If one member's write is refused, none of that cluster's members is linked; before, the members written ahead of the refusal stayed linked.
A served store copies its WAL into hippo.db about every 32 responses, where it was about every 3.synchronous = NORMAL is unchanged, and a crash of the process still loses nothing. After a power loss, the commits since the last checkpoint can be lost, as before; that window is now about 1,100 WAL pages, close to SQLite's default of 1,000. The CLI and the stdio MCP server checkpoint as before.
The WAL of a served store can grow to 4,000 pages (16 MB) under constant load. At that size a request checkpoints the WAL itself, as every request did at 100 pages before. hippo serve still checkpoints and removes the WAL when it stops.
A context query and an outcome for the last recall no longer read the whole memory index. At 10,000 memories per store a getContext with a query read about 10,500 rows and now reads about 540; outcome with no ids read about 10,450 and now reads about 450. Reads of a list of ids for one tenant now seek each id on the primary key, where SQLite used to walk every row of the tenant. config.json is parsed once per file version: an unchanged file costs one stat per call, and an edit shows on the next call. A warning about a bad config.json is now logged once per file version, not on every read. Responses are unchanged.
Internal: 16 hand-rolled write transactions now run through withWriteScope. Index, physics, audit-prune, snapshot, prediction, conflict, token-ledger and trace writes share one begin, commit and rollback path. A write that starts inside an open transaction now joins it as a savepoint instead of failing.
Every transaction start now goes through src/db/busy.ts. Internal only: read snapshots, dry runs and the lock-waiting writes use withReadSnapshot, the new withTrialScope and withWriteScope's optional busyWaitMs; behaviour on success is unchanged.
Internal: 14 small SQL statements moved into the store layer. Session owners, half-life migration, project merge and agent-memory sync call store functions instead of preparing SQL. No behaviour changes.
Quality repair, the vault importer, the dashboard snapshot and the support bundle no longer hold SQL. Internal move of seven queries into src/store and src/db; behaviour is unchanged.
The Slack connector's SQL now lives in src/store/connectors/slack.ts. Internal: the connector files call store functions with plain data and no longer open hippo.db themselves. No behaviour change.
Graph extraction sources are typed without any. Internal refactor of src/graph-extract.ts; extraction output is unchanged.
Decisions and project briefs share one close, load-by-id and list. Internal refactor: the new src/objects/ holds the lifecycle once and each type describes itself as data; the other five typed objects follow. Function names, signatures, error text and audit rows are unchanged. One ordering change for direct SDK callers: loadDecisions with an unknown status now throws before the store is opened, as loadProjectBriefs already did (the HTTP route checks the status first, so its replies are unchanged).
Decisions and project briefs share one save and supersede. Internal refactor: src/objects/lifecycle.ts now holds the write half (preflight, insert, supersede, audit rows and the mirror memory) and each type describes its columns and audit keys as data; src/objects/fields.ts holds the two field checks. Function names, signatures, error text, stored rows, mirror memories and audit rows are unchanged. Policies, customer notes, processes and skills follow.
Policies and customer notes use the shared typed-object save, close, load and list. Internal refactor: each type now describes its columns and audit keys as data for src/objects/lifecycle.ts. Function names, signatures, error text, stored rows, mirror memories and audit rows are unchanged. One ordering change for direct SDK callers: loadPolicies with an unknown status now throws before the store is opened, as loadCustomerNotes already did (the HTTP route and the CLI check the status first, so their replies are unchanged).
Processes and skills save, close, load and list through the shared object core. Internal refactor of src/processes.ts and src/skills.ts; replies, stored rows and audit events are unchanged.
Incidents close, load and list through the shared typed-object core. Internal refactor of src/incidents.ts; replies, error text and audit rows are unchanged.
The policy, customer-note, process and skill routes share one list, get, close and supersede handler.src/server/routes/object-routes.ts holds the four handlers and the two body-field checks; each route file passes one config. No HTTP reply changes: status codes, error text and the order of the checks are the same.
The decision, incident and project-brief routes use the shared list, get and close handlers. All seven typed-object route files now pass one config to src/server/routes/object-routes.ts; the project-brief supersede uses the shared one too. The decision supersede stays in its own file because it answers 201 and leaves the missing-row check to the store. No HTTP reply changes: status codes, error text and the order of the checks are the same.
Internal: the auto-sleep rule lives in src/api/auto-sleep.ts. The MCP remember tool and the session-close hook now call one module for when a write starts a sleep. No behaviour change.
Twenty hand-written BEGIN IMMEDIATE blocks now run through withWriteScope, and the ones that roll back and return a value through the new withWriteScopeOr, both in src/db/busy.ts. Internal; no behaviour change.
Internal: the three quarantine routes run through a store group.GET /v1/quarantine and the approve and reject routes read and write through an optional quarantine group on the store port, so a server on another store can answer them. No behaviour changes on hippo.db.
The five /v1/predictions routes run on a new optional predictions store group. A served store that has the group answers them; one without it answers 501 after auth. The baserate arithmetic is one pure function, predictionBaserateOf, that any store calls on its own rows. A prediction's mirror memory now goes in through the tenant-checked entry write, which refuses an id another tenant holds; mirror ids are random, so no caller reaches that. Internal only, no runtime behaviour changes.
The MCP tools hippo_predict_baserate, hippo_assemble and hippo_drill run on a served store that has their group. The baserate tool reads through the predictions group, the other two are marked for dagReads, and the baserate read now states ORDER BY id, the order its index already gave. Internal only, no runtime behaviour changes.
The typed-object routes run through a store group. A new optional objects group on the store port serves the list, get, close, save and supersede routes of decisions, processes, policies, skills, project briefs and customer notes, and the list, get and close routes of incidents. The save commits the mirror memory, the object row, the successor link and the audit rows in one transaction inside the store. Internal: no response, audit row or CLI output changes.
The remaining typed-object routes run through the store group. Incident open and resolve, the policies as-of read, the skills export and the project-brief refresh reach hippo.db through the objects store group, so every object route answers from a served store. Internal: no response, audit row or CLI output changes.
Internal: the graph route runs through a store group.GET /v1/graph reads its entities and relations through an optional graphReads group on the store port, so a server on another store can answer it. No behaviour changes on hippo.db.
Internal: the graph write and queue SQL sits in the store layer. The statements that write entities, relations and graph_extraction_queue, and the reads a graph rebuild diffs against, moved from src/graph to src/store. No behaviour changes.
Session assembly and recall drill read through a store group.GET /v1/sessions/:id/assemble and GET /v1/recall/drill/:id now read through the optional dagReads group of the store port, so a store other than hippo.db can serve them. Internal: replies, CLI output and SQL are unchanged.
The audit list reads through a store group.GET /v1/audit now reads through the optional auditLog group of the store port, so a store other than hippo.db can serve it. Internal: replies, CLI output and SQL are unchanged.
Internal: nine long functions in the MCP stream, card store, quarantine CLI, config and embedding code are split into named steps of 50 lines or fewer; no behaviour change.
Internal: split thirteen recall, graph, decay and extraction functions into named steps of 50 lines or fewer; no behaviour change.
Four internal modules moved down to the layer that uses them. The agent-memory tool list, the search result types and the hook blocks now live in src/core/ and src/hooks/, and the failure outcome types sit with the failure log. Six upward imports are gone from .layers-baseline.json. No behaviour changes.
Two more internal modules moved down a layer. The blocked-SQLite error now lives in src/util/ and the session-digest tag and row check in src/core/. Two upward imports are gone from .layers-baseline.json. No behaviour changes.
The day length and the strength coefficients are named once. Every inline milliseconds-per-day literal now reads DAY_MS, and calculateStrength and strengthSql share one set of coefficients. No behaviour change.
Recall, graph, compaction and agent-memory helpers take one options object instead of up to nine positional arguments. Internal only; the published loadRecallSearchEntries keeps its signature. No behaviour change.
hippo recall and hippo explain now rank and record through retrieve(), the entry HTTP and MCP already use. The CLI kept its own copy of the recall steps (rank, audit, strengthen, trace, stats, token ledger), so a fix to one surface could miss another. Its ranker is now a named option of the shared entry, and output is unchanged: every recall golden matches byte for byte.
A hippo recall whose audit rows cannot be written no longer strengthens, traces or counts that recall. It still prints its result and logs audit write failed. Before, the CLI wrote each audit row on its own and carried on, so a refused row left a half-recorded recall; HTTP and MCP already recorded all or nothing.
A failed token ledger write on hippo recall is now logged. It was silent unless the store was busy.
19 root files moved into their layer folders. Three into src/db/, six into the new src/dashboard/, six into src/cli/ and four into the new src/eval/. No behaviour changes.
12 table modules moved from the src/ root into src/store/. audit, auth, dormant, failure-log, goals, raw-archive, recall-trace, rejection, summary-dirty and working-memory keep their names; ambient-store is now store/ambient and store-cards is store/cards. No behaviour changes.
The remaining src/ root modules moved into their layer folders (util, core, db, store, api, graph, objects, consolidate, search, learn, trust, sharing, capture, cli, server). Only the entry files, doctor and support-bundle stay at the root. No behaviour changes.
The HTTP route table and its dispatch helpers now live in src/server/route-table.ts, and the server boot in src/server/boot.ts.src/server.ts is only the published hippo-memory/server export block. No exports or runtime behaviour change.
Internal: the CLI flag readers, text printers, hook helpers and install steps moved from src/cli/shared.ts into flag-values.ts, print.ts, hook-runtime.ts and install-steps.ts; no behaviour change.
An unwritable audit log now fails hippo auth create and hippo auth revoke instead of being skipped. The key write and its audit row commit together with or without a served store, so a failed audit write leaves no new key and leaves the key being revoked live. authCreate and authRevoke on a context with no store throw the same error.
An unwritable audit log now fails hippo auth grant and hippo auth ungrant too. The grant change and its audit row commit together, and the key is checked under the write lock, so a failed audit write leaves the grants as they were.
Each key operation has one implementation.authCreate, authCreateSelf, authList, authListRows and authRevoke run one body on the served store or on hippo.db, through the new onStore carrier in src/api/on-store.ts. Exported names and signatures are unchanged.
getContext records a recall on one path. The hippo.db and store-served record steps are one body on the store carrier, and the store-port ratchet now counts zero twin functions. The trace is written before the rows are strengthened, on one database handle.
A context call no longer reads every memory row before it saves its last recall. With 10,000 memories a hippo context call with no query reads about 4,100 rows where it read about 14,100.
A context call strengthens only the calling tenant's rows. A row another tenant holds under the same id as a returned global row is no longer counted as retrieved.
A failed trace write on an empty context reply logs one message. A store that cannot be opened now logs recall trace write failed, where it logged recall trace connection failed.
forget with no served store now checks reach and deletes under one write lock. It used one database handle for the reach check and a second for the delete, so another writer could change the row between them. The check and the delete now share one handle and one transaction, as they already did through a served store.
archiveRaw with no served store now checks reach inside the write lock. The reach check and the read of the raw row ran before the lock was taken. Both now run inside the archive's transaction, with or without an afterArchive hook.
forget, supersede and archiveRaw each have one implementation. One body runs on the served store or on hippo.db through the onStore carrier. A served store still refuses afterArchive, because the hook writes on hippo.db's own handle inside the archive's transaction. Exported names and signatures are unchanged.
The store-port ratchet now counts the carrier's own store branch.scripts/check-store-port.mjs counts a ternary on a local named store as well as on ctx.store, so the one branch left in src/api/on-store.ts shows in storeBranches.
An outcome over several ids now commits as one transaction on the CLI and SDK path.hippo outcome, the dashboard and outcome on a context with no store used to commit each row alone, so a row refused midway left the earlier rows changed. Now every row and its audit row commit together or not at all, as they already did through a served store. The link to the recall trace is still written after the commit.
A refused outcome leaves its reject_refusal audit row through a served store too.POST /v1/outcome used to roll the outcome back and record nothing; it now records the refusal the same way the CLI does.
remember with no store refuses an id that another tenant already holds. It throws ConflictError and leaves that tenant's row as it was, where it used to move the row to the writer. A served store already refused it.
A write through a store with an empty hippoRoot uses the built-in half-life.loadConfig('') returns the defaults, where it used to read config.json from the process's working folder.
remember through a served store checks the input before it refuses a connector option. A call that sends afterWrite or untrusted with invalid input now rejects with the input error; with valid input it is refused with the same message as before.
remember, outcome and outcomeForLastRecall each have one implementation. They run one body on the served store or on hippo.db through onStore. Exported names and signatures are unchanged.
The store port interface and its SQLite adapter now live in separate files.src/store/port.ts holds the interface and src/store/sqlite/store.ts the adapter; src/store-port.ts re-exports the same names. No behaviour change.
CI no longer fails on a slow runner's clock. The recall latency step reports p50, p95 and p99 to the job summary and gates nothing; the request-path step gates the same HTTP recall on statements run, rows read and store opens. Six test files swap elapsed-time assertions for counts, query plans or a clock the test owns. Internal only, no runtime behaviour changes.
Secret scrubbing, injection screening, the id, cursor and argv parsers, recall ordering and the store error codec are now also tested on generated inputs.tests/_helpers/property.ts draws them from fixed seeds and reports the smallest failing input. The tests are internal; the four gaps they found are listed under Fixed.
Test timeouts now fit the test. The suite runs as two vitest projects: unit at 5 s per test and 10 s per hook, and process at 30 s for files that start a process, a worker thread or the HTTP server. Which project a file joins is read from its source when the config loads. Seventeen files that are slow for a reason their own source does not show set a longer timeout at the top, each with the reason. Four files got faster instead: the Z0 analyzer tests run 200 bootstrap resamples where they ran 2,000 and skip a report block they never read, and the write loops of three files run on one connection. A hung in-memory test now reports in 5 s, where it took 30. Internal only, no runtime behaviour changes.
Internal: no test asserts an upper bound on elapsed time any more. Ten such bounds now assert what they stood for (the wait a process asked for, a pause count, a log line, a fake clock), a scan test keeps the count at zero, the supersede and Slack race tests run the production statements from two connections, the coverage provider's re-keying has unit tests, and seven token-eval test files join npm test while the publish workflow now requires the four slow ones.
Test files and suite titles name the behaviour they pin. 52 test files lose their roadmap, pull-request and release-version prefixes (tests/v039-server-hardening.test.ts is now tests/server-hardening.test.ts), and 231 top-level describe titles lose the same codes. Internal: no test body changed.
HIPPO_TEST_DELIVERY_FAULT and HIPPO_FORCE_LIKE_PATH are no longer read. Both were test switches that a shipped CLI or server picked up from its environment. Tests now set them through an in-process hook, so nothing in the environment can inject a delivery ledger fault or reroute a search.
A release tag now publishes to npm only after the whole CI workflow passes on the tagged commit.npm-publish.yml calls ci.yml and the publish job waits for it, so a red Windows, macOS, Node-floor or coverage job stops the release. Before, a tag published after one Ubuntu run of the release gate.
Nine tests no longer fail on a slow runner. Their upper bounds on elapsed time are now counts of statements, lock waits and pauses, or a ratio against a baseline timed in the same run. The offline HIPPO_MODEL_CACHE test now runs in the CI job that has the model, and fails there when the model folder is missing. No runtime behaviour changes.
Test files now share one helper to spawn the built CLI.tests/_helpers/spawn-hippo.ts replaces 49 local copies. Internal only, no runtime behaviour changes.
Fixed
A revoked API key stops working on the very next request.hippo serve used to trust a key it had checked for up to 60 seconds, so a revoke, an expiry, a role change or a scope grant made by the CLI or another server process landed up to a minute late. The server now reads the key's row on every request and remembers only that a secret matched its stored hash. A valid key gets the same answer as before; each authenticated request costs one extra indexed read.
Checking an API key no longer holds up other callers. The key hash is derived on the thread pool, at most two at a time with eight waiting, so the server keeps answering while a key is checked. A key the server has already checked since it started is not derived again.
One address gets five tries at one key id, then one more every 12 seconds. Past that, a key check from that address for that key id answers 429 with {"error":"too many key checks from this address"} and Retry-After: 12, and no hash is derived. Other addresses, other keys and a key the server has already checked are not affected.
A key check that finds two derivations running and eight waiting answers 429 with {"error":"rate limit exceeded"} and Retry-After: 1. This can reach a real caller when more than ten keys are checked for the first time at the same moment, for example right after a restart; a retry one second later passes.
A hostile compaction summary can no longer stall the PostCompact hook. Three patterns that read the "Memories for hippo" list took time that grew with the square of the input: a summary of repeated unclosed <analysis> tags, a line of tabs, or a list item padded with spaces could hold the hook for a minute. Each is now read in one pass, and ordinary summaries give the same items as before.
The store worker error codec reads thrown-value text through errorMessage. Internal only: it clears the error-text check on master, and the text is unchanged.
A crash or a full disk in the middle of a config write no longer leaves a truncated file.opencode.json, the opencode plugin file, the workspace registry, copilot-instructions.md, the VS Code instructions file, the Codex wrapper files and the AGENTS.md / CLAUDE.md hook blocks were written in place, so an interrupted write left half a file that the tool could not parse. They now go through the temp-file-and-rename helper settings.json already used, so a reader sees the old file or the new one.
A best-effort step that fails now says so. The failed-tool capture hook, the pilot arm, the token ledger and 25 other best-effort steps (session-end capture and sleep, the Codex wrapper repair, vault import, physics state, log-file writes) caught their error and dropped it, so a store that had been broken for weeks left no trace. Each still carries on as before, and now writes one line with the error message: a warning where the user loses something (a failure that was not captured, a Codex wrapper that could not be made executable), a debug line (HIPPO_LOG=debug) elsewhere.
Every log line says when it was written and which request it was written for. A [hippo] level: message line carried no time, and only the handful of lines that were handed a request id by hand carried one, so a warning from deep inside a recall could not be tied to the call that caused it. Each line now ends its message with ts=<ISO time>, and every line written while hippo serve, the dashboard or an MCP stdio call handles a request also carries requestId=<id>, the same id the X-Request-Id response header returns. HIPPO_LOG_FORMAT=json writes each line as one JSON object (ts, level, msg, then the fields) for a log collector; the text line stays the default. A script that matches a whole log line to its end needs to allow the new fields.
A client that sends headers and then never sends the body can no longer hold a request open. Only the key-mint route had a body deadline; POST /mcp, every /v1 route that reads a body, the Slack and GitHub webhooks and the dashboard's action routes waited for as long as the socket stayed open. The shared body reader now has a deadline by default: 30 seconds, or HIPPO_BODY_TIMEOUT_MS. A body that misses it gets 408 Request Timeout and the socket is closed. The key-mint route keeps its own 10 second deadline.
A dropped connection or a timed-out attempt is now retried where a replay is safe.fetchWithRetry retried only 429 and 5xx answers, so one ECONNRESET, ETIMEDOUT, EAI_AGAIN or socket hang-up failed the call outright. GET and HEAD now retry those faults with the same backoff and attempt cap; a POST does so only when its caller says a replay cannot commit twice, and a refused connection or the caller's own abort is never retried. The CLI's write requests are unchanged: a POST that lost its connection is still not replayed.
The remote rerankers retry, and say when they stop and start working. The llm, jev and clef rerankers each made one unretried request and warned once per process, so a long-running server gave no sign of a later outage or of a recovery. They now share one retried request bounded by the configured timeout for the whole call. A failing reranker, the local cross-encoder included, warns when it starts failing, at most every five minutes while it keeps failing, and once when it answers again.
hippo serve no longer dies silently on a listener error or an uncaught throw. Once the port was bound the server had no error listener, so an accept failure such as running out of file descriptors became an uncaught exception and stopped the server for every caller with a bare stack trace. The listener now stays for the life of the server and logs serve: listener error: ..., and the server keeps answering. An uncaught exception or unhandled rejection in hippo serve now logs one [hippo] error: serve uncaught exception: ... line with the stack, drains requests already running through the same graceful stop a SIGTERM uses, removes the pidfile and exits 1 for the supervisor to restart it. hippo mcp and hippo dashboard share the same handlers; a dashboard port that is already in use now ends in one log line and exit 1.
hippo serve keeps answering while POST /v1/sleep runs. The route ran the whole consolidation inside the serving process, so every other request, GET /health included, waited until the sleep was over. The sleep now runs in a child process and the route waits for its answer; the response body, the status codes and the busy 503 are unchanged. A sleep that has not finished after 10 minutes (or HIPPO_SLEEP_TIMEOUT_MS) is stopped and answered with 504; the next sleep carries on from where it stopped.
hippo daily-runner exits 1 when any workspace step fails. It printed the failure count and exited 0, so a scheduler saw a clean run. The exit code is now 1 on a failed learn or sleep in any workspace, and 0 otherwise.
hippo daily-runner stops a child step that runs past 30 minutes. One hung workspace used to stall every workspace after it. The step is stopped, counted as a failure and logged with the workspace and the reason; HIPPO_DAILY_STEP_TIMEOUT_MS changes the limit.
A stored JSON column that will not read now leaves a log line. Handoff artifacts, constraints and evidence, incident links, audit metadata, dormant snapshots and working-memory metadata still read as empty, as before, but one warn names the table, row id and column (never the text). Handoff evidence of the wrong shape reads as none instead of being passed through.
hippo import warns when its embedding backfill fails. The failure was dropped. The import still succeeds, and one warn gives the number of rows left without a vector and names hippo embed.
An MCP internal error logs its class and stack, as a failed HTTP request already did.
The MCP stdio server refuses a frame over 1 MB. A larger Content-Length, or a line that passes 1 MB with no newline, is answered with a JSON-RPC parse error and dropped without being buffered; the server keeps running. This is the cap POST /mcp already has.
hippo serve logs a request that is still unanswered after 60 seconds. One warn gives the request id, route and elapsed time. The request is left to finish and its reply is unchanged. The server's request timeout is now set explicitly to Node's own default of 5 minutes.
A CLI command that fails under HIPPO_LOG=debug prints the error class and stack after the message. At every other level the output is unchanged.
A failure that throws something other than an Error now reports that value. Sleep, the CLI verbs and the rerankers used to print undefined or unknown error for it.
hippo init on Windows: the schtasks /create line printed when the task cannot be created now doubles a backslash that sits before a quote or at the end, so the pasted command keeps its /tr value in one argument.
Hybrid and physics recall keep their vector arm on a store that runs on another thread. The recall path sent its admission function to the store with the vector query; a function cannot cross to a thread, and the failure was logged as a fall back to BM25. The function now stays on the caller's side.
A token-ledger row that cannot be written is now logged instead of dropped in silence. The prompt hook and the ledger writer skipped every failed row without a word unless the store was busy. An unexpected failure now logs one warning with the error class and stack. A store with no ledger table, or a hippo.db SQLite cannot read, stays quiet and is named at debug level. The hook's output and exit code are unchanged.
hippo serve has a readiness probe, GET /ready. It needs no key, like GET /health. It runs one cheap read on the served store and answers 200 {"ok":true}, or 503 {"ok":false,"error":"store_unavailable"} with a warning in the log. A store with no readiness check answers 200 {"ok":true,"store":"unchecked"}. GET /health is unchanged and still reports only that the process is up.
hippo serve no longer stalls at boot behind a locked store. The connection the server holds open waits 250 ms for a lock, as every request does, instead of up to 30 s on the event loop. The MCP event stream's key check and heartbeat use the same wait.
The MCP stdio server answers a request that has an id and no method. It replies with JSON-RPC error -32600 and that id, where it used to stay silent and leave the client waiting. A frame with neither is still not answered, and is now logged.
The MCP stdio server finishes calls in flight when its input closes. It waits up to 5 s, sends their replies, then exits 0. It used to exit at once and lose them. A call still running after 5 s is named in a warning.
hippo serve can log one line per request. With HIPPO_LOG=info each finished request logs its method, route pattern, status, duration and request id, plus the tenant where the route knows it. The line never holds the path or query the caller sent. The default log level is unchanged.
hippo init and hippo setup no longer hang on a scheduler that does not answer. Each schtasks and crontab call stops after 30 s and says so.
hippo serve no longer runs WAL checkpoints inside a request. A worker thread now checkpoints a served store, so a slow disk flush stalls the worker and not the event loop. Before, about one recall in three ran four fsync calls, and on a slow disk each of those requests took 100 to 340 ms.
A recall no longer truncates stats.json. The file is now overwritten in place. A truncate waits for the previous write of the file to reach the disk, which stalled recalls for 50 to 250 ms on a slow disk.
Rethrown failures keep the original error as cause. Git reads, embedding API calls, the TLS certificate check and trace-step parsing now carry the underlying error, so a log shows the real fault. The message text is unchanged.
A Google API key, Slack token or sk-prefixed key that ends in a hyphen is redacted whole. A Google key ending in - was not redacted at all. A Slack token or sk-prefixed key was redacted up to its last letter or digit, and its closing hyphens stayed in the text beside [REDACTED].
Recall order no longer depends on arrival order when a score is infinite or not a number. Two memories that both scored Infinity, or any memory with a NaN score, came back in the order the search happened to produce them. Equal scores now go to the id tie-break whatever their value, and a NaN score ranks last.
An id with too many digits to be held exactly is refused. A command given an id past 2^53 used to act on the neighbouring id the number rounded to. It now prints the same Invalid ... id error as any other bad id and exits 1.
The sharing scrub masks a home path that directly follows another./home/kit/home/kit came out as [home]/home/kit, with the user name still in it, and scrubbing that text again changed it. Each glued home path now gets its own [home], and a second scrub changes nothing.
The MCP stdio fault tests wait for the child process to flush its error stream. One case could read the stream before the warning arrived and fail by chance. Internal only, no runtime behaviour changes.
The emotional multiplier test reads the clock once. A paused CI runner can no longer move a strength ratio past its tolerance. Internal only, no runtime behaviour changes.
Five store-heavy test files no longer time out on a slow Windows runner. Three seed loops now write through one connection, and each file states its own time limit, so a slow disk no longer fails the suite.
Security
Injection screening of Slack and GitHub content now catches disguised forms of the phrases it already knew. A phrase could be slipped past the screen with a zero-width character or a soft hyphen inside a word, full-width or mathematical letters, a Cyrillic or Greek look-alike letter, an HTML character reference such as i, or a tab or a second space inside a fixed phrase. Connector content is now checked twice: as written, and as a reader sees it. The stored text is never changed, and a held memory carries the same pattern:<name> reason the plain phrase gets.
Connector content with a bidi override, embedding or isolate control is held for review, with the reason pattern:bidi-control. These controls reorder what is shown without changing the bytes. Ordinary Arabic and Hebrew text has none. A path copied from the Windows file properties dialog starts with one, so a message carrying such a path is held.
Some connector content that was stored before this release would now be held for review. New content of that kind goes to the quarantine queue (hippo quarantine list) until an admin approves or rejects it; memories already stored are not rescreened. Still not caught: a paraphrase, another language, a phrase broken across two lines, look-alike letters outside the Cyrillic and Greek table, and encodings such as base64.
GET /ready now runs at most one store read per second, whatever the number of callers. A health prober still gets the same reply and never a 429.
Working memory (hippo wm, wmPush, wmRead, wmClear, wmFlush) is now scoped by tenant. Existing rows belong to the default tenant and read exactly as before; each call takes an optional tenantId.
hippo serve --tls-cert now sets TLS 1.2 as the minimum version explicitly. This is the same floor Node used by default.
The runWatched doc now states that its command string runs in a shell.
A loopback request that carries Fly-Client-Ip no longer gets the no-key local fallback. The header means a proxy passed the request on, so the caller is not local. A request carrying it and no other proxy header was served without a key; it now gets 401, like one carrying X-Forwarded-For.
hippo serve warns at start when HIPPO_CLIENT_IP_HEADER is set and HIPPO_TRUSTED_PROXIES is not. In that setup any caller that reaches the port without passing the proxy can send the header and pick its own rate-limit bucket. The header is still read as before; the line names the risk and the variable that pins the header to the proxy.
An IPv6 client is rate-limited per /64, not per address. One subscriber holds a whole /64, so rotating inside it used to buy a fresh bucket on the general /v1 and /mcp limit. That limit, the open-stream cap for keyless clients and the MCP per-client recall state now key on the same /64 the failed-auth limit already used. IPv4 and IPv4-mapped IPv6 clients keep one bucket per address.
hippo serve can answer HTTPS itself. Pass --tls-cert <file> and --tls-key <file>, or set HIPPO_TLS_CERT and HIPPO_TLS_KEY, and the server speaks TLS only; serve() takes the same pair as tls: { cert, key }. Giving one without the other, or a file that cannot be read, exits 1 instead of falling back to cleartext.
A network bind without TLS warns once at boot.hippo serve --host 0.0.0.0 still starts, and logs that API keys and memory text travel in cleartext unless a TLS-terminating proxy sits in front.
The dashboard takes its access token out of the address bar. A page load with ?token= now sets the cookie and answers 303 to the same address without the token, so it stays out of history, bookmarks and copied links. A visit that another site started gets a same-site refresh instead, because a browser withholds the cookie from that redirect. An API call that carries the token is answered in place, as before.
Every dashboard reply carries Content-Security-Policy, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer and X-Frame-Options: DENY. The policy allows scripts, styles, fonts and fetches from the dashboard itself only, with no inline script; the page shown when the UI is not built moved its styles into one block the policy names by hash.
Every hippo serve reply carries X-Content-Type-Options: nosniff. Memory text is caller-written, so no browser may guess a JSON reply into HTML.
The Slack and GitHub webhook routes refuse before they read a body. With no signing secret configured they answer 404 at once, and with a secret but no signature header they answer 401 at once; either way a caller still sending its body loses the connection. An oversized upload to an unconfigured webhook route therefore gets 404, where it used to get 413. A signed request now has 10 seconds to deliver its body and gets 408 after that; before, it could hold the socket open. The signature is still checked over the exact bytes sent.
Minting an admin key by default now says so.hippo auth create without --role prints a note on stderr that the key is an admin key and never expires, and POST /v1/auth/keys without role logs the same at warn with the key id. The default itself, the printed key and the JSON reply are unchanged.
hippo serve prints at start that local requests need no API key. One line names HIPPO_REQUIRE_AUTH=1 as the way to require a key on every request; it is absent when that is already set.
Documentation
evals/README.md no longer tells you to set embeddings.hybridWeight in .hippo/config.json. No command reads that key, so setting it changed nothing. The embedding weight is set per run with hippo eval --embedding-weight.
The sync-or-async reply carrier is stated and bounded.docs/ARCHITECTURE.md explains why a few published functions return a value or a Promise, and scripts/check-store-port.mjs now stops andThen and onStore spreading to new files. No runtime change.
The store-port ratchet now counts SQL written outside the data layer.scripts/check-store-port.mjs adds sqlOutside (with a per-file map) and txLiterals, and fails when const V1_ROUTES is missing. No runtime change.
A CI ratchet now counts what is left of the store port.scripts/check-store-port.mjs tracks database openers outside the data layer, store branches in src/api, routes without storeReady and twin functions; none may rise above .store-port-baseline.json. No runtime change.
The ranker floor check now has its harness and its window-fixture generator, in benchmarks/q3b-ranker-floor/. The pre-registration requires both to be committed before any run. Nothing was scored: this is a retrieval-floor check for choosing one ranking core, not a success measure, and no user-facing behaviour changes.
hippo serve --help lists --tls-cert and --tls-key. The flags worked but the help text did not name them or their HIPPO_TLS_CERT and HIPPO_TLS_KEY variables.
Every /v1 route now declares its store status.POST /v1/memories/:id/promote and POST /v1/sleep are marked sqliteOnly with a reason, and the store-port ratchet reads the declaration from the syntax tree. No runtime change.
The test suite sheds cases that replayed a contract another test already owns. One characterization file, five replayed cases and two vacuous checks changed or moved to their owner files. No runtime change.
Tests
Six test calls to hippo recall drop --global.recall never read the flag and searches the global store anyway, so the tests check the same thing without the unknown-flag warning on stderr.
The two 10k-row timing gates now also count work, which does not move with the runner. Their wall-clock bounds (3,000 ms for a hybrid recall, 2,000 ms for each request path) are set wide for slow runners, so a request could do twice the work and still pass. scripts/check-request-path-timing.mjs now counts the statements each of its nine requests runs and the rows they hand to JavaScript, and fails above a per-request ceiling of about 1.3 times today's count. tests/hybrid-search-10k-timing.test.ts gains a case that holds one recall to 13,000 rows read (10,520 today, 10,000 of them the stored vectors) and 80 statements (61 today). A second vector scan or one whole-store read per connection passes both wall-clock bounds and fails both counts. The wall-clock bounds are unchanged.
A CI check keeps recall ranking and recording out of src/cli/.scripts/check-cli-recall-writes.mjs fails when a CLI file names the ranking core or a recall writer, so the surfaces cannot drift apart again.
The typed-object behaviour that no test owned is now pinned in one table-driven file, ahead of the lifecycle refactor.tests/typed-object-characterization.test.ts covers the seven types (decisions, incidents, processes, skills, customer notes, policies, project briefs) through the live HTTP server, the rows left in the store and the CLI: refusal wording, audit rows and their order, which list checks run before auth, graph rows after a close, and how each CLI verb reads an id. Behaviour that an existing test already pins gets no new test. No source file changes.
Seven test files and a 212-line snapshot that replayed a contract another test owns are gone. Their unique assertions moved into the keeper first, and each kept contract was checked by breaking the line it guards. Misnamed tests now check what their names say. No source file changes.
getContext is pinned once, by ids a reader can check.tests/context-store-path-parity.test.ts lists for each of its eight cases the entry ids in order, the token total, the blocks returned, the trace row count, the recall count in stats.json and the global-store rows counted as retrieved. The eight generated files in tests/fixtures/context-store-path/ are gone, and so is the second pass that compared hippo.db with itself.
tests/request-path-output-snapshot.test.ts keeps only its MCP rows. Its nine getContext snapshots stored every score to nine places on a generated store. Each option they covered has a named test elsewhere, and the snapshot file drops from 3,465 lines to 328.
The route status snapshot is now a literal table.tests/server-route-status-table.test.ts lists each route's status and error text for a bad key and for loopback, and asserts one field of each valid reply, in place of a 3,137-line snapshot.
Replayed HTTP and MCP cases are gone. Six test files and a handful of declarations re-asserted failures that a named test already catches; their few unique assertions moved into those tests first.
mcpContextFor is no longer exported from src/server/mcp-http.ts; no test calls it.
The half-life migration's scaling test no longer reads a clock. It timed two store sizes and compared them, and a shared runner that slowed one side failed it in nine CI runs. It now counts what the migration does. One test records the SQL: one indexed update per memory moved and the same number of other statements at 20 and at 400 memories. The other measures the JS heap the migration allocates for 250 and for 1,000 memories, which is 4 times as much for linear work and 54 times as much when the audit record is copied once per memory.
Tests that need the CLI to route to a running server no longer depend on the health probe's timer. The CLI writes to the store directly when the server's /health answer misses the probe deadline, so a slow runner made tests/cli-econnreset-no-replay.test.ts see a direct write where it expected a routed one. That file and tests/cli-thin-client.test.ts now set HIPPO_HEALTH_PROBE_MS past their own time limit, so only the server's answer picks the route. 30 of 30 local runs passed with 48 busy processes on 24 cores.
The 11 api functions that run both on hippo.db and through a store are now compared path against path, ahead of the change that merges each pair.tests/api-sync-store-parity.test.ts runs 64 calls on two copies of one seeded store and compares the reply and everything the store then holds; a KNOWN_DIFFERENCES table names the 12 places the two paths differ today, both source sites and the side the merge keeps. The same file pins five timing facts an add-on relies on for each function, and that importVault stays synchronous. tests/public-entry-exports.test.ts now lists every runtime name each package entry exports, read from the built package. No source file changes.
One build check for the whole suite. A vitest global setup stops the run with dist is older than src/<file>. Run npm run build when any source file is newer than its build output, so no test that spawns the CLI can pass against old code. The three per-file copies of that check are gone.
Token-eval tests run on every pull request that touches src/. They ran only when the harness itself changed, so a src change could break them and show only after merge.
The two tests that never ran in CI now run or are gone. The always-skipped p99 plumbing test is deleted (the CI benchmark benchmarks/a1/p99-recall.ts covers it), and the LongMemEval harness test now seeds its own store instead of skipping on every clean checkout.
The two CLI recall tests that read src/cli/recall.ts as text now run the command.tests/cli-recall-anchoring.test.ts and tests/cli-recall-autodebias-zero-results.test.ts call cmdRecall in process on a real store, through retrieve and the CLI ranking core, and assert what it prints: the anchored_on line and its place above the results, the JSON hint and interference count, the session id from flag or env, the per-tenant ring, and the planning-fallacy hint on a recall that matches nothing.
Two tests that failed on a loaded or older-Node Windows machine are deterministic. The slow-git test proves the timeoutMs cap by outcome (a wide cap reads the tree behind a 3 s hook, a 300 ms cap gives null) with no elapsed-time check, and the shared-store symlink test removes its link with unlinkSync, which works on a junction on every Node 24 build.
Tests that asserted regexes over source text are gone or now run the behaviour. The card wiring test goes through the flag parser and the command table, the hippo context test counts the rows the context admitted, and the source-text copies of contracts that already had a behaviour test are removed along with four duplicate or assertion-free declarations.
Negative assertions no longer follow a fixed sleep. Eight tests waited a fixed time and then asserted that nothing happened; each now waits on the event that proves the decision was taken (a held resolver answer, a hand-fired heartbeat tick, the request's socket close, the recorded spawn list) and carries a control that shows the opposite outcome on the same setup.
Performance
Whole-store reads that filtered or counted in JS now do it in SQL.hippo_status reads one aggregate row and a conflict count instead of a strength row per memory (10,006 rows down to 8 at 10,000 memories). The session digest reads only the live session-digest rows of other sessions (10,113 rows down to 63). The dashboard's memory detail reads the conflicts naming that memory in one query and their other sides in one batch (210 statements down to 11 with 20 conflicts). Sleep's dedupe pass reads only current distilled rows.
API-key hashing on a server cache miss runs off the event loop. The scrypt check now runs on the thread pool, so other requests keep moving while a key is verified. The CLI's synchronous check is unchanged.
Hybrid search reads stored vectors without copying them, and opening a store takes one statement after its PRAGMAs. Scoring 2,000 stored 384-number vectors used about 30 MB of copies and now uses under 2 MB; an open ran two to four statements after its PRAGMAs. Ranking, scores and replies are unchanged.
Drill-down reads whole rows only for the page it returns and counts the rest in SQL, the drill route refuses a limit above 1000 as the other list routes do, and the dashboard snapshot reads only the columns it shows.
Internal
The GitHub connector's SQL now lives in the store layer.src/store/connectors/github.ts owns the event log, dead-letter queue, tenant routing and backfill cursor queries; the connector files hold no .prepare( call and open no database. No behaviour change.