hippo-memory 1.55.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
@@ -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
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
  /**
package/dist/config.d.ts CHANGED
@@ -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
@@ -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))
@@ -0,0 +1,127 @@
1
+ import type { MemoryEntry } from './memory.js';
2
+ import { type PromptRecallGate } from './prompt-recall.js';
3
+ export type DeliveryRuntime = 'claude-code' | 'codex' | 'unknown';
4
+ export type DeliveryEventType = 'prompt-submit' | 'pinned-manual';
5
+ export type DeliverySurface = 'hook' | 'context';
6
+ export type DeliveryWriteStore = 'local' | 'global';
7
+ export type DeliverySessionState = 'payload' | 'env' | 'missing' | 'subagent';
8
+ export type DeliveryBlockState = 'sent' | 'reused' | 'reused-recall-sent' | 'empty' | 'disabled';
9
+ export type DeliveryPool = 'pin' | 'recent' | 'prompt-recall' | 'strength' | 'search';
10
+ export type DeliveryStage = 'load' | 'eligible' | 'gate' | 'budget' | 'limit' | 'final';
11
+ export type DeliveryOutcome = 'emitted' | 'reused' | 'rejected';
12
+ export type DeliveryRejectReason = 'budget' | 'gate-below-threshold' | 'gate-max-items' | 'duplicate' | 'scope' | 'quality' | 'limit';
13
+ /** Row format version written to `delivery_events.ledger_version`. */
14
+ export declare const DELIVERY_LEDGER_VERSION = 1;
15
+ /** Rejected candidate rows kept per event; the rest only add to `rejected_unlisted`. */
16
+ export declare const DELIVERY_REJECTED_ROW_CAP = 16;
17
+ export interface DeliveryCandidateInput {
18
+ memoryId: string;
19
+ sourceStore: DeliveryWriteStore;
20
+ pool: DeliveryPool;
21
+ stage: DeliveryStage;
22
+ outcome: DeliveryOutcome;
23
+ reason: DeliveryRejectReason | null;
24
+ rank: number | null;
25
+ score: number | null;
26
+ tokens: number | null;
27
+ }
28
+ /** One event as the writer stores it; the writer adds `turn_seq`, `duplicate_of` and the format version. */
29
+ export interface DeliveryEventInput {
30
+ ts: string;
31
+ tenantId: string;
32
+ runtime: DeliveryRuntime;
33
+ eventType: DeliveryEventType;
34
+ surface: DeliverySurface;
35
+ storeHash: string;
36
+ writeStore: DeliveryWriteStore;
37
+ projectHash: string | null;
38
+ sessionId: string | null;
39
+ sessionState: DeliverySessionState;
40
+ hostTurnId: string | null;
41
+ promptHash: string | null;
42
+ promptLength: number;
43
+ queryHash: string | null;
44
+ recallTraceId: number | null;
45
+ blockState: DeliveryBlockState;
46
+ promptRecall: boolean;
47
+ consideredCount: number;
48
+ filteredCount: number;
49
+ selectedCount: number;
50
+ emittedCount: number;
51
+ rejectedCount: number;
52
+ rejectedUnlisted: number;
53
+ sectionsShown: number;
54
+ sectionsDropped: number;
55
+ budgetTokens: number;
56
+ selectedTokens: number;
57
+ injectedTokens: number;
58
+ staticHash: string | null;
59
+ recallHash: string | null;
60
+ emittedHash: string | null;
61
+ elapsedMs: number;
62
+ candidates: readonly DeliveryCandidateInput[];
63
+ }
64
+ export interface DeliveryFacts {
65
+ projectName: string;
66
+ budgetTokens: number;
67
+ promptRecall: boolean;
68
+ }
69
+ /** The shape of a returned context entry the observer reads. */
70
+ export interface DeliverySelected {
71
+ entry: MemoryEntry;
72
+ score: number;
73
+ tokens: number;
74
+ isGlobal?: boolean;
75
+ promptRecall?: boolean;
76
+ }
77
+ /** What getContext reports while it selects. Every method only reads; none changes what is selected. */
78
+ export interface DeliveryObserver {
79
+ facts(facts: DeliveryFacts): void;
80
+ sections(shown: number, dropped: number): void;
81
+ /** Returns `admit`'s own answer unchanged and lets its throws through. */
82
+ watchAdmit(admit: (e: MemoryEntry) => boolean): (e: MemoryEntry) => boolean;
83
+ /** The loader's quality floor dropped a row admit let through; with prompt recall on, eligibility reports it instead. */
84
+ qualityDropped(entry: MemoryEntry, isGlobal: boolean): void;
85
+ disabled(): void;
86
+ /** No `pool` means pin or recent by the entry's own flag. */
87
+ offer(entries: readonly MemoryEntry[], isGlobal: boolean, pool?: DeliveryPool): void;
88
+ reject(entry: MemoryEntry, stage: DeliveryStage, reason: DeliveryRejectReason, score?: number, tokens?: number): void;
89
+ dropMissing(before: readonly MemoryEntry[], after: readonly MemoryEntry[], stage: DeliveryStage, reason: DeliveryRejectReason): void;
90
+ gated(prompt: ReadonlySet<string>, candidates: readonly {
91
+ id: string;
92
+ tokens: ReadonlySet<string>;
93
+ }[], gate: PromptRecallGate, kept: readonly {
94
+ item: {
95
+ id: string;
96
+ };
97
+ }[]): void;
98
+ selected(items: readonly DeliverySelected[]): void;
99
+ }
100
+ /** What the renderer sent, reported once at its exit. */
101
+ export interface DeliveryOutcomeInput {
102
+ state: DeliveryBlockState;
103
+ staticHash?: string | null;
104
+ recallHash?: string | null;
105
+ /** The exact text the agent receives: the hook's additionalContext, or every stdout byte, newline included. */
106
+ emittedText?: string | null;
107
+ /** The static block was skipped as unchanged, so its entries are reused, not sent. */
108
+ staticReused?: boolean;
109
+ }
110
+ export interface DeliveryRecorder extends DeliveryObserver {
111
+ readonly root: string;
112
+ delivered(outcome: DeliveryOutcomeInput): void;
113
+ /** Builds the event and hands it to `write` once per call; throws on an injected fault, and writes nothing once broken. */
114
+ flush(write: (input: DeliveryEventInput) => number | null): void;
115
+ }
116
+ export interface DeliveryRecorderInit {
117
+ /** The store the event is written to. */
118
+ root: string;
119
+ storeHash: string;
120
+ writeStore: DeliveryWriteStore;
121
+ tenantId: string;
122
+ stdinText?: string;
123
+ envSessionId?: string;
124
+ }
125
+ /** A recorder for one call; every observer method is guarded, and a throw marks it broken instead of escaping. */
126
+ export declare function createDeliveryRecorder(init: DeliveryRecorderInit): DeliveryRecorder;
127
+ //# sourceMappingURL=delivery-recorder.d.ts.map
@@ -0,0 +1,218 @@
1
+ import { evalNow } from './ablation.js';
2
+ import { scoreOverlap } from './prompt-recall.js';
3
+ import { blockHash, estimateTokens, hookPayloadSessionId, hookPayloadString, isSubagentPayload } from './token-ledger.js';
4
+ /** Row format version written to `delivery_events.ledger_version`. */
5
+ export const DELIVERY_LEDGER_VERSION = 1;
6
+ /** Rejected candidate rows kept per event; the rest only add to `rejected_unlisted`. */
7
+ export const DELIVERY_REJECTED_ROW_CAP = 16;
8
+ // Deeper stages were closer to being sent, so the row cap keeps them first.
9
+ const STAGE_DEPTH = new Map([
10
+ ['load', 0], ['eligible', 1], ['gate', 2], ['budget', 3], ['limit', 4], ['final', 5],
11
+ ]);
12
+ function storeOf(isGlobal) {
13
+ return isGlobal === true ? 'global' : 'local';
14
+ }
15
+ function byDepthThenScore(a, b) {
16
+ const depth = (STAGE_DEPTH.get(b.stage ?? 'load') ?? 0) - (STAGE_DEPTH.get(a.stage ?? 'load') ?? 0);
17
+ if (depth !== 0)
18
+ return depth;
19
+ const score = (b.score ?? Number.NEGATIVE_INFINITY) - (a.score ?? Number.NEGATIVE_INFINITY);
20
+ if (score !== 0 && !Number.isNaN(score))
21
+ return score;
22
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
23
+ }
24
+ /** A recorder for one call; every observer method is guarded, and a throw marks it broken instead of escaping. */
25
+ export function createDeliveryRecorder(init) {
26
+ const startedMs = Date.now();
27
+ const ts = evalNow().toISOString();
28
+ // Test-only fault injection, as HIPPO_FAKE_NOW is for time.
29
+ const fault = process.env.HIPPO_TEST_DELIVERY_FAULT ?? '';
30
+ const payloadSession = hookPayloadSessionId(init.stdinText);
31
+ const subagent = isSubagentPayload(init.stdinText);
32
+ const envSession = init.envSessionId !== undefined && init.envSessionId !== '' ? init.envSessionId : null;
33
+ const prompt = hookPayloadString(init.stdinText, 'prompt');
34
+ const rawTurnId = hookPayloadString(init.stdinText, 'turn_id');
35
+ const hostTurnId = rawTurnId !== null && rawTurnId.trim() !== '' ? rawTurnId : null;
36
+ const hookEvent = hookPayloadString(init.stdinText, 'hook_event_name');
37
+ const sessionState = subagent
38
+ ? 'subagent'
39
+ : payloadSession !== null ? 'payload' : envSession !== null ? 'env' : 'missing';
40
+ const candidates = new Map();
41
+ const picked = new Map();
42
+ const filtered = new Set();
43
+ let facts = null;
44
+ let shown = 0;
45
+ let dropped = 0;
46
+ let disabledSeen = false;
47
+ let outcome = { state: 'empty' };
48
+ let broken = null;
49
+ let flushed = false;
50
+ const guard = (fn) => {
51
+ if (broken !== null)
52
+ return;
53
+ try {
54
+ if (fault === 'observe')
55
+ throw new Error('injected observe fault');
56
+ fn();
57
+ }
58
+ catch (error) {
59
+ broken = error instanceof Error ? error.message : String(error);
60
+ }
61
+ };
62
+ const rejectId = (id, stage, reason, score, tokens) => {
63
+ const held = candidates.get(id);
64
+ // First rejection wins; an id never offered is not a candidate of this call.
65
+ if (!held || held.reason !== null)
66
+ return;
67
+ candidates.set(id, { ...held, stage, reason, score, tokens });
68
+ };
69
+ const build = () => {
70
+ if (fault === 'build')
71
+ throw new Error('injected build fault');
72
+ const staticReused = outcome.staticReused === true;
73
+ const rows = [];
74
+ for (const p of picked.values()) {
75
+ const held = candidates.get(p.entry.id);
76
+ rows.push({
77
+ memoryId: p.entry.id,
78
+ sourceStore: p.sourceStore,
79
+ pool: p.promptRecall ? 'prompt-recall' : held?.pool === 'pin' || p.entry.pinned ? 'pin' : 'recent',
80
+ stage: 'final',
81
+ outcome: staticReused && !p.promptRecall ? 'reused' : 'emitted',
82
+ reason: null,
83
+ rank: p.rank,
84
+ score: p.score,
85
+ tokens: p.tokens,
86
+ });
87
+ }
88
+ const rejected = [];
89
+ let undecided = 0;
90
+ for (const c of candidates.values()) {
91
+ if (picked.has(c.id))
92
+ continue;
93
+ if (c.reason === null)
94
+ undecided += 1;
95
+ else
96
+ rejected.push(c);
97
+ }
98
+ rejected.sort(byDepthThenScore);
99
+ for (const c of rejected.slice(0, DELIVERY_REJECTED_ROW_CAP)) {
100
+ rows.push({
101
+ memoryId: c.id, sourceStore: c.sourceStore, pool: c.pool, stage: c.stage ?? 'load', outcome: 'rejected',
102
+ reason: c.reason, rank: null, score: c.score, tokens: c.tokens,
103
+ });
104
+ }
105
+ const overflow = Math.max(0, rejected.length - DELIVERY_REJECTED_ROW_CAP);
106
+ const emitted = outcome.emittedText ?? null;
107
+ return {
108
+ ts,
109
+ tenantId: init.tenantId,
110
+ runtime: hostTurnId !== null ? 'codex' : hookEvent !== null ? 'claude-code' : 'unknown',
111
+ eventType: hookEvent === 'UserPromptSubmit' ? 'prompt-submit' : 'pinned-manual',
112
+ surface: 'hook',
113
+ storeHash: init.storeHash,
114
+ writeStore: init.writeStore,
115
+ projectHash: facts !== null && facts.projectName !== '' ? blockHash(facts.projectName) : null,
116
+ sessionId: payloadSession ?? envSession,
117
+ sessionState,
118
+ hostTurnId,
119
+ promptHash: prompt !== null ? blockHash(prompt) : null,
120
+ promptLength: prompt?.length ?? 0,
121
+ queryHash: null,
122
+ recallTraceId: null,
123
+ blockState: disabledSeen ? 'disabled' : outcome.state,
124
+ promptRecall: facts?.promptRecall === true,
125
+ consideredCount: new Set([...candidates.keys(), ...picked.keys()]).size,
126
+ filteredCount: filtered.size,
127
+ selectedCount: picked.size,
128
+ emittedCount: rows.filter((r) => r.outcome === 'emitted').length,
129
+ rejectedCount: rejected.length + undecided,
130
+ rejectedUnlisted: overflow + undecided,
131
+ sectionsShown: shown,
132
+ sectionsDropped: dropped,
133
+ budgetTokens: facts?.budgetTokens ?? 0,
134
+ selectedTokens: [...picked.values()].reduce((sum, p) => sum + p.tokens, 0),
135
+ injectedTokens: emitted !== null ? estimateTokens(emitted) : 0,
136
+ staticHash: outcome.staticHash ?? null,
137
+ recallHash: outcome.recallHash ?? null,
138
+ emittedHash: emitted !== null ? blockHash(emitted) : null,
139
+ elapsedMs: Math.max(0, Date.now() - startedMs),
140
+ candidates: rows,
141
+ };
142
+ };
143
+ return {
144
+ root: init.root,
145
+ facts: (f) => guard(() => { facts = { ...f }; }),
146
+ sections: (s, d) => guard(() => { shown = s; dropped = d; }),
147
+ watchAdmit: (admit) => (e) => {
148
+ const ok = admit(e);
149
+ if (!ok)
150
+ guard(() => { filtered.add(e.id); });
151
+ return ok;
152
+ },
153
+ qualityDropped: (e, isGlobal) => guard(() => {
154
+ filtered.add(e.id);
155
+ if (!candidates.has(e.id)) {
156
+ candidates.set(e.id, {
157
+ id: e.id, sourceStore: storeOf(isGlobal), pool: 'recent', stage: null, reason: null, score: null, tokens: null,
158
+ });
159
+ }
160
+ rejectId(e.id, 'load', 'quality', null, null);
161
+ }),
162
+ disabled: () => guard(() => { disabledSeen = true; }),
163
+ offer: (entries, isGlobal, pool) => guard(() => {
164
+ for (const e of entries) {
165
+ const held = candidates.get(e.id);
166
+ const wanted = pool ?? (e.pinned ? 'pin' : 'recent');
167
+ // A loaded recent row the prompt-recall gate then judges belongs to that pool.
168
+ const relabel = held !== undefined && held.reason === null && held.pool === 'recent' && wanted === 'prompt-recall';
169
+ if (held !== undefined && !relabel)
170
+ continue;
171
+ candidates.set(e.id, {
172
+ id: e.id, sourceStore: storeOf(isGlobal), pool: wanted, stage: null, reason: null, score: null, tokens: null,
173
+ });
174
+ }
175
+ }),
176
+ reject: (e, stage, reason, score, tokens) => guard(() => rejectId(e.id, stage, reason, score ?? null, tokens ?? null)),
177
+ dropMissing: (before, after, stage, reason) => guard(() => {
178
+ const kept = new Set(after.map((e) => e.id));
179
+ for (const e of before)
180
+ if (!kept.has(e.id))
181
+ rejectId(e.id, stage, reason, null, null);
182
+ }),
183
+ gated: (prompt, items, gate, kept) => guard(() => {
184
+ const keptIds = new Set(kept.map((g) => g.item.id));
185
+ for (const c of items) {
186
+ if (keptIds.has(c.id))
187
+ continue;
188
+ const { score, shared } = scoreOverlap(prompt, c.tokens, gate.metric);
189
+ const cleared = score >= gate.threshold && shared >= gate.minShared;
190
+ rejectId(c.id, 'gate', cleared ? 'gate-max-items' : 'gate-below-threshold', score, null);
191
+ }
192
+ }),
193
+ selected: (items) => guard(() => {
194
+ picked.clear();
195
+ items.forEach((r, i) => {
196
+ picked.set(r.entry.id, {
197
+ entry: r.entry, rank: i + 1, score: r.score, tokens: r.tokens,
198
+ sourceStore: storeOf(r.isGlobal), promptRecall: r.promptRecall === true,
199
+ });
200
+ });
201
+ }),
202
+ delivered: (o) => guard(() => { outcome = { ...o }; }),
203
+ flush: (write) => {
204
+ if (flushed)
205
+ return;
206
+ flushed = true;
207
+ if (broken !== null) {
208
+ console.error(`[hippo] delivery ledger skipped: recorder failed: ${broken}`);
209
+ return;
210
+ }
211
+ const input = build();
212
+ if (fault === 'flush')
213
+ throw new Error('injected flush fault');
214
+ write(input);
215
+ },
216
+ };
217
+ }
218
+ //# sourceMappingURL=delivery-recorder.js.map
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { type DatabaseSyncLike } from './db.js';
19
19
  import type { RerankStep } from './search.js';
20
+ import { type DeliveryEventInput } from './delivery-recorder.js';
20
21
  /** One ranked result to persist alongside its trace row. */
21
22
  export interface RecallTraceResultInput {
22
23
  memoryId: string;
@@ -114,4 +115,72 @@ export interface RecordTraceOutcomeInput {
114
115
  * Fail-soft: never throws.
115
116
  */
116
117
  export declare function recordTraceOutcome(db: DatabaseSyncLike, input: RecordTraceOutcomeInput): void;
118
+ /** Pruned on write, counted back from the event's ts capped at the real clock, so a far-future fake time spares real rows. */
119
+ export declare const DELIVERY_LEDGER_RETENTION_DAYS = 90;
120
+ /** Lock wait for the ledger's own connection: a busy store drops the row rather than slow the hook. */
121
+ export declare const DELIVERY_LEDGER_WAIT_MS = 50;
122
+ /** Two prompt-identical events without a host turn id this close together are one turn fired twice. */
123
+ export declare const DELIVERY_DUPLICATE_WINDOW_MS = 2000;
124
+ /** One stored `delivery_candidates` row. */
125
+ export interface DeliveryCandidateRow {
126
+ event_id: number;
127
+ tenant_id: string;
128
+ memory_id: string;
129
+ source_store: string;
130
+ pool: string;
131
+ stage: string;
132
+ outcome: string;
133
+ reason: string | null;
134
+ cand_rank: number | null;
135
+ score: number | null;
136
+ tokens: number | null;
137
+ }
138
+ /** One stored `delivery_events` row with its candidate rows. */
139
+ export interface DeliveryEventRow {
140
+ id: number;
141
+ ts: string;
142
+ ledger_version: number;
143
+ tenant_id: string;
144
+ runtime: string;
145
+ event_type: string;
146
+ surface: string;
147
+ store_hash: string;
148
+ write_store: string;
149
+ project_hash: string | null;
150
+ session_id: string | null;
151
+ session_state: string;
152
+ host_turn_id: string | null;
153
+ turn_seq: number | null;
154
+ duplicate_of: number | null;
155
+ prompt_hash: string | null;
156
+ prompt_length: number;
157
+ query_hash: string | null;
158
+ recall_trace_id: number | null;
159
+ block_state: string;
160
+ prompt_recall: number;
161
+ considered_count: number;
162
+ filtered_count: number;
163
+ selected_count: number;
164
+ emitted_count: number;
165
+ rejected_count: number;
166
+ rejected_unlisted: number;
167
+ sections_shown: number;
168
+ sections_dropped: number;
169
+ budget_tokens: number;
170
+ selected_tokens: number;
171
+ injected_tokens: number;
172
+ static_hash: string | null;
173
+ recall_hash: string | null;
174
+ emitted_hash: string | null;
175
+ elapsed_ms: number;
176
+ candidates: DeliveryCandidateRow[];
177
+ }
178
+ /** One event plus its candidates in one write transaction, then prune; fail-soft. The caller must not hold a transaction on `db`. */
179
+ export declare function writeDeliveryEvent(db: DatabaseSyncLike, input: DeliveryEventInput): number | null;
180
+ /** Write on a short-lived connection that waits at most {@link DELIVERY_LEDGER_WAIT_MS} for the lock. Fail-soft. */
181
+ export declare function writeDeliveryEventAtRoot(root: string, input: DeliveryEventInput): number | null;
182
+ /** On a caller's open handle, which saves a second open and close per turn; the handle's own lock wait comes back after. */
183
+ export declare function writeDeliveryEventOnHandle(db: DatabaseSyncLike, input: DeliveryEventInput): number | null;
184
+ /** A session's delivery events in write order, each with its candidate rows; `sessionId` null reads session-less events. */
185
+ export declare function readDeliveryEvents(db: DatabaseSyncLike, tenantId: string, sessionId: string | null): DeliveryEventRow[];
117
186
  //# sourceMappingURL=recall-trace.d.ts.map
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { createHash } from 'node:crypto';
19
19
  import { openHippoDb, closeHippoDb } from './db.js';
20
+ import { DELIVERY_LEDGER_VERSION } from './delivery-recorder.js';
20
21
  /**
21
22
  * Strip a RerankStep down to {stage, multiplier, scoreBefore, scoreAfter}
22
23
  * before persisting (F3 privacy fix, codex cross-model finding). `note` is
@@ -183,4 +184,139 @@ export function recordTraceOutcome(db, input) {
183
184
  console.error(`[hippo] recall trace outcome write failed: ${error instanceof Error ? error.message : String(error)}`);
184
185
  }
185
186
  }
187
+ /** Pruned on write, counted back from the event's ts capped at the real clock, so a far-future fake time spares real rows. */
188
+ export const DELIVERY_LEDGER_RETENTION_DAYS = 90;
189
+ /** Lock wait for the ledger's own connection: a busy store drops the row rather than slow the hook. */
190
+ export const DELIVERY_LEDGER_WAIT_MS = 50;
191
+ /** Two prompt-identical events without a host turn id this close together are one turn fired twice. */
192
+ export const DELIVERY_DUPLICATE_WINDOW_MS = 2000;
193
+ const DELIVERY_EVENT_COLUMNS = [
194
+ 'ts', 'ledger_version', 'tenant_id', 'runtime', 'event_type', 'surface', 'store_hash', 'write_store', 'project_hash',
195
+ 'session_id', 'session_state', 'host_turn_id', 'turn_seq', 'duplicate_of', 'prompt_hash', 'prompt_length', 'query_hash',
196
+ 'recall_trace_id', 'block_state', 'prompt_recall', 'considered_count', 'filtered_count', 'selected_count', 'emitted_count',
197
+ 'rejected_count', 'rejected_unlisted', 'sections_shown', 'sections_dropped', 'budget_tokens', 'selected_tokens',
198
+ 'injected_tokens', 'static_hash', 'recall_hash', 'emitted_hash', 'elapsed_ms',
199
+ ];
200
+ function findDuplicateTurn(db, input) {
201
+ if (input.hostTurnId !== null) {
202
+ // SAFETY: a single `id` column, undefined when no row matches.
203
+ const row = db.prepare(`
204
+ SELECT id FROM delivery_events
205
+ WHERE tenant_id = ? AND session_id = ? AND event_type = ? AND host_turn_id = ? AND turn_seq IS NOT NULL
206
+ ORDER BY id LIMIT 1
207
+ `).get(input.tenantId, input.sessionId, input.eventType, input.hostTurnId);
208
+ return row?.id ?? null;
209
+ }
210
+ if (input.promptHash === null)
211
+ return null;
212
+ // SAFETY: rows carry exactly the `id` and `ts` columns selected.
213
+ const rows = db.prepare(`
214
+ SELECT id, ts FROM delivery_events
215
+ WHERE tenant_id = ? AND session_id = ? AND event_type = ? AND prompt_hash = ? AND host_turn_id IS NULL AND turn_seq IS NOT NULL
216
+ ORDER BY id
217
+ `).all(input.tenantId, input.sessionId, input.eventType, input.promptHash);
218
+ const at = Date.parse(input.ts);
219
+ // Absolute difference: two hook processes can commit out of ts order.
220
+ return rows.find((r) => Math.abs(Date.parse(r.ts) - at) <= DELIVERY_DUPLICATE_WINDOW_MS)?.id ?? null;
221
+ }
222
+ function nextTurnSeq(db, input) {
223
+ // SAFETY: a single MAX aggregate aliased `m`, NULL when the session has no turns yet.
224
+ const row = db.prepare(`
225
+ SELECT MAX(turn_seq) AS m FROM delivery_events
226
+ WHERE tenant_id = ? AND session_id = ? AND event_type = ? AND turn_seq IS NOT NULL
227
+ `).get(input.tenantId, input.sessionId, input.eventType);
228
+ return (row.m ?? 0) + 1;
229
+ }
230
+ /** One event plus its candidates in one write transaction, then prune; fail-soft. The caller must not hold a transaction on `db`. */
231
+ export function writeDeliveryEvent(db, input) {
232
+ try {
233
+ db.exec('BEGIN IMMEDIATE');
234
+ try {
235
+ // Missing-session and sub-agent events are not turns of a session, so they get no number and no duplicate check.
236
+ const isTurn = input.sessionId !== null && (input.sessionState === 'payload' || input.sessionState === 'env');
237
+ const duplicateOf = isTurn ? findDuplicateTurn(db, input) : null;
238
+ const turnSeq = isTurn && duplicateOf === null ? nextTurnSeq(db, input) : null;
239
+ const values = [
240
+ input.ts, DELIVERY_LEDGER_VERSION, input.tenantId, input.runtime, input.eventType, input.surface, input.storeHash,
241
+ input.writeStore, input.projectHash, input.sessionId, input.sessionState, input.hostTurnId, turnSeq, duplicateOf,
242
+ input.promptHash, input.promptLength, input.queryHash, input.recallTraceId, input.blockState, input.promptRecall ? 1 : 0,
243
+ input.consideredCount, input.filteredCount, input.selectedCount, input.emittedCount, input.rejectedCount,
244
+ input.rejectedUnlisted, input.sectionsShown, input.sectionsDropped, input.budgetTokens, input.selectedTokens,
245
+ input.injectedTokens, input.staticHash, input.recallHash, input.emittedHash, Math.round(input.elapsedMs),
246
+ ];
247
+ const eventId = Number(db.prepare(`
248
+ INSERT INTO delivery_events (${DELIVERY_EVENT_COLUMNS.join(', ')})
249
+ VALUES (${DELIVERY_EVENT_COLUMNS.map(() => '?').join(', ')})
250
+ `).run(...values).lastInsertRowid);
251
+ const insertCandidate = db.prepare(`
252
+ INSERT INTO delivery_candidates (event_id, tenant_id, memory_id, source_store, pool, stage, outcome, reason, cand_rank, score, tokens)
253
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
254
+ `);
255
+ for (const c of input.candidates) {
256
+ insertCandidate.run(eventId, input.tenantId, c.memoryId, c.sourceStore, c.pool, c.stage, c.outcome, c.reason, c.rank, c.score, c.tokens);
257
+ }
258
+ const pruneFrom = Math.min(Date.parse(input.ts), Date.now());
259
+ const cutoff = new Date(pruneFrom - DELIVERY_LEDGER_RETENTION_DAYS * 86_400_000).toISOString();
260
+ db.prepare(`DELETE FROM delivery_events WHERE ts < ?`).run(cutoff);
261
+ db.exec('COMMIT');
262
+ return eventId;
263
+ }
264
+ catch (error) {
265
+ try {
266
+ db.exec('ROLLBACK');
267
+ }
268
+ catch { /* SQLite may already have rolled back (SQLITE_FULL, IOERR); keep the original error */ }
269
+ throw error;
270
+ }
271
+ }
272
+ catch (error) {
273
+ // eslint-disable-next-line no-console
274
+ console.error(`[hippo] delivery ledger write failed: ${error instanceof Error ? error.message : String(error)}`);
275
+ return null;
276
+ }
277
+ }
278
+ /** Write on a short-lived connection that waits at most {@link DELIVERY_LEDGER_WAIT_MS} for the lock. Fail-soft. */
279
+ export function writeDeliveryEventAtRoot(root, input) {
280
+ let db;
281
+ try {
282
+ db = openHippoDb(root, { busyWaitMs: DELIVERY_LEDGER_WAIT_MS });
283
+ }
284
+ catch (error) {
285
+ // eslint-disable-next-line no-console
286
+ console.error(`[hippo] delivery ledger write failed: ${error instanceof Error ? error.message : String(error)}`);
287
+ return null;
288
+ }
289
+ try {
290
+ return writeDeliveryEvent(db, input);
291
+ }
292
+ finally {
293
+ closeHippoDb(db);
294
+ }
295
+ }
296
+ /** On a caller's open handle, which saves a second open and close per turn; the handle's own lock wait comes back after. */
297
+ export function writeDeliveryEventOnHandle(db, input) {
298
+ const prior = Math.trunc(Number(db.prepare('PRAGMA busy_timeout').get().timeout));
299
+ db.exec(`PRAGMA busy_timeout = ${DELIVERY_LEDGER_WAIT_MS}`);
300
+ try {
301
+ return writeDeliveryEvent(db, input);
302
+ }
303
+ finally {
304
+ db.exec(`PRAGMA busy_timeout = ${prior}`);
305
+ }
306
+ }
307
+ /** A session's delivery events in write order, each with its candidate rows; `sessionId` null reads session-less events. */
308
+ export function readDeliveryEvents(db, tenantId, sessionId) {
309
+ // SAFETY: SELECT * over delivery_events returns exactly the columns DeliveryEventRow names, less `candidates`.
310
+ const events = db.prepare(`SELECT * FROM delivery_events WHERE tenant_id = ? AND session_id IS ? ORDER BY id`)
311
+ .all(tenantId, sessionId);
312
+ const candidates = db.prepare(`
313
+ SELECT * FROM delivery_candidates WHERE event_id = ?
314
+ ORDER BY outcome = 'rejected', cand_rank, memory_id
315
+ `);
316
+ return events.map((e) => ({
317
+ ...e,
318
+ // SAFETY: SELECT * over delivery_candidates returns exactly the columns DeliveryCandidateRow names.
319
+ candidates: candidates.all(e.id),
320
+ }));
321
+ }
186
322
  //# sourceMappingURL=recall-trace.js.map
@@ -116,6 +116,8 @@ interface ModelTagged {
116
116
  export declare function isSyntheticMessage(message: ModelTagged): boolean;
117
117
  /** A hook payload's non-empty `session_id`, or null; with `requiredSource`, also null when its `source` differs. */
118
118
  export declare function hookPayloadSessionId(stdinText: string | undefined, requiredSource?: string | null): string | null;
119
+ /** A hook payload's string `field` as sent, or null when the payload or the field is missing or not a string. */
120
+ export declare function hookPayloadString(stdinText: string | undefined, field: string): string | null;
119
121
  /** Whether a hook fired inside a sub-agent, the only payload with `agent_id` (https://code.claude.com/docs/en/hooks#common-input-fields).
120
122
  * Its `session_id` is the parent's, so a sub-agent's blocks and compactions must not count as the parent's. */
121
123
  export declare function isSubagentPayload(stdinText: string | undefined): boolean;
@@ -174,6 +174,11 @@ export function hookPayloadSessionId(stdinText, requiredSource = null) {
174
174
  return null;
175
175
  return sessionId;
176
176
  }
177
+ /** A hook payload's string `field` as sent, or null when the payload or the field is missing or not a string. */
178
+ export function hookPayloadString(stdinText, field) {
179
+ const value = parseHookPayload(stdinText)?.[field];
180
+ return isJsonString(value) ? value : null;
181
+ }
177
182
  /** Whether a hook fired inside a sub-agent, the only payload with `agent_id` (https://code.claude.com/docs/en/hooks#common-input-fields).
178
183
  * Its `session_id` is the parent's, so a sub-agent's blocks and compactions must not count as the parent's. */
179
184
  export function isSubagentPayload(stdinText) {
package/dist/version.d.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * an ESM `import` can resolve cleanly, and a hardcoded constant survives
17
17
  * any packager that drops .json files.
18
18
  */
19
- export declare const PACKAGE_VERSION = "1.55.0";
19
+ export declare const PACKAGE_VERSION = "1.56.0";
20
20
  /** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
21
21
  export declare function compareSemver(a: string, b: string): number;
22
22
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * an ESM `import` can resolve cleanly, and a hardcoded constant survives
17
17
  * any packager that drops .json files.
18
18
  */
19
- export const PACKAGE_VERSION = '1.55.0';
19
+ export const PACKAGE_VERSION = '1.56.0';
20
20
  /** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
21
21
  export function compareSemver(a, b) {
22
22
  const parse = (v) => {
@@ -2,7 +2,7 @@
2
2
  "id": "hippo-memory",
3
3
  "name": "Hippo Memory",
4
4
  "description": "Memory for AI agents that learns what is wrong and ranks it down. Injects context at session start and captures errors.",
5
- "version": "1.55.0",
5
+ "version": "1.56.0",
6
6
 
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.55.0",
3
+ "version": "1.56.0",
4
4
  "type": "module",
5
5
  "description": "Hippo Memory plugin for OpenClaw - biologically-inspired agent memory",
6
6
  "main": "index.ts",
@@ -2,7 +2,7 @@
2
2
  "id": "hippo-memory",
3
3
  "name": "Hippo Memory",
4
4
  "description": "Memory for AI agents that learns what is wrong and ranks it down. Injects context at session start and captures errors.",
5
- "version": "1.55.0",
5
+ "version": "1.56.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.55.0",
3
+ "version": "1.56.0",
4
4
  "description": "Memory for AI agents that learns what is wrong and ranks it down. MCP server, hooks for Claude Code, OpenCode and Codex, AGENTS.md instructions for Codex, Cursor, OpenClaw and Pi. SQLite, zero runtime deps.",
5
5
  "mcpName": "io.github.kitfunso/hippo-memory",
6
6
  "type": "module",
@@ -34,6 +34,7 @@
34
34
  "pretest": "npm run build",
35
35
  "test": "vitest run",
36
36
  "test:watch": "vitest",
37
+ "test:delivery-ledger": "vitest run tests/delivery-ledger",
37
38
  "lint": "oxlint",
38
39
  "sbom": "node scripts/sbom.mjs",
39
40
  "audit:security": "npm audit --audit-level=high && npm --prefix ui audit --audit-level=high",