hippo-memory 1.54.0 → 1.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -118,7 +118,7 @@ Run `hippo init` inside one project. This is everything it writes, in the projec
118
118
  - **Instruction files.** A block between `<!-- hippo:start -->` and `<!-- hippo:end -->` in the project's `CLAUDE.md` or `AGENTS.md`, only if that file already exists. Codex, Cursor, OpenClaw, OpenCode and Pi read `AGENTS.md`.
119
119
  - **Claude Code,** when the project has `CLAUDE.md` or `.claude/settings.json`: 7 hook entries in `~/.claude/settings.json`, one each on SessionEnd, UserPromptSubmit, PreCompact, PostCompact and PostToolUseFailure and two on SessionStart. [Framework Integrations](#framework-integrations) says what each one runs.
120
120
  - **OpenCode,** when the project has `.opencode/` or `opencode.json`: a plugin at `~/.config/opencode/plugins/hippo.ts`.
121
- - **Codex,** when the project has `AGENTS.md` or `.codex` and Codex is installed (`$CODEX_HOME`, else `~/.codex`, exists): 2 hook entries in Codex's `hooks.json`, one on UserPromptSubmit that sends your pinned memories plus the five most recent ones with every prompt and one on SessionStart after a compaction. **Codex runs them only after you trust them once in `/hooks`.** Init also prints `hippo hook install codex`, the opt-in that wraps the Codex launcher to capture sessions; `hippo hook uninstall codex` removes hippo's hooks and the wrapper.
121
+ - **Codex,** when the project has `AGENTS.md` or `.codex` and Codex is installed (`$CODEX_HOME`, else `~/.codex`, exists): 2 hook entries in Codex's `hooks.json`, one on UserPromptSubmit that sends your pinned memories plus up to 5 that match the prompt with every prompt and one on SessionStart after a compaction. **Codex runs them only after you trust them once in `/hooks`.** Init also prints `hippo hook install codex`, the opt-in that wraps the Codex launcher to capture sessions; `hippo hook uninstall codex` removes hippo's hooks and the wrapper.
122
122
  - **A daily run at 6:15am,** one per machine: a crontab line on Linux and macOS, a scheduled task named `hippo-daily-runner` on Windows. It runs `hippo learn --git --days 1` and then `hippo sleep` in every project listed in `~/.hippo/workspaces.json`, and init adds this project to that list.
123
123
  - **Agent memories.** On every run, the notes your coding agents keep about this project go into its store, and the ones about you go into the global store. [Agent memories](#agent-memories) lists what is read.
124
124
 
@@ -548,6 +548,17 @@ crashed, shows what was sent only. Re-reads usually bill at the provider's cache
548
548
  a fraction of the full input price. Counts are estimates (characters / 4), the same estimate
549
549
  every budget uses. Rows older than 90 days are pruned.
550
550
 
551
+ **See why a memory did or did not reach the agent.** Turn on the delivery ledger with
552
+ `{"deliveryLedger":{"enabled":true}}` in `.hippo/config.json` (off by default). The flag is
553
+ read from the store the token ledger writes to: the project's local store when it has one,
554
+ else the global store. Each per-prompt hook call then records one event (session, turn
555
+ number, whether the block was sent, reused, empty or disabled, counts and token totals) and
556
+ one row per candidate memory: emitted, reused or rejected, with the stage and the
557
+ reason it was dropped. With prompt recall on, recent memories dropped by the quality filter
558
+ are not recorded yet. It holds ids, hashes, counts and reasons only, never prompt or memory
559
+ text; the prompt hash is unsalted, so a very short prompt can be guessed. A ledger failure prints one stderr line and never changes what the hook prints. Rows
560
+ older than 90 days are pruned; at a heavy 300 prompts a day that is about 190 MB per store.
561
+
551
562
  ---
552
563
 
553
564
  ### Outcome feedback
@@ -783,7 +794,7 @@ The block asks for nothing a hook already does, because each extra tool call re-
783
794
  For Claude Code, it also adds 7 hook entries to `~/.claude/settings.json`:
784
795
  - a `SessionEnd` hook that runs `hippo sleep` and then `hippo capture` when the session exits. Capture matches the last 20 user and 10 assistant messages of the transcript against word patterns for decisions, rules, errors and preferences. It uses no model and does not read earlier turns, so record lessons with `hippo remember` as you go.
785
796
  - a `SessionStart` hook that prints the previous session's consolidation output
786
- - a `UserPromptSubmit` hook that runs `hippo context --pinned-only --include-recent 5 --format additional-context` every turn. It re-injects pinned memories (`hippo remember <text> --pin`) plus the 5 newest memories in the store, so fresh same-session lessons appear on the next prompt before you pin them. It does not read your prompt unless you set `{"pinnedInject":{"promptRecall":true}}`, which swaps the 5 newest for memories that match the prompt. The block is rendered without live strength percentages, so it stays byte-identical while its memories do not change, and it is sent only when it changed since the session's last prompt: an unchanged block is skipped, resent every 10 skips (`pinnedInject.refreshTurns`, `0` never resends) and resent after compaction. `{"pinnedInject":{"skipUnchanged":false}}` sends it every turn as before. Opt out entirely with `{"pinnedInject":{"enabled":false}}` in `.hippo/config.json`.
797
+ - a `UserPromptSubmit` hook that runs `hippo context --pinned-only --include-recent 5 --format additional-context` every turn. It re-injects pinned memories (`hippo remember <text> --pin`) plus up to 5 memories that share words with your prompt. When nothing matches, it adds only the pinned ones. Since 1.55.0 this replaces the 5 newest memories, which cut the median block from 847 to 533 tokens in our eval. `{"pinnedInject":{"promptRecall":false}}` brings back the 5 newest, so fresh same-session lessons appear on the next prompt whatever you ask. The block is rendered without live strength percentages, so it stays byte-identical while its memories do not change, and it is sent only when it changed since the session's last prompt: an unchanged block is skipped, resent every 10 skips (`pinnedInject.refreshTurns`, `0` never resends) and resent after compaction. The prompt-matched memories go in a separate block that is never skipped, so they are sent on every prompt they match. `{"pinnedInject":{"skipUnchanged":false}}` sends the pinned block every turn as before. Opt out entirely with `{"pinnedInject":{"enabled":false}}` in `.hippo/config.json`.
787
798
  - a `PreCompact` hook that runs `hippo pre-compact` before the transcript gets summarized. It records the compaction in the store, saves a working-state snapshot (task/summary/next step) so mid-session compaction can't drop it, and asks the summariser to end its summary with a "Memories for hippo" list: the lessons, decisions and corrections from the session that should outlive it. The `SessionEnd` hook still owns extracting durable memories from the transcript.
788
799
  - a second `SessionStart` hook (matcher `compact`) that runs `hippo compact-resume`, printing that snapshot back into context right after compaction, if it is under 15 minutes old.
789
800
  - a `PostCompact` hook that runs `hippo post-compact`. It keeps the summary in the store with secrets scrubbed, and saves each item of that list as a memory that sleep never deletes: at most 10 per compaction, skipping an item an earlier compaction already saved and any item that looks like a secret. An item over 500 characters stays in the compaction's record only. It then prints one line, such as "Hippo saved 3 memories from this compaction and restored your task snapshot." If the store is busy, the summary waits in the store's `compactions-spool/` folder and `hippo sleep` finishes the save; `hippo doctor` names any compaction left unfinished for over 10 minutes. The session that compacted does not have those memories injected back into its own prompts, since it just read them in the summary; `hippo recall` still finds them. The hook prints nothing when there is no store to save to.
@@ -794,7 +805,7 @@ Only Claude Code saves memories at a compaction: hippo installs no `PreCompact`
794
805
  To remove: `hippo hook uninstall claude-code`
795
806
 
796
807
  For Codex, it adds two hooks to `$CODEX_HOME/hooks.json` (else `~/.codex/hooks.json`) and keeps every hook already there:
797
- - a `UserPromptSubmit` hook that runs the same `hippo context --pinned-only` command as Claude Code's, so your pinned memories plus the five most recent ones reach every prompt as developer context
808
+ - a `UserPromptSubmit` hook that runs the same `hippo context --pinned-only` command as Claude Code's, so your pinned memories plus up to 5 that match the prompt reach every prompt as developer context
798
809
  - a `SessionStart` hook (matcher `compact`) that runs `hippo compact-resume` after a compaction, so the next prompt sends that block again. Codex gets no `PreCompact` hook from hippo, so it restores a task snapshot only if one was saved with `hippo snapshot save` in the last 15 minutes
799
810
 
800
811
  **Codex runs a new or changed hook only after you trust it, so open `/hooks` in Codex once and trust both;** `hippo doctor` reminds you. The per-prompt hook was checked against a real Codex request; the compaction hook follows Codex's documented `compact` start source and has not been watched end to end in Codex. Each hook also carries a `commandWindows` form (`hippo.cmd ...`), because Codex runs hooks through PowerShell on Windows, where the execution policy can block npm's `hippo.ps1`. hippo only ever appends these two entries and never rewrites one, since Codex treats a changed command as a new hook to trust. To remove: `hippo hook uninstall codex`, which takes out only hippo's exact commands and leaves every other hook, including one of yours that runs hippo.
@@ -1056,7 +1067,7 @@ node run.mjs --adapter all
1056
1067
 
1057
1068
  ### How do I give Claude Code memory between sessions?
1058
1069
 
1059
- Run `npm install -g hippo-memory`, then `hippo init` in the project. If the project has a `CLAUDE.md`, init adds a short block telling Claude to run `hippo context --auto` when a session starts. It also adds 7 hook entries to Claude Code's settings that keep your pinned memories plus the five most recent ones in context, save a task snapshot and the memories a compaction summary lists, store failed tool calls as lessons, and run `hippo sleep` when the session ends, and it sets up a daily 6:15am run. [What hippo init changes](#what-hippo-init-changes) lists everything. The [Claude Code plugin](https://github.com/kitfunso/hippo-memory/tree/master/extensions/claude-code-plugin) is the alternative to these hooks; use one, not both. To set up every git repo up to three folders below your home directory at once, know what the scan changes first: each repo gets its own store, seeded from a year of its git history, the same hooks go in when one of those repos uses Claude Code, and the daily run is set up, but no block goes into any repo's `CLAUDE.md`. The command is `hippo init --scan ~`; run `hippo init` in the projects where you want the block.
1070
+ Run `npm install -g hippo-memory`, then `hippo init` in the project. If the project has a `CLAUDE.md`, init adds a short block telling Claude to run `hippo context --auto` when a session starts. It also adds 7 hook entries to Claude Code's settings that keep your pinned memories plus up to 5 that match the prompt in context, save a task snapshot and the memories a compaction summary lists, store failed tool calls as lessons, and run `hippo sleep` when the session ends, and it sets up a daily 6:15am run. [What hippo init changes](#what-hippo-init-changes) lists everything. The [Claude Code plugin](https://github.com/kitfunso/hippo-memory/tree/master/extensions/claude-code-plugin) is the alternative to these hooks; use one, not both. To set up every git repo up to three folders below your home directory at once, know what the scan changes first: each repo gets its own store, seeded from a year of its git history, the same hooks go in when one of those repos uses Claude Code, and the daily run is set up, but no block goes into any repo's `CLAUDE.md`. The command is `hippo init --scan ~`; run `hippo init` in the projects where you want the block.
1060
1071
 
1061
1072
  ### How do I give Cursor memory between sessions?
1062
1073
 
@@ -1064,7 +1075,7 @@ Run `npm install -g hippo-memory`, then `hippo init` in the project. If the proj
1064
1075
 
1065
1076
  ### How do I give Codex memory across sessions?
1066
1077
 
1067
- `hippo init` adds its instructions to your `AGENTS.md`, which Codex reads before it starts work. When Codex is installed, init also adds two hooks to Codex's `hooks.json`: one puts your pinned memories plus the five most recent ones into every prompt, the other makes the next prompt send them again after a compaction. Codex asks you to trust each new hook once in `/hooks`, and skips it until you do. Capturing Codex sessions is opt-in: `hippo hook install codex` wraps the Codex launcher, and `hippo hook uninstall codex` removes the wrapper and the hooks.
1078
+ `hippo init` adds its instructions to your `AGENTS.md`, which Codex reads before it starts work. When Codex is installed, init also adds two hooks to Codex's `hooks.json`: one puts your pinned memories plus up to 5 that match the prompt into every prompt, the other makes the next prompt send them again after a compaction. Codex asks you to trust each new hook once in `/hooks`, and skips it until you do. Capturing Codex sessions is opt-in: `hippo hook install codex` wraps the Codex launcher, and `hippo hook uninstall codex` removes the wrapper and the hooks.
1068
1079
 
1069
1080
  ### Which agents does hippo work with?
1070
1081
 
package/dist/api.d.ts CHANGED
@@ -17,6 +17,7 @@ import { type SessionHandoff } from './handoff.js';
17
17
  import { type MemoryKind, type MemoryEntry } from './memory.js';
18
18
  import { auditMemories, type AuditEvent, type AuditOp } from './audit.js';
19
19
  import { autoShare } from './shared.js';
20
+ import type { DeliveryObserver } from './delivery-recorder.js';
20
21
  import { type ApiKeyListItem } from './auth.js';
21
22
  import { type RerankStep } from './search.js';
22
23
  import { consolidate } from './consolidate.js';
@@ -974,6 +975,8 @@ export interface ContextOpts {
974
975
  prompt?: string;
975
976
  /** What the budget pays for, from the caller that renders the block. Absent = the memory text alone. */
976
977
  cost?: ContextCost;
978
+ /** @internal The CLI's delivery-ledger observer; it only reads, so selection is the same with or without it. */
979
+ deliveryObserver?: DeliveryObserver;
977
980
  }
978
981
  /** Budget prices in the text a caller prints, so the budget bounds what reaches the model. */
979
982
  export interface ContextCost {
package/dist/api.js CHANGED
@@ -135,12 +135,19 @@ export function ambientSecretAdmit(e, currentProjectName) {
135
135
  return origin === currentProjectName;
136
136
  }
137
137
  // The pinned-only branch needs pins and recent-N candidates, not the corpus; `recall` applies there only.
138
- function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admit, recall) {
138
+ function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admit, recall, onQualityDrop) {
139
139
  if (!pinnedOnly)
140
140
  return { entries: loadAllEntries(hippoRoot, tenantId).filter(admit) };
141
141
  // DF3's quality floor runs on the recent-N slice AFTER this load, so the load
142
142
  // counts by it too, or it stops short of a store whose newest rows are junk.
143
- const admitAmbient = (e) => admit(e) && (e.pinned || isContentWorthStoring(e.content));
143
+ const admitAmbient = (e) => {
144
+ if (!admit(e))
145
+ return false;
146
+ if (e.pinned || isContentWorthStoring(e.content))
147
+ return true;
148
+ onQualityDrop?.(e);
149
+ return false;
150
+ };
144
151
  return loadAmbientCandidates(hippoRoot, tenantId, includeRecent, admitAmbient, recall);
145
152
  }
146
153
  // Share and promote copy a memory to the global store under a new id, so equal content is the only link.
@@ -1579,6 +1586,10 @@ export async function getContext(ctx, opts = {}) {
1579
1586
  ? cost.entry({ entry, isGlobal, promptRecall, origin: entry.origin_project ?? null, category: classifyOriginProject(entry.origin_project, currentProjectName) })
1580
1587
  : estimateTokens(entry.content);
1581
1588
  const blockBudget = pinnedOnly && opts.budget === undefined ? config.pinnedInject.budget : budget;
1589
+ const obs = opts.deliveryObserver;
1590
+ obs?.facts({ projectName: currentProjectName, budgetTokens: blockBudget, promptRecall: promptRecallPending });
1591
+ if (pinnedOnly && !config.pinnedInject.enabled)
1592
+ obs?.disabled();
1582
1593
  let left = cost
1583
1594
  ? Math.max(0, blockBudget - cost.fixed(blockBudget, { cross: includeCrossProject, promptRecall: promptRecallPending, ambient: !pinnedOnly && config.ambient.enabled }))
1584
1595
  : blockBudget;
@@ -1630,6 +1641,7 @@ export async function getContext(ctx, opts = {}) {
1630
1641
  const shownSnapshot = activeSnapshot && (!cost || pays(cost.snapshot(activeSnapshot))) ? activeSnapshot : null;
1631
1642
  const shownHandoff = sessionHandoff && (!cost || pays(cost.handoff(sessionHandoff))) ? sessionHandoff : null;
1632
1643
  const shownEvents = recentSessionEvents.length > 0 && (!cost || pays(cost.trail(recentSessionEvents))) ? recentSessionEvents : [];
1644
+ obs?.sections(Number(shownSnapshot !== null) + Number(shownHandoff !== null) + Number(shownEvents.length > 0), Number(activeSnapshot !== shownSnapshot) + Number(sessionHandoff !== shownHandoff) + Number(recentSessionEvents.length !== shownEvents.length));
1633
1645
  const transcriptHandoffSession = shownHandoff?.evidence?.derivedFrom === 'transcript' ? shownHandoff.sessionId : null;
1634
1646
  let digestHiddenForHandoff = false;
1635
1647
  const ambientAdmit = (e) => {
@@ -1647,12 +1659,14 @@ export async function getContext(ctx, opts = {}) {
1647
1659
  e.tags.includes(COMPACTION_MEMORY_TAG);
1648
1660
  // Superseded rows never inject; which rows reach ambientAdmitEntry matters because it regex-scans content for secrets.
1649
1661
  const admit = (e) => !e.superseded_by && !isOwnCompactionItem(e) && ambientAdmit(e);
1662
+ const loadAdmit = obs ? obs.watchAdmit(admit) : admit;
1663
+ const qualityDrop = (isGlobal) => obs && !promptRecallPending ? (e) => obs.qualityDropped(e, isGlobal) : undefined;
1650
1664
  // Tenant-scoped loads (v1.11.1 lesson: NEVER resolveTenantId({}) here).
1651
1665
  const localLoad = hasLocal
1652
- ? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, includeRecent, admit, recallRequest)
1666
+ ? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, includeRecent, loadAdmit, recallRequest, qualityDrop(primaryIsGlobal))
1653
1667
  : { entries: [] };
1654
1668
  const globalLoad = hasGlobal && !primaryIsGlobal
1655
- ? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, admit, recallRequest)
1669
+ ? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, loadAdmit, recallRequest, qualityDrop(true))
1656
1670
  : { entries: [] };
1657
1671
  let localEntries = localLoad.entries;
1658
1672
  let globalEntries = globalLoad.entries;
@@ -1677,7 +1691,10 @@ export async function getContext(ctx, opts = {}) {
1677
1691
  // Effective budget: explicit opts.budget wins over config, less what the sections took.
1678
1692
  const effBudget = left;
1679
1693
  const nowP = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
1694
+ obs?.offer(localEntries, primaryIsGlobal);
1695
+ obs?.offer(globalEntries, true);
1680
1696
  const [localPool, globalPool] = oneCopyPerMemory(localEntries, globalEntries, nowP);
1697
+ obs?.dropMissing([...localEntries, ...globalEntries], [...localPool, ...globalPool], 'load', 'duplicate');
1681
1698
  const selectedIds = new Set();
1682
1699
  let usedP = 0;
1683
1700
  // Pinned entries are explicit user intent, the recent-N list an automatic
@@ -1743,8 +1760,23 @@ export async function getContext(ctx, opts = {}) {
1743
1760
  // Candidates came off the ambient load's own connection (recallRequest above), not a fresh open.
1744
1761
  // A candidate carrying a pin's text would inject that memory a second time.
1745
1762
  const pinnedText = new Set(rankedPinned.map((r) => r.entry.content));
1746
- const eligible = (e) => admit(e) && !e.pinned && isContentWorthStoring(e.content) && !pinnedText.has(e.content);
1747
- const [localCandidates, globalCandidates] = oneCopyPerMemory((localLoad.recall ?? []).filter(eligible), (globalLoad.recall ?? []).filter(eligible), nowP);
1763
+ const ineligibleReason = (e) => !admit(e) ? 'scope'
1764
+ : e.pinned ? 'pinned'
1765
+ : !isContentWorthStoring(e.content) ? 'quality'
1766
+ : pinnedText.has(e.content) ? 'duplicate'
1767
+ : null;
1768
+ const eligible = (e) => {
1769
+ const why = ineligibleReason(e);
1770
+ if (why !== null && why !== 'pinned')
1771
+ obs?.reject(e, 'eligible', why);
1772
+ return why === null;
1773
+ };
1774
+ obs?.offer(localLoad.recall ?? [], primaryIsGlobal, 'prompt-recall');
1775
+ obs?.offer(globalLoad.recall ?? [], true, 'prompt-recall');
1776
+ const localEligible = (localLoad.recall ?? []).filter(eligible);
1777
+ const globalEligible = (globalLoad.recall ?? []).filter(eligible);
1778
+ const [localCandidates, globalCandidates] = oneCopyPerMemory(localEligible, globalEligible, nowP);
1779
+ obs?.dropMissing([...localEligible, ...globalEligible], [...localCandidates, ...globalCandidates], 'eligible', 'duplicate');
1748
1780
  const seenCandidateIds = new Set();
1749
1781
  const candidateItems = [];
1750
1782
  // Local wins the id collision (a global row synced into the local store).
@@ -1761,12 +1793,15 @@ export async function getContext(ctx, opts = {}) {
1761
1793
  candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: true });
1762
1794
  }
1763
1795
  const gated = gatePromptRecall(p, candidateItems, gate);
1796
+ obs?.gated(p, candidateItems, gate, gated);
1764
1797
  for (const g of gated) {
1765
1798
  if (selectedIds.has(g.item.id))
1766
1799
  continue;
1767
1800
  const tokens = price(g.item.entry, g.item.isGlobal, true);
1768
- if (usedP + tokens > recentBudget)
1801
+ if (usedP + tokens > recentBudget) {
1802
+ obs?.reject(g.item.entry, 'budget', 'budget', g.score, tokens);
1769
1803
  continue;
1804
+ }
1770
1805
  selectedItems.push({ entry: g.item.entry, score: g.score, tokens, isGlobal: g.item.isGlobal, promptRecall: true });
1771
1806
  selectedIds.add(g.item.id);
1772
1807
  usedP += tokens;
@@ -1818,8 +1853,10 @@ export async function getContext(ctx, opts = {}) {
1818
1853
  for (const r of recent) {
1819
1854
  if (selectedIds.has(r.entry.id))
1820
1855
  continue;
1821
- if (usedP + r.tokens > recentBudget)
1856
+ if (usedP + r.tokens > recentBudget) {
1857
+ obs?.reject(r.entry, 'budget', 'budget', r.score, r.tokens);
1822
1858
  continue;
1859
+ }
1823
1860
  selectedItems.push(r);
1824
1861
  selectedIds.add(r.entry.id);
1825
1862
  usedP += r.tokens;
@@ -1834,8 +1871,10 @@ export async function getContext(ctx, opts = {}) {
1834
1871
  for (const r of rankedPinned) {
1835
1872
  if (selectedIds.has(r.entry.id))
1836
1873
  continue;
1837
- if (usedP + r.tokens > effBudget)
1874
+ if (usedP + r.tokens > effBudget) {
1875
+ obs?.reject(r.entry, 'budget', 'budget', r.score, r.tokens);
1838
1876
  continue;
1877
+ }
1839
1878
  selectedItems.push(r);
1840
1879
  selectedIds.add(r.entry.id);
1841
1880
  usedP += r.tokens;
@@ -1968,9 +2007,13 @@ export async function getContext(ctx, opts = {}) {
1968
2007
  }
1969
2008
  }
1970
2009
  if (limit < selectedItems.length) {
1971
- selectedItems = selectedItems.slice(0, limit);
2010
+ const cut = selectedItems.slice(0, limit);
2011
+ obs?.dropMissing(selectedItems.map((r) => r.entry), cut.map((r) => r.entry), 'limit', 'limit');
2012
+ selectedItems = cut;
1972
2013
  }
1973
- selectedItems = dropHeldCopies(selectedItems, (r) => r.entry); // after the last cut, so a merged row that was cut hides nothing
2014
+ const heldDropped = dropHeldCopies(selectedItems, (r) => r.entry); // after the last cut, so a merged row that was cut hides nothing
2015
+ obs?.dropMissing(selectedItems.map((r) => r.entry), heldDropped.map((r) => r.entry), 'limit', 'duplicate');
2016
+ selectedItems = heldDropped;
1974
2017
  totalTokens = selectedItems.reduce((sum, r) => sum + r.tokens, 0);
1975
2018
  // v39: annotate every returned entry with its origin and how it relates to
1976
2019
  // the active project, so renderers can demarcate cross-project inclusions.
@@ -1979,6 +2022,7 @@ export async function getContext(ctx, opts = {}) {
1979
2022
  origin: r.entry.origin_project ?? null,
1980
2023
  category: classifyOriginProject(r.entry.origin_project, currentProjectName),
1981
2024
  }));
2025
+ obs?.selected(selectedItems);
1982
2026
  if (selectedItems.length === 0 &&
1983
2027
  !shownSnapshot &&
1984
2028
  !shownHandoff &&
package/dist/cli.js CHANGED
@@ -51,7 +51,8 @@ import { passesScopeFilterForRecall } from './recall-scope.js';
51
51
  import { estimateTokens, fitBudget, hybridSearch, physicsSearch, explainMatch, textOverlap, tokenize as tokenizeQuery } from './search.js';
52
52
  import { compareEntryIdentity } from './compare.js';
53
53
  import { renderTraceContent, parseSteps } from './trace.js';
54
- import { writeRecallTraceAtRoot } from './recall-trace.js';
54
+ import { writeDeliveryEventAtRoot, writeDeliveryEventOnHandle, writeRecallTraceAtRoot } from './recall-trace.js';
55
+ import { createDeliveryRecorder } from './delivery-recorder.js';
55
56
  import { deduplicateStore } from './dedupe.js';
56
57
  import { isEmbeddingAvailable, embedAll, embedMemory, loadEmbeddingIndex, resolveEmbeddingModel, embeddingModelRequiresReindex, } from './embeddings.js';
57
58
  import { resolveEmbeddingProvider } from './embedding-provider.js';
@@ -6291,6 +6292,46 @@ function hostSessionId() {
6291
6292
  return process.env.HIPPO_SESSION_ID?.trim() || process.env.CLAUDE_CODE_SESSION_ID?.trim() || undefined;
6292
6293
  }
6293
6294
  async function cmdContext(hippoRoot, args, flags, stdinText) {
6295
+ const rec = startDeliveryRecorder(hippoRoot, flags, stdinText);
6296
+ // No try/finally: a render throw keeps its own exit code and writes no event.
6297
+ await renderContext(hippoRoot, args, flags, stdinText, rec);
6298
+ flushDeliveryRecorder(rec);
6299
+ }
6300
+ /** A delivery recorder for a pinned-only call when its ledger store enables one, else null; never throws. */
6301
+ function startDeliveryRecorder(hippoRoot, flags, stdinText) {
6302
+ if (flags['pinned-only'] !== true)
6303
+ return null;
6304
+ try {
6305
+ // The same store withLedgerDb writes the token ledger to, so its config governs both.
6306
+ const root = isInitialized(hippoRoot) ? hippoRoot : isInitialized(getGlobalRoot()) ? getGlobalRoot() : null;
6307
+ if (root === null || !loadConfig(root).deliveryLedger.enabled)
6308
+ return null;
6309
+ return createDeliveryRecorder({
6310
+ root,
6311
+ storeHash: blockHash(path.resolve(root)),
6312
+ writeStore: isGlobalStoreRoot(root) ? 'global' : 'local',
6313
+ tenantId: resolveTenantId({}),
6314
+ stdinText,
6315
+ envSessionId: hostSessionId(),
6316
+ });
6317
+ }
6318
+ catch (error) {
6319
+ console.error(`[hippo] delivery ledger skipped: ${error instanceof Error ? error.message : String(error)}`);
6320
+ return null;
6321
+ }
6322
+ }
6323
+ /** With `db`, writes on the token ledger's handle (same store); without it, opens its own. A second flush is a no-op. */
6324
+ function flushDeliveryRecorder(rec, db) {
6325
+ if (rec === null)
6326
+ return;
6327
+ try {
6328
+ rec.flush((input) => (db ? writeDeliveryEventOnHandle(db, input) : writeDeliveryEventAtRoot(rec.root, input)));
6329
+ }
6330
+ catch (error) {
6331
+ console.error(`[hippo] delivery ledger write failed: ${error instanceof Error ? error.message : String(error)}`);
6332
+ }
6333
+ }
6334
+ async function renderContext(hippoRoot, args, flags, stdinText, rec) {
6294
6335
  // --pinned-only fires on every UserPromptSubmit — including in directories
6295
6336
  // that don't have a local .hippo. Skip requireInit for that path and fall
6296
6337
  // back to global-only inside api.getContext. The non-pinned path still
@@ -6300,8 +6341,10 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6300
6341
  requireInit(hippoRoot);
6301
6342
  }
6302
6343
  const budget = parseBudgetFlag(flags['budget'], 1500);
6303
- if (budget <= 0)
6344
+ if (budget <= 0) {
6345
+ rec?.disabled();
6304
6346
  return;
6347
+ }
6305
6348
  // Resolve query: explicit args, --auto (git diff via CLI-side helper), or
6306
6349
  // fall through to api.getContext's '*' fallback. api.getContext is host-
6307
6350
  // agnostic so the auto-detect (which shells out to git) stays CLI-side.
@@ -6362,6 +6405,7 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6362
6405
  prompt: payloadPrompt,
6363
6406
  // JSON is budgeted as the markdown it stands for, so one budget picks the same memories in every format.
6364
6407
  cost: contextCost(format === 'additional-context' ? 'additional-context' : 'markdown', framing),
6408
+ deliveryObserver: rec ?? undefined,
6365
6409
  };
6366
6410
  const result = await api.getContext(ctx, opts);
6367
6411
  // Early exit when there's nothing to render (matches pre-extraction behavior).
@@ -6369,8 +6413,10 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6369
6413
  result.activeSnapshot ||
6370
6414
  result.sessionHandoff ||
6371
6415
  (result.recentEvents && result.recentEvents.length > 0);
6372
- if (!hasContextData)
6416
+ if (!hasContextData) {
6417
+ rec?.delivered({ state: 'empty' });
6373
6418
  return;
6419
+ }
6374
6420
  // Adapter: ContextResultEntry -> the print-helper input shape. v39:
6375
6421
  // cross-project inclusions (only present under --cross-project or with
6376
6422
  // isolation disabled via crossProject) render in their own demarcated
@@ -6404,10 +6450,14 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6404
6450
  tokens: result.tokens,
6405
6451
  });
6406
6452
  console.log(jsonText);
6407
- withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6408
- tenantId: ctx.tenantId, sessionId: ledgerSessionId, surface: pinnedOnly ? 'hook' : 'context',
6409
- event: 'inject', items: output.length, tokens: estimateTokens(jsonText),
6410
- }));
6453
+ rec?.delivered({ state: 'sent', emittedText: `${jsonText}\n` });
6454
+ withLedgerDb(hippoRoot, (db) => {
6455
+ recordTokenUse(db, {
6456
+ tenantId: ctx.tenantId, sessionId: ledgerSessionId, surface: pinnedOnly ? 'hook' : 'context',
6457
+ event: 'inject', items: output.length, tokens: estimateTokens(jsonText),
6458
+ });
6459
+ flushDeliveryRecorder(rec, db);
6460
+ });
6411
6461
  }
6412
6462
  else if (format === 'additional-context') {
6413
6463
  // Z1: split into a static block (snapshot/handoff/events/pins/recent-N,
@@ -6433,8 +6483,10 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6433
6483
  const recallBlock = recallItems.length > 0
6434
6484
  ? settleTokens((t) => captureConsole(() => printContextMarkdown(recallItems, t, framing, { showStrength: false, heading: 'Prompt-Relevant Memory' })))
6435
6485
  : '';
6436
- if (!staticBlock.trim() && !recallBlock.trim())
6486
+ if (!staticBlock.trim() && !recallBlock.trim()) {
6487
+ rec?.delivered({ state: 'empty' });
6437
6488
  return;
6489
+ }
6438
6490
  const surface = pinnedOnly ? 'hook' : 'context';
6439
6491
  let sendStatic = staticBlock.trim().length > 0;
6440
6492
  // TE2: the per-prompt hook skips a static block identical to the one this
@@ -6449,10 +6501,16 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6449
6501
  const staticHash = blockHash(staticBlock);
6450
6502
  const last = withLedgerDb(hippoRoot, (db) => lastSentState(db, ctx.tenantId, payloadSessionId, surface));
6451
6503
  if (shouldSkipUnchanged(last ?? null, staticHash, refreshTurns)) {
6452
- withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6453
- tenantId: ctx.tenantId, sessionId: payloadSessionId, surface, event: 'skip',
6454
- items: staticItems.length, tokens: estimateTokens(staticBlock), hash: staticHash,
6455
- }));
6504
+ withLedgerDb(hippoRoot, (db) => {
6505
+ recordTokenUse(db, {
6506
+ tenantId: ctx.tenantId, sessionId: payloadSessionId, surface, event: 'skip',
6507
+ items: staticItems.length, tokens: estimateTokens(staticBlock), hash: staticHash,
6508
+ });
6509
+ if (recallBlock.trim())
6510
+ return;
6511
+ rec?.delivered({ state: 'reused', staticHash, staticReused: true });
6512
+ flushDeliveryRecorder(rec, db);
6513
+ });
6456
6514
  sendStatic = false;
6457
6515
  }
6458
6516
  }
@@ -6461,8 +6519,11 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6461
6519
  const additionalContext = finalStatic && recallBlock
6462
6520
  ? `${finalStatic}\n\n${recallBlock}`
6463
6521
  : finalStatic || recallBlock;
6464
- if (!additionalContext.trim())
6522
+ const staticReused = !sendStatic && staticBlock.trim().length > 0;
6523
+ if (!additionalContext.trim()) {
6524
+ rec?.delivered({ state: 'reused', staticHash: blockHash(staticBlock), staticReused });
6465
6525
  return;
6526
+ }
6466
6527
  const payload = {
6467
6528
  hookSpecificOutput: {
6468
6529
  hookEventName: 'UserPromptSubmit',
@@ -6470,6 +6531,13 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6470
6531
  },
6471
6532
  };
6472
6533
  process.stdout.write(JSON.stringify(payload));
6534
+ rec?.delivered({
6535
+ state: staticReused ? 'reused-recall-sent' : 'sent',
6536
+ staticHash: staticBlock.trim() ? blockHash(staticBlock) : null,
6537
+ recallHash: recallBlock ? blockHash(recallBlock) : null,
6538
+ emittedText: additionalContext,
6539
+ staticReused,
6540
+ });
6473
6541
  if (finalStatic || recallBlock) {
6474
6542
  // One connection for both rows; each insert in its own try so one failing doesn't skip the other.
6475
6543
  withLedgerDb(hippoRoot, (db) => {
@@ -6491,6 +6559,7 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6491
6559
  }
6492
6560
  catch { /* best effort; see withLedgerDb doc comment */ }
6493
6561
  }
6562
+ flushDeliveryRecorder(rec, db);
6494
6563
  });
6495
6564
  }
6496
6565
  }
@@ -6515,10 +6584,14 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6515
6584
  }));
6516
6585
  if (text.length > 0)
6517
6586
  console.log(text);
6518
- withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6519
- tenantId: ctx.tenantId, sessionId: ledgerSessionId, surface: pinnedOnly ? 'hook' : 'context',
6520
- event: 'inject', items: renderItems.length, tokens: estimateTokens(text),
6521
- }));
6587
+ rec?.delivered(text.length > 0 ? { state: 'sent', emittedText: `${text}\n` } : { state: 'empty' });
6588
+ withLedgerDb(hippoRoot, (db) => {
6589
+ recordTokenUse(db, {
6590
+ tenantId: ctx.tenantId, sessionId: ledgerSessionId, surface: pinnedOnly ? 'hook' : 'context',
6591
+ event: 'inject', items: renderItems.length, tokens: estimateTokens(text),
6592
+ });
6593
+ flushDeliveryRecorder(rec, db);
6594
+ });
6522
6595
  }
6523
6596
  }
6524
6597
  /**
@@ -8773,7 +8846,7 @@ Commands:
8773
8846
  --budget <n> Token budget for the whole printed block (default: 1500)
8774
8847
  --pinned-only Only inject pinned memories (used by UserPromptSubmit hook)
8775
8848
  --include-recent <n> With --pinned-only, also inject the last N writes regardless of pinning
8776
- (the hook payload's "prompt" drives prompt recall instead of --include-recent when pinnedInject.promptRecall is on)
8849
+ (the hook payload's "prompt" drives prompt recall instead of --include-recent when pinnedInject.promptRecall is on, the default)
8777
8850
  --format <fmt> Output format: markdown (default), json, or additional-context (Claude Code hook JSON)
8778
8851
  --framing <mode> Framing: observe (default), suggest, assert
8779
8852
  sleep Run consolidation pass (auto-learns + dedup + auto-shares)
package/dist/config.d.ts CHANGED
@@ -71,8 +71,8 @@ export interface HippoConfig {
71
71
  * never resends an unchanged block. */
72
72
  refreshTurns: number;
73
73
  /** Z1: gate the hook's backfill on the prompt's own content instead of
74
- * the five newest memories. Default false: the eval failed its overlap gate
75
- * (docs/evals/2026-09-26-z1-prompt-recall-result.md). */
74
+ * the five newest memories. Default true since 1.55.0: overlap tied but median
75
+ * tokens fell 847 to 533 (docs/evals/2026-09-26-z1-prompt-recall-result.md). */
76
76
  promptRecall: boolean;
77
77
  /** Z1: overlap metric for the prompt-recall gate. Default 'jaccard' (tuned, docs/evals/2026-09-26-z1-prompt-recall-result.md). */
78
78
  promptRecallMetric: PromptRecallMetric;
@@ -140,6 +140,11 @@ export interface HippoConfig {
140
140
  agentMemories: {
141
141
  tools: string[] | null;
142
142
  };
143
+ /** Per-turn delivery ledger (src/recall-trace.ts): hashes, ids, counts and rejection reasons for each
144
+ * pinned-only context call. Default off; read from the store the token ledger writes to. */
145
+ deliveryLedger: {
146
+ enabled: boolean;
147
+ };
143
148
  }
144
149
  export declare function loadConfig(hippoRoot: string): HippoConfig;
145
150
  //# sourceMappingURL=config.d.ts.map
package/dist/config.js CHANGED
@@ -47,7 +47,7 @@ const DEFAULT_CONFIG = {
47
47
  budget: 1500,
48
48
  skipUnchanged: true,
49
49
  refreshTurns: 10,
50
- promptRecall: false,
50
+ promptRecall: true,
51
51
  promptRecallMetric: 'jaccard',
52
52
  promptRecallThreshold: 0.04,
53
53
  promptRecallMinShared: 2,
@@ -85,6 +85,9 @@ const DEFAULT_CONFIG = {
85
85
  agentMemories: {
86
86
  tools: null,
87
87
  },
88
+ deliveryLedger: {
89
+ enabled: false,
90
+ },
88
91
  };
89
92
  function isMemoryValueConfig(value) {
90
93
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -95,6 +98,23 @@ function isDormantConfig(value) {
95
98
  function isChurnStalenessConfig(value) {
96
99
  return typeof value === 'object' && value !== null && !Array.isArray(value);
97
100
  }
101
+ function isDeliveryLedgerConfig(value) {
102
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
103
+ }
104
+ // Only `{"enabled": true}` turns it on; anything malformed warns and stays off.
105
+ function deliveryLedgerEnabled(value) {
106
+ if (value === undefined)
107
+ return false;
108
+ const isObject = isDeliveryLedgerConfig(value);
109
+ const enabled = isObject ? value.enabled : undefined;
110
+ if (enabled === true || enabled === false)
111
+ return enabled;
112
+ if (isObject && enabled === undefined)
113
+ return false;
114
+ console.error(`Warning: config.json's "deliveryLedger" must be an object like {"enabled": true} ` +
115
+ `(got ${JSON.stringify(value)}) - using false.`);
116
+ return false;
117
+ }
98
118
  function agentMemoryTools(value) {
99
119
  if (value === undefined || value === null)
100
120
  return null;
@@ -205,6 +225,7 @@ export function loadConfig(hippoRoot) {
205
225
  enabled: churnStalenessEnabled,
206
226
  },
207
227
  agentMemories: { tools: agentMemoryTools(raw.agentMemories?.tools) },
228
+ deliveryLedger: { enabled: deliveryLedgerEnabled(raw.deliveryLedger) },
208
229
  };
209
230
  }
210
231
  catch (err) {
package/dist/db.js CHANGED
@@ -11,7 +11,7 @@ const require = createRequire(import.meta.url);
11
11
  // runtime (Node's built-in synchronous SQLite module); there are no bundled
12
12
  // types for it here, so this require + cast is the module's documented boundary.
13
13
  const { DatabaseSync } = require('node:sqlite');
14
- const CURRENT_SCHEMA_VERSION = 49;
14
+ const CURRENT_SCHEMA_VERSION = 50;
15
15
  const MIGRATIONS = [
16
16
  {
17
17
  version: 1,
@@ -2501,6 +2501,72 @@ const MIGRATIONS = [
2501
2501
  `);
2502
2502
  },
2503
2503
  },
2504
+ {
2505
+ version: 50,
2506
+ up: (db) => {
2507
+ // Per-turn delivery events (src/recall-trace.ts). Additive only: no min_compatible_binary bump; rollback drops both tables
2508
+ // and sets schema_version back to 49. No CHECK on enum columns since SQLite cannot alter one; delivery-recorder.ts unions are the allowlist.
2509
+ db.exec(`
2510
+ CREATE TABLE IF NOT EXISTS delivery_events (
2511
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2512
+ ts TEXT NOT NULL,
2513
+ ledger_version INTEGER NOT NULL,
2514
+ tenant_id TEXT NOT NULL DEFAULT 'default',
2515
+ runtime TEXT NOT NULL,
2516
+ event_type TEXT NOT NULL,
2517
+ surface TEXT NOT NULL,
2518
+ store_hash TEXT NOT NULL,
2519
+ write_store TEXT NOT NULL,
2520
+ project_hash TEXT,
2521
+ session_id TEXT,
2522
+ session_state TEXT NOT NULL,
2523
+ host_turn_id TEXT,
2524
+ turn_seq INTEGER,
2525
+ duplicate_of INTEGER,
2526
+ prompt_hash TEXT,
2527
+ prompt_length INTEGER NOT NULL DEFAULT 0,
2528
+ query_hash TEXT,
2529
+ recall_trace_id INTEGER,
2530
+ block_state TEXT NOT NULL,
2531
+ prompt_recall INTEGER NOT NULL DEFAULT 0,
2532
+ considered_count INTEGER NOT NULL DEFAULT 0,
2533
+ filtered_count INTEGER NOT NULL DEFAULT 0,
2534
+ selected_count INTEGER NOT NULL DEFAULT 0,
2535
+ emitted_count INTEGER NOT NULL DEFAULT 0,
2536
+ rejected_count INTEGER NOT NULL DEFAULT 0,
2537
+ rejected_unlisted INTEGER NOT NULL DEFAULT 0,
2538
+ sections_shown INTEGER NOT NULL DEFAULT 0,
2539
+ sections_dropped INTEGER NOT NULL DEFAULT 0,
2540
+ budget_tokens INTEGER NOT NULL DEFAULT 0,
2541
+ selected_tokens INTEGER NOT NULL DEFAULT 0,
2542
+ injected_tokens INTEGER NOT NULL DEFAULT 0,
2543
+ static_hash TEXT,
2544
+ recall_hash TEXT,
2545
+ emitted_hash TEXT,
2546
+ elapsed_ms INTEGER NOT NULL DEFAULT 0
2547
+ );
2548
+ CREATE INDEX IF NOT EXISTS idx_delivery_events_session ON delivery_events(tenant_id, session_id, id);
2549
+ CREATE INDEX IF NOT EXISTS idx_delivery_events_ts ON delivery_events(ts);
2550
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_delivery_events_turn
2551
+ ON delivery_events(tenant_id, session_id, event_type, turn_seq) WHERE turn_seq IS NOT NULL;
2552
+
2553
+ CREATE TABLE IF NOT EXISTS delivery_candidates (
2554
+ event_id INTEGER NOT NULL REFERENCES delivery_events(id) ON DELETE CASCADE,
2555
+ tenant_id TEXT NOT NULL DEFAULT 'default',
2556
+ memory_id TEXT NOT NULL, -- no FK: events outlive forgotten memories, as recall traces do
2557
+ source_store TEXT NOT NULL,
2558
+ pool TEXT NOT NULL,
2559
+ stage TEXT NOT NULL,
2560
+ outcome TEXT NOT NULL,
2561
+ reason TEXT,
2562
+ cand_rank INTEGER,
2563
+ score REAL,
2564
+ tokens INTEGER,
2565
+ PRIMARY KEY (event_id, memory_id)
2566
+ ) WITHOUT ROWID;
2567
+ `);
2568
+ },
2569
+ },
2504
2570
  ];
2505
2571
  function tableHasColumn(db, tableName, columnName) {
2506
2572
  if (!/^[a-z_]+$/i.test(tableName))