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.
Fixed
'stale' was both a stored confidence tier and a derived one, and every reporting surface collapsed the two.resolveConfidence (src/memory.ts:447) returned the literal 'stale' for any non-pinned, non-verified row untouched for 30 days, discarding the stored tier. 'stale' is also what hippo invalidate and decision supersede store, so a well-grounded observed memory nobody had read in a month and a memory a human had explicitly rejected came back identical, and the stored tier was unrecoverable at the call site. hippo stats bucketed both under "Stale" and hippo recall --why reported confidence: "stale" for a row whose stored tier was observed. confidenceFacets now returns { tier, agedOut } and confidenceLabel renders the pair as observed, aged, both sitting on one isAgedOut predicate so the 30-day threshold and the pinned/verified exemption exist in exactly one place. Eight display sites move onto it: recall human and --why JSON, explain --json, trace human and JSON, status, the context render, the dashboard payload, and MCP formatMemories. status gains an Aged out line that cross-cuts the four tier buckets rather than partitioning with them, which the label says. The JSON surfaces are a deliberate user-visible change: confidence is now the stored tier and aged_out carries the rest, which is lossless since the old value is aged_out ? 'stale' : confidence. Nothing outside the repo consumes those shapes; the Python SDK types the field as an open string and talks to src/api.ts, which never collapsed it. resolveConfidence is unchanged, still exported, and keeps two callers: replay.ts, which filters and genuinely wants the collapsed answer, and hippo inspect, which already printed both facets correctly. In the dashboard, the tier no longer stands in for the age-out, so the confidence checkboxes stopped reaching cold rows; age-out is a separate axis and gets its own aged out toggle beside fading, and the count is rendered rather than left as a field nothing reads. Removing 'stale' from the exported ConfidenceLevel union is the clean fix and is not taken here: it is a breaking change to a published type plus a migration of the 807 live rows holding the literal, and it stays filed in TODOS.md. Pinned by tests/confidence-display-facets.test.ts, 16 cases over real stores, plus one aged-out fixture added to the context-render snapshot file with the eight existing snapshots byte-identical; nine mutants were each killed by a different case. One finding came out of building it: markRetrieved (src/search.ts:1254) refreshes last_retrieved for every row a retrieval returns, and hippo context renders the post-refresh copy, so that surface can never show an aged row and deliberately carries no aged_out field. hippo recall renders the pre-refresh entry and does show it. That disagreement predates this change and is filed in TODOS.md as its own decision. Codex review then found the new suite carried a date bomb: the fixtures are dated from a fixed NOW while runCli spawns the real CLI on the real clock, so the row seeded one day old would itself have aged out after 2026-10-06 and the status count would read 2 where the test expects 1. The two clocks are now one. beforeEach pins HIPPO_FAKE_NOW to that same NOW, which evalNow (src/ablation.ts:146) already honours, and runCli spawns off process.env, so the in-process dashboard and MCP assertions and the spawned CLI all read the fixture clock. Moving NOW a year into the past leaves every case green, which is what the fix buys.
Reading a memory undid hippo invalidate. Invalidation weakens a memory by storing confidence: 'stale' and tagging it invalidated (src/invalidation.ts:138), and markRetrieved promoted any stored 'stale' back to 'observed' on the next read. So one hippo recall, one hippo context, or one MCP recall put the memory back to a tier the user had explicitly rejected, while the invalidated tag stayed on the row. Decision supersede (src/cli.ts:4703) is the second writer of a deliberate 'stale' and was undone the same way. The promotion arrived in 471ba86 as one half of a pair: consolidate wrote the derived staleness into the row, and this line woke it on use. The sleep half was removed later, and src/consolidate.ts:243-245 now states the rule it left behind, that confidence is an epistemic tier and not a cached computation, so resolveConfidence derives staleness on read. The read half survived, and with nothing storing a derived 'stale' any more, undoing a deliberate mark became its only reachable effect. It is deleted rather than made tag-aware, because a guard that reads the invalidated tag would re-encode the same overloaded meaning at one more site and need editing for every future writer of a stored 'stale'. Retrieval still applies its three strengthening effects: retrieval_count, last_retrieved and the half-life increment. Pinned by tests/invalidation-survives-recall.test.ts, which invalidates a real stored memory and reads it back through getContext; restoring the promotion fails 4 cases across that file and tests/search.test.ts. Measured on two live stores before the fix: 10 rows tagged invalidated carried observed or inferred, with 794 retrievals across the seven in the global store. Existing rows are not repaired by this change; re-run hippo invalidate --id <id> on any that matter.
A recall served over HTTP never moved total_recalled.GET /v1/memories (src/server.ts:930) called api.recall and nothing incremented, so hippo stats reported a number that depended on which surface the caller used. Reproduced on a real server: three memories stored, the route returned 3 results, the counter stayed at 0. The count now lives in the route rather than in api.recall, because api.recall is not a shared chokepoint: cmdRecall runs its own pipeline and never calls it, and MCP calls it for a secondary audit band whose length differs from what the caller is shown, so a counter there would count the wrong thing for MCP. Every other read surface already counts at its own site, including GET /v1/context, which routes to api.getContext. hippo recall has no runViaServerIfAvailable branch, so the CLI never routes through this endpoint and the new site cannot double-count it. Drill is still uncounted on both surfaces, which keeps that pair symmetric. Pinned by tests/http-recall-counter.test.ts, a real server over a real store; removing the increment fails 4 of its 5 cases. This is the third counter of this class fixed in three releases, after archive in 1.38.7 and plain forget in 1.38.9. The structural change that would end the class, deriving the counters from the audit log instead of incrementing them by hand, is filed in TODOS.md rather than taken here: bootstrapLegacyStore seeds total_recalled with history predating the audit table, so deriving it would silently reset a published number.
The release version gate printed OK for a package-lock.json it never read.check-manifest-versions.mjs excluded the lockfile wholesale, on the grounds that "the lockfile has its own multi-version semantics". That is true of the packages["node_modules/..."] entries and false of the two root fields: .version and .packages[""].version are the root package's own version, and npm rewrites both on every install. Excluding the whole file let a stale pair through on v1.38.9, where the lockfile stayed at 1.38.8, the gate reported "All 4 lockstep manifests + src/version.ts at version 1.38.9. OK.", and the lockfile was bumped by hand afterwards. The gate now reads the two root fields and still ignores the dependency entries, and a missing lockfile, an unparseable one or a missing version field is reported rather than skipped. The failure message names npm install --package-lock-only, since this repo has no npm version script to rewrite the lockfile when the manifests are bumped by hand, which is how it drifted. Pinned by tests/manifest-version-gate.test.ts, which spawns the real script against a minimal repo in a temp dir and fails 3 of its 5 cases if the new block is reverted. No other manifest was added to the lockstep set: ui/, website/, extensions/claude-code-plugin/, evals/model-profile-bench.json and python/pyproject.toml all version on their own cadence.
A routed hippo remember stored the memory twice when the server's socket died after it had already committed the row.isConnectionRefused (src/client.ts:266) answered "did the transport fail?", but its only consumer, runViaServerIfAvailable (src/cli.ts:346), read the answer as "is it safe to replay this write locally?". ECONNREFUSED means the request never left the client, so a local replay is safe; a socket that breaks with the request already on the wire means the server may have committed it. The predicate returned true for both, and for a bare fetch failed as well, so the CLI cleared the pidfile and wrote a second row on the direct path. Reproduced with a stub server that commits the row then destroys the socket: 2 rows where there should be 1. Node's fetch reports that case as fetch failed with cause.codeUND_ERR_SOCKET, a shape the old predicate never named; it fell through the bare-fetch failed catch-all to the same wrong answer. classifyTransportFailure replaces it with three outcomes: none, never-sent, delivery-unknown. The helper self-heals the pidfile and falls back only on never-sent. On delivery-unknown it leaves the pidfile alone, tells the user the write may already have landed, and exits non-zero; the next command's connect-phase failure heals the pidfile, so nothing stays stuck. All three callers of the helper (remember, forget/archive, promote) are non-idempotent writes, so no per-call-site flag was needed, and src/cli.ts:349 is the only HTTP-to-direct fallback in the tree. Pinned by tests/cli-econnreset-no-replay.test.ts, which fails with 2 rows if the classification is reverted. isConnectionRefused was internal (src/client.ts is not exported from src/index.ts), so it is replaced rather than aliased.
Several processes opening a store that does not exist yet crashed with database is locked.openHippoDb rested its whole concurrency story on PRAGMA busy_timeout = 5000, and both of the statements that actually contend are ones SQLite refuses to run the busy handler for, so the timeout covered neither. PRAGMA journal_mode = WAL (src/db.ts:2367) needs an exclusive lock to convert a fresh rollback-journal file, and returns BUSY immediately instead of waiting. The migration loop's BEGIN (src/db.ts:2413) was DEFERRED, so migration.up and setSchemaVersion were writes trying to upgrade a read snapshot, which SQLite fails on the spot rather than risk a deadlock. Reproduced with 6 real processes released from a barrier onto one cold store: 13 of 30 workers died across 5 trials, most with errcode 5 and some with 517. The WAL pragma now retries against a 30s deadline, and the loop takes BEGIN IMMEDIATE and re-reads the schema version inside the write lock, because a waiter that re-runs a migration another process already applied is not idempotent. BEGIN IMMEDIATE was already the convention at 14 other sites, documented at src/store.ts:2026-2035; this file was the hold-out. The four other deferred-BEGIN sites were checked and are clean, because each takes its lock on its first statement. After the fix, 0 of 48 and 0 of 72 workers failed and every one reached schema version 41. Pinned by tests/db-cold-store-concurrency.test.ts, which fails 6 of its 8 workers if either half is reverted. Warm stores were never affected.
Performance
Every prompt loaded both stores in full to return about eleven rows. The UserPromptSubmit hook installs hippo context --pinned-only --include-recent 5 (src/hooks.ts:134), and that branch reads its working set for exactly three things: the empty-store early return, the pins, and the recent-N candidates. It got that set from two loadAllEntries calls, so every prompt read all 530 local and 1896 global rows of the real stores off disk and ran the secret-scanning admission predicate over every one of them. Measured on copies of those stores: 67.7ms per prompt, 32.2ms of it inside the two loads. loadAmbientCandidates (src/store.ts) replaces them on the pinned-only branch, selecting the pins plus a window of the newest rows in one connection. The predicate handed to it is the caller's whole admission rule, DF3's quality floor included, because that floor is applied to the recent-N slice after the load: counting candidates without it let the load call a window of junk rows sufficient and return an empty slice where the old whole-corpus load reached past them. A store whose 32 newest rows all failed the floor returned 0 entries instead of 5 until that was fixed. Interleaved medians over 40 iterations against the same two stores: 72.7ms to 43.4ms with a project set, 90.0ms to 54.7ms without, identical result ids in both. The first attempt was slower than the baseline, 78.3ms, because opening a connection plus initStore costs about 5.8ms whatever the row count, so splitting the work into two helper calls paid that floor twice; a widening retry loop that reopened the connection each round reached 118.4ms on a window that mostly failed admission. Both are gone: one connection, and one exhaustive fallback for the case where nothing in the window is admissible. The worst case, a local-only store where every row is rejected so the fallback always runs, measured 37.2ms before and 38.6ms after, which is slower and within noise. No migration was needed: idx_memories_tenant_created (src/db.ts:401) already serves the window, and the pinned query measured 3.34ms unindexed over 1896 rows. loadAllEntries is untouched and still serves every other caller, including the non-pinned getContext branch. Pinned by tests/context-pinned-only-load.test.ts, 16 cases over real stores; dropping the pinned query, dropping the fallback, flipping the id DESC tiebreak, dropping the quality floor from the load predicate, or dropping the pin bypass from it each fails a different case. Codex review then found two compatibility regressions, both reproduced before being fixed. The window ordered created as text, which is chronological only for canonical UTC ISO: deserializeEntry preserves offset timestamps from legacy markdown, so 2026-06-01T09:00:00-04:00 sorts below every canonical row of that hour while being an hour newer, and 32 qualifying rows were enough to push it out of a window the old Date.parse comparator would have picked it from. And include_recent is any non-negative finite number at the HTTP edge, so the Array.slice this replaced truncated 8.1 to 8 while a SQL LIMIT bound 32.4 and threw datatype mismatch, turning /v1/context?pinned_only=1&include_recent=8.1 into a 400. Both are fixed where the constraint was introduced: the count is truncated once at the top of the function, and a store holding any non-canonical created takes the whole-corpus load it always had, detected by one covering-index probe against idx_memories_tenant_created. Both real stores are fully canonical, 0 of 530 and 0 of 1973 rows, so the measured fast path is the one they still take and the fallback is reserved for a store rebuilt from drifted markdown. The probe itself measured 0.037ms and 0.127ms median over 200 runs against copies of those two stores, against the 43.4ms and 54.7ms the whole load now costs. Validating include_recent at the HTTP edge was rejected as the fix, because it would turn a request that has always returned 200 into a 400. Two more cases cover them, and each fails alone if its half is reverted.
Internal
The comment above the pinned-budget block in getContext still prescribed a fix that the pinnedReserve block directly beneath it already implements, so it read as if pins could still be displaced by the recent-N loop. Found by reproduce-checking the TODO that sourced the change above. Comment only, no behaviour change.