hippo-memory 1.52.8 → 1.53.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.
Files changed (122) hide show
  1. package/README.md +185 -101
  2. package/dist/agent-memories/apply.d.ts +47 -0
  3. package/dist/agent-memories/apply.js +253 -0
  4. package/dist/agent-memories/claude-code.d.ts +11 -0
  5. package/dist/agent-memories/claude-code.js +113 -0
  6. package/dist/agent-memories/codex.d.ts +3 -0
  7. package/dist/agent-memories/codex.js +47 -0
  8. package/dist/agent-memories/copilot.d.ts +3 -0
  9. package/dist/agent-memories/copilot.js +125 -0
  10. package/dist/agent-memories/files.d.ts +37 -0
  11. package/dist/agent-memories/files.js +77 -0
  12. package/dist/agent-memories/folder-store.d.ts +17 -0
  13. package/dist/agent-memories/folder-store.js +44 -0
  14. package/dist/agent-memories/gemini.d.ts +3 -0
  15. package/dist/agent-memories/gemini.js +103 -0
  16. package/dist/agent-memories/git.d.ts +8 -0
  17. package/dist/agent-memories/git.js +11 -0
  18. package/dist/agent-memories/keys.d.ts +9 -0
  19. package/dist/agent-memories/keys.js +20 -0
  20. package/dist/agent-memories/legacy.d.ts +17 -0
  21. package/dist/agent-memories/legacy.js +45 -0
  22. package/dist/agent-memories/markdown.d.ts +13 -0
  23. package/dist/agent-memories/markdown.js +123 -0
  24. package/dist/agent-memories/openclaw.d.ts +3 -0
  25. package/dist/agent-memories/openclaw.js +42 -0
  26. package/dist/agent-memories/plan.d.ts +78 -0
  27. package/dist/agent-memories/plan.js +123 -0
  28. package/dist/agent-memories/qwen-code.d.ts +5 -0
  29. package/dist/agent-memories/qwen-code.js +50 -0
  30. package/dist/agent-memories/report.d.ts +52 -0
  31. package/dist/agent-memories/report.js +88 -0
  32. package/dist/agent-memories/source.d.ts +16 -0
  33. package/dist/agent-memories/source.js +32 -0
  34. package/dist/agent-memories/sync.d.ts +33 -0
  35. package/dist/agent-memories/sync.js +336 -0
  36. package/dist/agent-memories/tools.d.ts +33 -0
  37. package/dist/agent-memories/tools.js +19 -0
  38. package/dist/agent-memories/types.d.ts +42 -0
  39. package/dist/agent-memories/types.js +2 -0
  40. package/dist/api.d.ts +53 -20
  41. package/dist/api.js +141 -97
  42. package/dist/audit.d.ts +2 -1
  43. package/dist/audit.js +68 -2
  44. package/dist/capture.d.ts +48 -22
  45. package/dist/capture.js +186 -161
  46. package/dist/cli.d.ts +0 -2
  47. package/dist/cli.js +750 -797
  48. package/dist/codex-patch.d.ts +12 -0
  49. package/dist/codex-patch.js +71 -0
  50. package/dist/compaction-items.d.ts +18 -0
  51. package/dist/compaction-items.js +60 -0
  52. package/dist/compaction-record.d.ts +94 -0
  53. package/dist/compaction-record.js +546 -0
  54. package/dist/config.d.ts +4 -1
  55. package/dist/config.js +13 -4
  56. package/dist/connectors/slack/types.d.ts +0 -1
  57. package/dist/consolidate.js +87 -34
  58. package/dist/context-render.d.ts +36 -0
  59. package/dist/context-render.js +154 -0
  60. package/dist/dag.js +3 -2
  61. package/dist/db.d.ts +5 -1
  62. package/dist/db.js +46 -14
  63. package/dist/dedupe.d.ts +6 -6
  64. package/dist/dedupe.js +10 -9
  65. package/dist/doctor.d.ts +1 -1
  66. package/dist/doctor.js +69 -4
  67. package/dist/dormant.d.ts +9 -3
  68. package/dist/dormant.js +26 -2
  69. package/dist/embedding-provider.d.ts +2 -1
  70. package/dist/embedding-provider.js +2 -1
  71. package/dist/embeddings.js +23 -3
  72. package/dist/extract.js +5 -1
  73. package/dist/forward-claim-detector.d.ts +1 -1
  74. package/dist/forward-claim-detector.js +1 -1
  75. package/dist/gated-write.d.ts +9 -0
  76. package/dist/gated-write.js +24 -0
  77. package/dist/graph-recall.d.ts +3 -1
  78. package/dist/graph-recall.js +5 -3
  79. package/dist/hooks.d.ts +18 -2
  80. package/dist/hooks.js +128 -32
  81. package/dist/importers.js +5 -12
  82. package/dist/judgment.d.ts +30 -0
  83. package/dist/judgment.js +122 -0
  84. package/dist/mcp/server.js +171 -210
  85. package/dist/memory.d.ts +19 -2
  86. package/dist/memory.js +35 -3
  87. package/dist/merged-row.d.ts +6 -0
  88. package/dist/merged-row.js +35 -0
  89. package/dist/multihop.d.ts +2 -1
  90. package/dist/multihop.js +7 -4
  91. package/dist/physics-state.d.ts +0 -4
  92. package/dist/physics-state.js +0 -6
  93. package/dist/predictions.d.ts +2 -17
  94. package/dist/predictions.js +2 -15
  95. package/dist/reject-flow.d.ts +7 -5
  96. package/dist/reject-flow.js +41 -12
  97. package/dist/salience.js +12 -5
  98. package/dist/same-text.d.ts +17 -0
  99. package/dist/same-text.js +38 -0
  100. package/dist/scheduler.d.ts +4 -0
  101. package/dist/scheduler.js +8 -0
  102. package/dist/search.d.ts +7 -0
  103. package/dist/search.js +16 -32
  104. package/dist/secret-detect.d.ts +2 -0
  105. package/dist/secret-detect.js +6 -0
  106. package/dist/server-detect.js +9 -33
  107. package/dist/server.js +6 -62
  108. package/dist/session-digest.d.ts +79 -0
  109. package/dist/session-digest.js +528 -0
  110. package/dist/shared.d.ts +10 -2
  111. package/dist/shared.js +44 -36
  112. package/dist/store.d.ts +9 -2
  113. package/dist/store.js +25 -2
  114. package/dist/token-ledger.d.ts +46 -8
  115. package/dist/token-ledger.js +140 -21
  116. package/dist/version.d.ts +1 -1
  117. package/dist/version.js +1 -1
  118. package/extensions/openclaw-plugin/README.md +4 -4
  119. package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
  120. package/extensions/openclaw-plugin/package.json +1 -1
  121. package/openclaw.plugin.json +2 -2
  122. package/package.json +2 -2
package/dist/shared.js CHANGED
@@ -6,16 +6,18 @@
6
6
  */
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
- import { generateId } from './memory.js';
9
+ import { generateId, COMPACTION_MEMORY_TAG } from './memory.js';
10
+ import { AGENT_MEMORY_SOURCE_PREFIX, AGENT_MEMORY_TAGS } from './agent-memories/tools.js';
10
11
  import { initStore, loadAllEntries, loadIndex, loadSearchEntries, loadRecallSearchEntries, writeEntry, readEntry, } from './store.js';
11
12
  import { passesScopeFilterForRecall, passesCliRecallScopeFilter } from './recall-scope.js';
12
- import { search, hybridSearch } from './search.js';
13
+ import { search, hybridSearch, fitBudget } from './search.js';
13
14
  import { evalNow } from './ablation.js';
14
15
  import { deriveOriginProject, classifyOriginProject, resolveGlobalRootDir } from './project-identity.js';
15
16
  import { detectSecret } from './secret-detect.js';
16
17
  import { isQuarantineScope } from './quarantine.js';
17
18
  import { RejectedValueError } from './rejection.js';
18
19
  import { embedMemory, embedAll } from './embeddings.js';
20
+ import { duplicateKey, storedTextKeys } from './same-text.js';
19
21
  /**
20
22
  * Returns the path to the global Hippo store.
21
23
  * Resolution order: $HIPPO_HOME > $XDG_DATA_HOME/hippo > ~/.hippo/
@@ -70,11 +72,8 @@ export function promoteToGlobal(localRoot, id, opts) {
70
72
  origin_project: entry.origin_project ?? deriveOriginProject(path.dirname(path.resolve(localRoot))),
71
73
  };
72
74
  writeEntry(globalRoot, globalEntry, { actor: opts?.actor });
73
- // Fire-and-forget: embedMemory's own availability gate (embeddings.ts:438)
74
- // already no-ops when embeddings are unavailable/disabled, so a pre-guard
75
- // here would be redundant (capture.ts:598 pre-guards instead; both
76
- // contracts are correct, see docs/plans/2026-07-18-global-row-embeddings.md).
77
- void embedMemory(globalRoot, globalEntry).catch(() => { });
75
+ // Fire-and-forget: embedMemory gates on availability and never rejects.
76
+ void embedMemory(globalRoot, globalEntry);
78
77
  return globalEntry;
79
78
  }
80
79
  /**
@@ -108,7 +107,7 @@ export function searchBoth(query, localRoot, globalRoot, options = {}) {
108
107
  // Remove duplicates by content (local/global IDs differ after promote/share)
109
108
  const seen = new Set();
110
109
  const deduped = tagged.filter((r) => {
111
- const key = r.entry.content.slice(0, 200).toLowerCase();
110
+ const key = duplicateKey(r.entry.content);
112
111
  if (seen.has(key))
113
112
  return false;
114
113
  seen.add(key);
@@ -136,7 +135,7 @@ export function searchBoth(query, localRoot, globalRoot, options = {}) {
136
135
  * Async version of searchBoth that calls hybridSearch instead of search.
137
136
  */
138
137
  export async function searchBothHybrid(query, localRoot, globalRoot, options = {}) {
139
- const { budget = 4000, now = evalNow(), embeddingWeight, explain, mmr, mmrLambda, localBump = 1.2, minResults, scope, includeSuperseded, asOf, tenantId, summaryDeboost, summaryFreshness, entryFilter, recallScope } = options;
138
+ const { budget = 4000, now = evalNow(), embeddingWeight, explain, mmr, mmrLambda, localBump = 1.2, minResults, cost, scope, includeSuperseded, asOf, tenantId, summaryDeboost, summaryFreshness, entryFilter, recallScope } = options;
140
139
  // When an admission filter is active, lift the per-store candidate cap
141
140
  // (default 200): excluded rows matching the query could otherwise fill the
142
141
  // window before any admitted row is even loaded (codex gating round 6).
@@ -177,10 +176,10 @@ export async function searchBothHybrid(query, localRoot, globalRoot, options = {
177
176
  if (localEntries.length === 0 && globalEntries.length === 0)
178
177
  return [];
179
178
  const localResults = await hybridSearch(query, localEntries, {
180
- budget, now, hippoRoot: localRoot, embeddingWeight, explain, mmr, mmrLambda, minResults, scope, includeSuperseded, asOf, summaryDeboost, summaryFreshness,
179
+ budget, now, hippoRoot: localRoot, embeddingWeight, explain, mmr, mmrLambda, minResults, cost, scope, includeSuperseded, asOf, summaryDeboost, summaryFreshness,
181
180
  });
182
181
  const globalResults = await hybridSearch(query, globalEntries, {
183
- budget, now, hippoRoot: globalRoot, embeddingWeight, explain, mmr, mmrLambda, minResults, scope, includeSuperseded, asOf, summaryDeboost, summaryFreshness,
182
+ budget, now, hippoRoot: globalRoot, embeddingWeight, explain, mmr, mmrLambda, minResults, cost, scope, includeSuperseded, asOf, summaryDeboost, summaryFreshness,
184
183
  });
185
184
  // Tag global results. Local memories get a configurable priority bump.
186
185
  const tagged = [
@@ -197,7 +196,7 @@ export async function searchBothHybrid(query, localRoot, globalRoot, options = {
197
196
  // Remove duplicates by content (local/global IDs differ after promote/share)
198
197
  const seen = new Set();
199
198
  const deduped = tagged.filter((r) => {
200
- const key = r.entry.content.slice(0, 200).toLowerCase();
199
+ const key = duplicateKey(r.entry.content);
201
200
  if (seen.has(key))
202
201
  return false;
203
202
  seen.add(key);
@@ -206,17 +205,7 @@ export async function searchBothHybrid(query, localRoot, globalRoot, options = {
206
205
  // T2 note: PLAIN stable score sort on purpose -- see searchBoth above;
207
206
  // same rationale (deterministic inputs + stability; local-first on ties).
208
207
  deduped.sort((a, b) => b.score - a.score);
209
- // Apply combined token budget (guarantee at least minResults items)
210
- const effectiveMinHybrid = minResults ?? 1;
211
- const results = [];
212
- let usedTokens = 0;
213
- for (let i = 0; i < deduped.length; i++) {
214
- if (results.length >= effectiveMinHybrid && usedTokens + deduped[i].tokens > budget)
215
- continue;
216
- usedTokens += deduped[i].tokens;
217
- results.push(deduped[i]);
218
- }
219
- return results;
208
+ return fitBudget(deduped, budget, minResults ?? 1, cost);
220
209
  }
221
210
  // ---------------------------------------------------------------------------
222
211
  // Multi-agent shared memory
@@ -232,6 +221,22 @@ const TRANSFERABLE_TAGS = new Set([
232
221
  'powershell', 'quant', 'backtest', 'pattern', 'rule', 'gotcha',
233
222
  'sub-agent', 'review', 'best-practice',
234
223
  ]);
224
+ /** Tags whose rows only a hand-run share or promote may copy to the global store; derived rows inherit them. */
225
+ export const NEVER_AUTO_SHARE_TAGS = new Set([
226
+ 'git-learned',
227
+ 'session-digest',
228
+ ...AGENT_MEMORY_TAGS,
229
+ ]);
230
+ export function neverAutoShareTags(sources) {
231
+ return [...NEVER_AUTO_SHARE_TAGS].filter((tag) => sources.some((s) => s.tags.includes(tag)));
232
+ }
233
+ /** Tags whose rows sleep keeps as written: never merged, never sent to LLM extraction. Conflict detection keeps its own list. */
234
+ export const NO_MERGE_TAGS = new Set([
235
+ 'extracted',
236
+ 'session-digest',
237
+ COMPACTION_MEMORY_TAG,
238
+ ...AGENT_MEMORY_TAGS,
239
+ ]);
235
240
  /**
236
241
  * Estimate how well a memory would transfer to other projects.
237
242
  * Returns 0..1 where >0.5 = good candidate for sharing.
@@ -301,10 +306,9 @@ export function shareMemory(localRoot, id, options = {}) {
301
306
  // Single-row producer: embed here unless the caller opts out. autoShare
302
307
  // sets skipEmbed so it can batch its whole run through one embedAll() at
303
308
  // the end instead of N serialized full-index rewrites (embedMemory rewrites
304
- // the whole index JSON per call). Same redundant-pre-guard reasoning as
305
- // promoteToGlobal above.
309
+ // the whole index JSON per call).
306
310
  if (!options.skipEmbed) {
307
- void embedMemory(globalRoot, globalEntry).catch(() => { });
311
+ void embedMemory(globalRoot, globalEntry);
308
312
  }
309
313
  return globalEntry;
310
314
  }
@@ -358,7 +362,7 @@ export function listPeers(globalRoot, tenantId) {
358
362
  .sort((a, b) => b.count - a.count);
359
363
  }
360
364
  /**
361
- * Auto-share: find local memories with high transfer scores that aren't already global.
365
+ * Auto-share: local memories with high transfer scores, not already global, no NEVER_AUTO_SHARE_TAGS tag.
362
366
  * Returns the list of shared entries.
363
367
  *
364
368
  * L9: `options.tenantId` is opt-in. When provided, the LOCAL-entries read is
@@ -392,17 +396,22 @@ export function autoShare(localRoot, options = {}) {
392
396
  // per-tenant filtering on the global root would defeat the purpose.
393
397
  const globalEntries = loadAllEntries(globalRoot);
394
398
  // Build set of global content hashes to avoid duplicates
395
- const globalContentSet = new Set(globalEntries.map((e) => e.content.toLowerCase().trim().slice(0, 200)));
399
+ const globalContentSet = storedTextKeys(globalEntries);
396
400
  const candidates = localEntries.filter((entry) => {
397
401
  // CD5: shareMemory refuses quarantined rows; filtering here keeps sleep from aborting on one.
398
402
  if (isQuarantineScope(entry.scope ?? null))
399
403
  return false;
404
+ // Before the score: these rows describe one project only, and a git seed's 'error' tag clears the bar.
405
+ if (entry.tags.some((t) => NEVER_AUTO_SHARE_TAGS.has(t)) || entry.source.startsWith(AGENT_MEMORY_SOURCE_PREFIX)) {
406
+ if (options.stats)
407
+ options.stats.neverAutoShareSkipped = (options.stats.neverAutoShareSkipped ?? 0) + 1;
408
+ return false;
409
+ }
400
410
  const score = transferScore(entry);
401
411
  if (score < minScore)
402
412
  return false;
403
- // Skip if already shared (approximate content match)
404
- const contentKey = entry.content.toLowerCase().trim().slice(0, 200);
405
- if (globalContentSet.has(contentKey))
413
+ // Skip if already shared (same text apart from spacing)
414
+ if (globalContentSet.has(duplicateKey(entry.content)))
406
415
  return false;
407
416
  // v39 S4 producer veto: secret rows never auto-share, regardless of
408
417
  // transfer score. (shareMemory would throw; filtering here keeps the
@@ -476,16 +485,15 @@ export function syncGlobalToLocal(localRoot, globalRoot, opts = {}) {
476
485
  // only stamps when the field is missing).
477
486
  const currentName = deriveOriginProject(path.dirname(path.resolve(localRoot)));
478
487
  let count = 0;
479
- // AT1 (plan §3 containment, the roadmap threat this whole feature targets
480
- // — a locally-rejected value must not silently resurrect via sync down
481
- // from global): per-item catch, no signature change (bare number return;
482
- // see the syncGlobalToLocal callers in cli.ts + tests). Counted locally
483
- // and printed as one summary line, same pattern as learnFromMemoryMd.
488
+ // A locally rejected value must not come back through sync down: caught per item, printed as one line.
484
489
  let rejected = 0;
485
490
  for (const entry of globalEntries) {
486
491
  // Skip if already present by ID
487
492
  if (localIndex.entries[entry.id])
488
493
  continue;
494
+ // Only the global store's user pass sets an imported note's row aside, so a copy would outlive the note.
495
+ if (entry.source.startsWith(AGENT_MEMORY_SOURCE_PREFIX))
496
+ continue;
489
497
  if (localText.has(textKey(entry)))
490
498
  continue;
491
499
  if (detectSecret(entry).flagged)
package/dist/store.d.ts CHANGED
@@ -367,7 +367,7 @@ export declare function loadChildrenOf(hippoRoot: string, parentId: string, tena
367
367
  * Default keeps `deleteEntry` byte-identical to its pre-split behavior.
368
368
  *
369
369
  * Returns `{tenantId, dagParentId}` for the removed row, or `null` if no row with `id`
370
- * existed or `automatic` refused it (pinned or raw at DELETE time, so a late pin wins).
370
+ * existed or `automatic` refused it (pinned, raw or kept for good at DELETE time, so a late pin wins).
371
371
  */
372
372
  export declare function deleteEntryCore(db: ReturnType<typeof openHippoDb>, id: string, opts?: {
373
373
  actor?: string;
@@ -400,7 +400,7 @@ export declare function deleteEntry(hippoRoot: string, id: string, opts?: {
400
400
  * `dormant` (src/dormant.ts): each move's snapshot is inserted into `dormant_memories` and its `memories` row
401
401
  * leaves exactly like a delete (FTS row, DAG parent dirty-mark, mirrors), in the same transaction, so a memory
402
402
  * is never in both places or in neither. Deletes and moves both skip rows that are no longer auto-deletable
403
- * (pinned or raw since the caller decided). Returns the ids that left `memories`, deleted or moved. */
403
+ * (pinned, raw or kept for good since the caller decided). Returns the ids that left `memories`, deleted or moved. */
404
404
  export declare function batchWriteAndDelete(hippoRoot: string, toWrite: MemoryEntry[], toDeleteIds: string[], opts?: {
405
405
  snapshot?: ReadonlyMap<string, MemoryEntry>;
406
406
  dormant?: DormantMove[];
@@ -415,6 +415,12 @@ export declare function batchWriteAndDelete(hippoRoot: string, toWrite: MemoryEn
415
415
  export declare function loadAllEntries(hippoRoot: string, tenantId?: string): MemoryEntry[];
416
416
  /** Every memory row on an open connection, so a caller can read inside its own transaction. */
417
417
  export declare function selectAllEntries(db: DatabaseSyncLike, tenantId?: string): MemoryEntry[];
418
+ /** Live rows whose source starts with `prefix`, on the caller's handle; LIKE folds case, so the prefix is checked again exactly. */
419
+ export declare function selectLiveEntriesBySourcePrefix(db: DatabaseSyncLike, tenantId: string, prefix: string): MemoryEntry[];
420
+ /** Rewrites a live row's tags and its full-text row on the caller's transaction, with no audit row. */
421
+ export declare function setEntryTagsInTx(db: DatabaseSyncLike, entry: MemoryEntry): void;
422
+ /** Removes a row from `memories` and full-text search on the caller's transaction, as sleep's dormant move does, and marks its summary parent dirty. */
423
+ export declare function deleteEntryRowInTx(db: DatabaseSyncLike, entry: MemoryEntry, actor: string): void;
418
424
  export declare function loadContentsWithTag(hippoRoot: string, tenantId: string, tag: string): string[];
419
425
  export interface AmbientRecallRequest {
420
426
  terms: string[];
@@ -645,6 +651,7 @@ export declare function loadLatestHandoff(hippoRoot: string, tenantId: string, s
645
651
  unfinishedOnly?: boolean;
646
652
  maxAgeMs?: number;
647
653
  scopeFilter?: 'default-deny';
654
+ excludeSessionId?: string;
648
655
  }): SessionHandoff | null;
649
656
  /**
650
657
  * Load a specific handoff by its row ID.
package/dist/store.js CHANGED
@@ -1555,7 +1555,7 @@ export function loadChildrenOf(hippoRoot, parentId, tenantId) {
1555
1555
  * Default keeps `deleteEntry` byte-identical to its pre-split behavior.
1556
1556
  *
1557
1557
  * Returns `{tenantId, dagParentId}` for the removed row, or `null` if no row with `id`
1558
- * existed or `automatic` refused it (pinned or raw at DELETE time, so a late pin wins).
1558
+ * existed or `automatic` refused it (pinned, raw or kept for good at DELETE time, so a late pin wins).
1559
1559
  */
1560
1560
  export function deleteEntryCore(db, id, opts) {
1561
1561
  // SAFETY: row's shape matches the three columns named in the SELECT above.
@@ -1622,7 +1622,7 @@ function mergeOwnChanges(base, ours, live) {
1622
1622
  * `dormant` (src/dormant.ts): each move's snapshot is inserted into `dormant_memories` and its `memories` row
1623
1623
  * leaves exactly like a delete (FTS row, DAG parent dirty-mark, mirrors), in the same transaction, so a memory
1624
1624
  * is never in both places or in neither. Deletes and moves both skip rows that are no longer auto-deletable
1625
- * (pinned or raw since the caller decided). Returns the ids that left `memories`, deleted or moved. */
1625
+ * (pinned, raw or kept for good since the caller decided). Returns the ids that left `memories`, deleted or moved. */
1626
1626
  export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds, opts) {
1627
1627
  const dormantMoves = opts?.dormant ?? [];
1628
1628
  if (toWrite.length === 0 && toDeleteIds.length === 0 && dormantMoves.length === 0)
@@ -1810,6 +1810,25 @@ export function selectAllEntries(db, tenantId) {
1810
1810
  : db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories ORDER BY created ASC, id ASC`).all();
1811
1811
  return rows.map(rowToEntry);
1812
1812
  }
1813
+ /** Live rows whose source starts with `prefix`, on the caller's handle; LIKE folds case, so the prefix is checked again exactly. */
1814
+ export function selectLiveEntriesBySourcePrefix(db, tenantId, prefix) {
1815
+ // SAFETY: selects exactly MEMORY_SELECT_COLUMNS, matching MemoryRow's field set.
1816
+ const rows = db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE tenant_id = ? AND superseded_by IS NULL AND source LIKE ? ESCAPE '\\'`).all(tenantId, `${prefix.replace(/[%_\\]/g, '\\$&')}%`);
1817
+ return rows.map(rowToEntry).filter((entry) => entry.source.startsWith(prefix));
1818
+ }
1819
+ /** Rewrites a live row's tags and its full-text row on the caller's transaction, with no audit row. */
1820
+ export function setEntryTagsInTx(db, entry) {
1821
+ db.prepare(`UPDATE memories SET tags_json = ?, updated_at = datetime('now') WHERE id = ? AND tenant_id = ?`)
1822
+ .run(JSON.stringify(entry.tags), entry.id, entry.tenantId);
1823
+ syncFtsRow(db, entry);
1824
+ }
1825
+ /** Removes a row from `memories` and full-text search on the caller's transaction, as sleep's dormant move does, and marks its summary parent dirty. */
1826
+ export function deleteEntryRowInTx(db, entry, actor) {
1827
+ db.prepare('DELETE FROM memories WHERE id = ? AND tenant_id = ?').run(entry.id, entry.tenantId);
1828
+ deleteFtsRow(db, entry.id);
1829
+ if (entry.dag_parent_id)
1830
+ markSummaryDirtyInTx(db, entry.dag_parent_id, entry.tenantId, actor);
1831
+ }
1813
1832
  // Content of every tenant row tagged `tag`, without reading the rest of the store.
1814
1833
  // `instr` is a substring prefilter over the raw JSON; `includes` below re-checks exactly.
1815
1834
  export function loadContentsWithTag(hippoRoot, tenantId, tag) {
@@ -2835,6 +2854,10 @@ export function loadLatestHandoff(hippoRoot, tenantId, sessionId, opts = {}) {
2835
2854
  conditions.push('session_id = ?');
2836
2855
  params.push(sessionId);
2837
2856
  }
2857
+ if (opts.excludeSessionId) {
2858
+ conditions.push('session_id != ?');
2859
+ params.push(opts.excludeSessionId);
2860
+ }
2838
2861
  if (opts.unfinishedOnly) {
2839
2862
  // codex P2: restrict to each session's newest revision first — stampHandoffOutcome
2840
2863
  // only stamps the newest row, so an older null-outcome revision must not resurrect.
@@ -3,20 +3,24 @@ import type { DatabaseSyncLike } from './db.js';
3
3
  * Where a block of memory text was sent.
4
4
  * - `hook`: the per-prompt `UserPromptSubmit` hook (`hippo context --pinned-only`).
5
5
  * - `hook_recall`: the same hook's Z1 prompt-recall section (docs/plans/2026-09-26-z1-prompt-recall.md).
6
+ * - `compact_resume`: the snapshot the SessionStart(compact) hook prints (`hippo compact-resume`).
6
7
  * - `context`, `recall`: the CLI commands.
7
8
  * - `mcp_recall`, `mcp_context`: the MCP tools.
8
9
  * - `http_recall`, `http_context`, `http_assemble`: the HTTP API.
9
10
  */
10
- export type TokenSurface = 'hook' | 'hook_recall' | 'context' | 'recall' | 'mcp_recall' | 'mcp_context' | 'http_recall' | 'http_context' | 'http_assemble';
11
+ export type TokenSurface = 'hook' | 'hook_recall' | 'compact_resume' | 'context' | 'recall' | 'mcp_recall' | 'mcp_context' | 'http_recall' | 'http_context' | 'http_assemble';
11
12
  /** All surfaces, in report order. */
12
13
  export declare const TOKEN_SURFACES: readonly TokenSurface[];
14
+ /** Surfaces whose re-reads are counted: only a hook payload tells a sub-agent's block from its parent's, as both carry one session id. */
15
+ export declare const REREAD_SURFACES: readonly TokenSurface[];
13
16
  /**
14
17
  * What happened to a block.
15
18
  * - `inject`: sent to the agent.
16
19
  * - `skip`: identical to the session's last injected block, so not sent again.
17
20
  * - `reset`: the host compacted its context, so the next block must be sent.
21
+ * - `reread`: tokens later calls read again, booked at session end as one row per session, hook surface and UTC day of the calls.
18
22
  */
19
- export type TokenEvent = 'inject' | 'skip' | 'reset';
23
+ export type TokenEvent = 'inject' | 'skip' | 'reset' | 'reread';
20
24
  /** Rows older than this are pruned on write. */
21
25
  export declare const TOKEN_LEDGER_RETENTION_DAYS = 90;
22
26
  /**
@@ -33,7 +37,7 @@ export interface TokenUse {
33
37
  sessionId?: string | null;
34
38
  surface: TokenSurface;
35
39
  event: TokenEvent;
36
- /** Memories (and continuity blocks) in the text. */
40
+ /** Memories (and continuity blocks) in the text; for a `reread`, how many times blocks were read again. */
37
41
  items: number;
38
42
  /** Estimated tokens of the text; for a `skip`, the tokens not sent. */
39
43
  tokens: number;
@@ -77,6 +81,8 @@ export interface TokenSurfaceSummary {
77
81
  skipped: number;
78
82
  /** Tokens those skipped blocks would have cost. */
79
83
  tokensAvoided: number;
84
+ /** Tokens of these blocks that later model calls read again, from sessions that have ended; 0 off {@link REREAD_SURFACES}. */
85
+ tokensReread: number;
80
86
  /** Distinct session ids seen (rows without one are not counted). */
81
87
  sessions: number;
82
88
  }
@@ -87,20 +93,32 @@ export interface TokenSummary {
87
93
  surfaces: TokenSurfaceSummary[];
88
94
  totalTokens: number;
89
95
  totalTokensAvoided: number;
96
+ totalTokensReread: number;
90
97
  /** Mean tokens sent per session, over rows that carry a session id. */
91
98
  meanTokensPerSession: number;
99
+ /** Distinct session ids in the window. */
100
+ sessions: number;
101
+ /** Sessions that sent, skipped or re-read a {@link REREAD_SURFACES} block, the ones whose re-reads can be counted. */
102
+ hookSessions: number;
103
+ /** Sessions whose re-reads were counted at session end; open or crashed sessions are not. */
104
+ rereadSessions: number;
92
105
  }
93
106
  /**
94
107
  * Sum the ledger for one tenant since `sinceIso`. Surfaces with no rows are
95
108
  * omitted.
96
109
  */
97
110
  export declare function summarizeTokenUse(db: DatabaseSyncLike, tenantId: string, sinceIso: string): TokenSummary;
98
- /**
99
- * The `session_id` of a Claude Code hook payload on stdin, or null when the
100
- * text is empty, malformed, has no non-empty session id, or (with
101
- * `requiredSource`) a different `source`.
102
- */
111
+ /** JSON-value string check without a runtime `typeof` (anti-slop rule). */
112
+ interface ModelTagged {
113
+ model?: unknown;
114
+ }
115
+ /** Claude Code writes its own API errors and limit notices as assistant lines from this model; no model call made them. */
116
+ export declare function isSyntheticMessage(message: ModelTagged): boolean;
117
+ /** A hook payload's non-empty `session_id`, or null; with `requiredSource`, also null when its `source` differs. */
103
118
  export declare function hookPayloadSessionId(stdinText: string | undefined, requiredSource?: string | null): string | null;
119
+ /** 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
+ * Its `session_id` is the parent's, so a sub-agent's blocks and compactions must not count as the parent's. */
121
+ export declare function isSubagentPayload(stdinText: string | undefined): boolean;
104
122
  /** Tokens hippo sent and skipped in one session, for {@link tokensBySession}. */
105
123
  export interface SessionTokens {
106
124
  sessionId: string;
@@ -117,4 +135,24 @@ export interface SessionTokens {
117
135
  * transcript file name, so these join to the host's own usage records.
118
136
  */
119
137
  export declare function tokensBySession(db: DatabaseSyncLike, tenantId: string, sinceIso: string): SessionTokens[];
138
+ /** One model API call in a host transcript, for {@link recordRereads}. */
139
+ export interface ApiCall {
140
+ /** Epoch milliseconds of the call's first transcript line. */
141
+ at: number;
142
+ /** Compactions before the call, in file order. */
143
+ compactions: number;
144
+ }
145
+ /** What {@link readApiCalls} found in a transcript. */
146
+ export interface TranscriptCalls {
147
+ calls: ApiCall[];
148
+ /** Candidate lines that were not valid JSON, skipped. */
149
+ malformed: number;
150
+ }
151
+ /** Main-thread model calls in a Claude Code transcript, in file order; rejects when the file cannot be read. */
152
+ export declare function readApiCalls(transcriptPath: string): Promise<TranscriptCalls>;
153
+ /** Calls that carried a block sent at `at` (epoch ms): every later call in the first later call's context window. */
154
+ export declare function carryingCalls(calls: readonly ApiCall[], at: number): ApiCall[];
155
+ /** Replace a session's `reread` rows in one transaction, one per {@link REREAD_SURFACES} surface and UTC day; returns the tokens booked. */
156
+ export declare function recordRereads(db: DatabaseSyncLike, tenantId: string, sessionId: string, calls: readonly ApiCall[]): number;
157
+ export {};
120
158
  //# sourceMappingURL=token-ledger.d.ts.map
@@ -3,9 +3,13 @@
3
3
  * and how many tokens it costs.
4
4
  *
5
5
  * One row per block of memory text sent to an agent, on every surface: the
6
- * per-prompt hook, `hippo context`, `hippo recall`, the MCP tools and the
7
- * HTTP API. The ledger answers the question a buyer asks first ("what does
8
- * this cost me per session?") and is the input for the token-savings evals.
6
+ * per-prompt hook, the block `hippo compact-resume` restores after compaction,
7
+ * `hippo context`, `hippo recall`, the MCP tools and the HTTP API. The ledger
8
+ * answers the question a buyer asks first ("what does this cost me per
9
+ * session?") and is the input for the token-savings evals.
10
+ *
11
+ * Every later model call re-reads a sent block until the host compacts; at session end the worker counts
12
+ * those calls from the transcript as `reread` rows for the {@link REREAD_SURFACES} blocks, dated by call day.
9
13
  *
10
14
  * It also backs TE2, inject only on change: the per-prompt hook compares the
11
15
  * hash of the block it is about to send with the last block it sent in the
@@ -15,15 +19,19 @@
15
19
  * every budget in hippo uses. Rows hold counts, surfaces, session ids and
16
20
  * hashes, never memory content or query text.
17
21
  *
18
- * DB-only helpers: the caller owns the handle. Writes are best-effort at the
19
- * call sites; a ledger failure must never break recall.
22
+ * DB helpers take the caller's handle; {@link readApiCalls} streams one
23
+ * transcript file. Writes are best-effort at the call sites; a ledger failure
24
+ * must never break recall.
20
25
  */
21
26
  import { createHash } from 'node:crypto';
27
+ import { open } from 'node:fs/promises';
22
28
  /** All surfaces, in report order. */
23
29
  export const TOKEN_SURFACES = [
24
- 'hook', 'hook_recall', 'context', 'recall', 'mcp_recall', 'mcp_context',
30
+ 'hook', 'hook_recall', 'compact_resume', 'context', 'recall', 'mcp_recall', 'mcp_context',
25
31
  'http_recall', 'http_context', 'http_assemble',
26
32
  ];
33
+ /** Surfaces whose re-reads are counted: only a hook payload tells a sub-agent's block from its parent's, as both carry one session id. */
34
+ export const REREAD_SURFACES = ['hook', 'hook_recall', 'compact_resume'];
27
35
  /** Rows older than this are pruned on write. */
28
36
  export const TOKEN_LEDGER_RETENTION_DAYS = 90;
29
37
  /**
@@ -90,6 +98,7 @@ export function summarizeTokenUse(db, tenantId, sinceIso) {
90
98
  SUM(CASE WHEN event = 'inject' THEN tokens ELSE 0 END) AS tokens,
91
99
  SUM(CASE WHEN event = 'skip' THEN 1 ELSE 0 END) AS skipped,
92
100
  SUM(CASE WHEN event = 'skip' THEN tokens ELSE 0 END) AS avoided,
101
+ SUM(CASE WHEN event = 'reread' THEN tokens ELSE 0 END) AS reread,
93
102
  COUNT(DISTINCT session_id) AS sessions
94
103
  FROM token_ledger
95
104
  WHERE tenant_id = ? AND ts >= ?
@@ -106,14 +115,17 @@ export function summarizeTokenUse(db, tenantId, sinceIso) {
106
115
  tokens: Number(r.tokens),
107
116
  skipped: Number(r.skipped),
108
117
  tokensAvoided: Number(r.avoided),
118
+ tokensReread: Number(r.reread),
109
119
  sessions: Number(r.sessions),
110
120
  });
111
121
  }
112
- // SAFETY: the SELECT names exactly these two aggregate columns.
122
+ // SAFETY: the SELECT names exactly these four aggregate columns.
113
123
  const perSession = db.prepare(`SELECT COUNT(DISTINCT session_id) AS sessions,
124
+ COUNT(DISTINCT CASE WHEN event <> 'reset' AND surface IN (${REREAD_SURFACES.map(() => '?').join(', ')}) THEN session_id END) AS hook_sessions,
125
+ COUNT(DISTINCT CASE WHEN event = 'reread' THEN session_id END) AS reread_sessions,
114
126
  SUM(CASE WHEN event = 'inject' THEN tokens ELSE 0 END) AS tokens
115
127
  FROM token_ledger
116
- WHERE tenant_id = ? AND ts >= ? AND session_id IS NOT NULL`).get(tenantId, sinceIso);
128
+ WHERE tenant_id = ? AND ts >= ? AND session_id IS NOT NULL`).get(...REREAD_SURFACES, tenantId, sinceIso);
117
129
  const sessionCount = Number(perSession?.sessions ?? 0);
118
130
  const sessionTokens = Number(perSession?.tokens ?? 0);
119
131
  return {
@@ -121,10 +133,17 @@ export function summarizeTokenUse(db, tenantId, sinceIso) {
121
133
  surfaces,
122
134
  totalTokens: surfaces.reduce((s, x) => s + x.tokens, 0),
123
135
  totalTokensAvoided: surfaces.reduce((s, x) => s + x.tokensAvoided, 0),
136
+ totalTokensReread: surfaces.reduce((s, x) => s + x.tokensReread, 0),
124
137
  meanTokensPerSession: sessionCount > 0 ? Math.round(sessionTokens / sessionCount) : 0,
138
+ sessions: sessionCount,
139
+ hookSessions: Number(perSession?.hook_sessions ?? 0),
140
+ rereadSessions: Number(perSession?.reread_sessions ?? 0),
125
141
  };
126
142
  }
127
- /** JSON-value string check without a runtime `typeof` (anti-slop rule). */
143
+ /** Claude Code writes its own API errors and limit notices as assistant lines from this model; no model call made them. */
144
+ export function isSyntheticMessage(message) {
145
+ return message.model === '<synthetic>';
146
+ }
128
147
  function isJsonString(value) {
129
148
  return value !== undefined && value !== null && value.constructor === String;
130
149
  }
@@ -132,31 +151,35 @@ function isJsonString(value) {
132
151
  function isJsonObject(value) {
133
152
  return value !== undefined && value !== null && !Array.isArray(value) && value.constructor === Object;
134
153
  }
135
- /**
136
- * The `session_id` of a Claude Code hook payload on stdin, or null when the
137
- * text is empty, malformed, has no non-empty session id, or (with
138
- * `requiredSource`) a different `source`.
139
- */
140
- export function hookPayloadSessionId(stdinText, requiredSource = null) {
154
+ /** A Claude Code hook payload on stdin as a JSON object; null when empty, malformed or not an object. */
155
+ function parseHookPayload(stdinText) {
141
156
  if (!stdinText || stdinText.trim() === '')
142
157
  return null;
143
- let payload;
144
158
  try {
145
159
  // SAFETY: JSON.parse returns a JSON value by definition.
146
- payload = JSON.parse(stdinText.trim());
160
+ const payload = JSON.parse(stdinText.trim());
161
+ return isJsonObject(payload) ? payload : null;
147
162
  }
148
163
  catch {
149
164
  return null;
150
165
  }
151
- if (!isJsonObject(payload))
152
- return null;
153
- const sessionId = payload.session_id;
154
- if (!isJsonString(sessionId) || sessionId.trim() === '')
166
+ }
167
+ /** A hook payload's non-empty `session_id`, or null; with `requiredSource`, also null when its `source` differs. */
168
+ export function hookPayloadSessionId(stdinText, requiredSource = null) {
169
+ const payload = parseHookPayload(stdinText);
170
+ const sessionId = payload?.session_id;
171
+ if (!payload || !isJsonString(sessionId) || sessionId.trim() === '')
155
172
  return null;
156
173
  if (requiredSource !== null && payload.source !== requiredSource)
157
174
  return null;
158
175
  return sessionId;
159
176
  }
177
+ /** 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
+ * Its `session_id` is the parent's, so a sub-agent's blocks and compactions must not count as the parent's. */
179
+ export function isSubagentPayload(stdinText) {
180
+ const agentId = parseHookPayload(stdinText)?.agent_id;
181
+ return isJsonString(agentId) && agentId.trim() !== '';
182
+ }
160
183
  /**
161
184
  * Ledger totals per session id since `sinceIso`, across every surface.
162
185
  * Claude Code hook rows carry the host's session id, which is also the
@@ -178,4 +201,100 @@ export function tokensBySession(db, tenantId, sinceIso) {
178
201
  injections: Number(r.injections),
179
202
  }));
180
203
  }
204
+ /** Main-thread model calls in a Claude Code transcript, in file order; rejects when the file cannot be read. */
205
+ export async function readApiCalls(transcriptPath) {
206
+ const file = await open(transcriptPath);
207
+ const calls = [];
208
+ const seen = new Set();
209
+ let compactions = 0;
210
+ let malformed = 0;
211
+ try {
212
+ for await (const line of file.readLines({ encoding: 'utf8' })) {
213
+ // Transcripts can pass 100 MB, so only lines that can be a call or a boundary are parsed.
214
+ if (!line.includes('"usage"') && !line.includes('"compact_boundary"'))
215
+ continue;
216
+ let entry;
217
+ try {
218
+ // SAFETY: JSON.parse returns a JSON value by definition.
219
+ entry = JSON.parse(line);
220
+ }
221
+ catch {
222
+ malformed += 1; // reported by the caller: one torn line must not void the session
223
+ continue;
224
+ }
225
+ // Sidechain calls, sidechain compactions and `<synthetic>` messages never touch the main context.
226
+ if (!isJsonObject(entry) || entry.isSidechain === true)
227
+ continue;
228
+ if (entry.subtype === 'compact_boundary') {
229
+ compactions += 1;
230
+ continue;
231
+ }
232
+ // One call spans lines sharing a message id.
233
+ const message = entry.message;
234
+ if (entry.type !== 'assistant' || !isJsonObject(message))
235
+ continue;
236
+ if (!isJsonObject(message.usage) || isSyntheticMessage(message) || !isJsonString(message.id))
237
+ continue;
238
+ if (seen.has(message.id))
239
+ continue;
240
+ seen.add(message.id);
241
+ const at = isJsonString(entry.timestamp) ? Date.parse(entry.timestamp) : Number.NaN;
242
+ if (!Number.isNaN(at))
243
+ calls.push({ at, compactions });
244
+ }
245
+ }
246
+ finally {
247
+ await file.close();
248
+ }
249
+ return { calls, malformed };
250
+ }
251
+ /** Calls that carried a block sent at `at` (epoch ms): every later call in the first later call's context window. */
252
+ export function carryingCalls(calls, at) {
253
+ // Windows come from file order, not boundary timestamps: the compact-resume block is booked before its boundary's timestamp.
254
+ const first = calls.find((call) => call.at > at);
255
+ if (!first)
256
+ return [];
257
+ return calls.filter((call) => call.at > at && call.compactions === first.compactions);
258
+ }
259
+ /** Replace a session's `reread` rows in one transaction, one per {@link REREAD_SURFACES} surface and UTC day; returns the tokens booked. */
260
+ export function recordRereads(db, tenantId, sessionId, calls) {
261
+ db.exec('BEGIN IMMEDIATE');
262
+ let committed = false;
263
+ try {
264
+ // SAFETY: the SELECT names exactly these three columns.
265
+ const rows = db.prepare(`SELECT ts, surface, tokens FROM token_ledger
266
+ WHERE tenant_id = ? AND session_id = ? AND event = 'inject' AND surface IN (${REREAD_SURFACES.map(() => '?').join(', ')})`).all(tenantId, sessionId, ...REREAD_SURFACES);
267
+ const days = new Map();
268
+ const add = (surface, at, rereads, tokens) => {
269
+ const key = `${surface} ${new Date(at).toISOString().slice(0, 10)}`;
270
+ const day = days.get(key) ?? { surface, at, rereads: 0, tokens: 0 };
271
+ days.set(key, { surface, at: Math.max(day.at, at), rereads: day.rereads + rereads, tokens: day.tokens + tokens });
272
+ };
273
+ for (const row of rows) {
274
+ const sentAt = Date.parse(row.ts);
275
+ // Dated by when the calls happened, so a --days window counts re-reads in it; the empty send-day row keeps the session in coverage.
276
+ add(row.surface, sentAt, 0, 0);
277
+ // The first carrying call is the send itself; every later one is a re-read.
278
+ for (const call of carryingCalls(calls, sentAt).slice(1))
279
+ add(row.surface, call.at, 1, Number(row.tokens));
280
+ }
281
+ db.prepare(`DELETE FROM token_ledger WHERE tenant_id = ? AND session_id = ? AND event = 'reread'`).run(tenantId, sessionId);
282
+ for (const day of days.values()) {
283
+ recordTokenUse(db, {
284
+ tenantId, sessionId, surface: day.surface, event: 'reread', items: day.rereads, tokens: day.tokens, now: new Date(day.at).toISOString(),
285
+ });
286
+ }
287
+ db.exec('COMMIT');
288
+ committed = true;
289
+ return [...days.values()].reduce((sum, day) => sum + day.tokens, 0);
290
+ }
291
+ finally {
292
+ if (!committed) {
293
+ try {
294
+ db.exec('ROLLBACK');
295
+ }
296
+ catch { /* preserve the original throw */ }
297
+ }
298
+ }
299
+ }
181
300
  //# sourceMappingURL=token-ledger.js.map
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.52.8";
19
+ export declare const PACKAGE_VERSION = "1.53.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.52.8';
19
+ export const PACKAGE_VERSION = '1.53.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) => {