hippo-memory 1.52.7 → 1.52.9

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 (84) hide show
  1. package/README.md +159 -99
  2. package/dist/api.d.ts +52 -18
  3. package/dist/api.js +155 -86
  4. package/dist/audit.d.ts +2 -1
  5. package/dist/audit.js +63 -0
  6. package/dist/autolearn.js +3 -2
  7. package/dist/capture.d.ts +40 -0
  8. package/dist/capture.js +141 -119
  9. package/dist/churn-git.js +2 -2
  10. package/dist/cli.d.ts +1 -4
  11. package/dist/cli.js +695 -702
  12. package/dist/codex-patch.d.ts +12 -0
  13. package/dist/codex-patch.js +71 -0
  14. package/dist/config.d.ts +0 -1
  15. package/dist/config.js +0 -4
  16. package/dist/connectors/slack/types.d.ts +0 -1
  17. package/dist/consolidate.js +85 -32
  18. package/dist/context-render.d.ts +36 -0
  19. package/dist/context-render.js +154 -0
  20. package/dist/dag.js +3 -2
  21. package/dist/dashboard.js +4 -0
  22. package/dist/db.js +6 -6
  23. package/dist/dedupe.d.ts +6 -6
  24. package/dist/dedupe.js +10 -9
  25. package/dist/doctor.d.ts +1 -1
  26. package/dist/doctor.js +35 -2
  27. package/dist/dormant.d.ts +4 -0
  28. package/dist/dormant.js +17 -2
  29. package/dist/embedding-provider.d.ts +2 -1
  30. package/dist/embedding-provider.js +2 -1
  31. package/dist/embeddings.js +23 -3
  32. package/dist/extensions/openclaw-plugin/index.js +1 -0
  33. package/dist/extract.js +5 -1
  34. package/dist/forward-claim-detector.d.ts +1 -1
  35. package/dist/forward-claim-detector.js +1 -1
  36. package/dist/graph-recall.d.ts +3 -1
  37. package/dist/graph-recall.js +13 -10
  38. package/dist/handoff.d.ts +2 -0
  39. package/dist/hooks.d.ts +19 -5
  40. package/dist/hooks.js +131 -30
  41. package/dist/importers.js +5 -12
  42. package/dist/judgment.d.ts +30 -0
  43. package/dist/judgment.js +122 -0
  44. package/dist/mcp/server.js +174 -213
  45. package/dist/merged-row.d.ts +6 -0
  46. package/dist/merged-row.js +35 -0
  47. package/dist/multihop.d.ts +2 -1
  48. package/dist/multihop.js +7 -4
  49. package/dist/physics-state.d.ts +0 -4
  50. package/dist/physics-state.js +0 -6
  51. package/dist/predictions.d.ts +2 -17
  52. package/dist/predictions.js +2 -15
  53. package/dist/reject-flow.d.ts +7 -5
  54. package/dist/reject-flow.js +41 -12
  55. package/dist/salience.js +12 -5
  56. package/dist/same-text.d.ts +17 -0
  57. package/dist/same-text.js +38 -0
  58. package/dist/scheduler.d.ts +4 -0
  59. package/dist/scheduler.js +8 -0
  60. package/dist/search.d.ts +7 -0
  61. package/dist/search.js +16 -32
  62. package/dist/secret-detect.d.ts +2 -0
  63. package/dist/secret-detect.js +9 -3
  64. package/dist/server-detect.js +9 -33
  65. package/dist/server.js +6 -62
  66. package/dist/session-digest.d.ts +79 -0
  67. package/dist/session-digest.js +528 -0
  68. package/dist/shared.d.ts +11 -3
  69. package/dist/shared.js +41 -31
  70. package/dist/store.d.ts +4 -5
  71. package/dist/store.js +26 -13
  72. package/dist/token-ledger.d.ts +46 -8
  73. package/dist/token-ledger.js +140 -21
  74. package/dist/version.d.ts +1 -1
  75. package/dist/version.js +1 -1
  76. package/dist-ui/assets/index-BhT8RvO6.js +61 -0
  77. package/dist-ui/index.html +1 -1
  78. package/extensions/openclaw-plugin/README.md +4 -4
  79. package/extensions/openclaw-plugin/index.ts +1 -0
  80. package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
  81. package/extensions/openclaw-plugin/package.json +1 -1
  82. package/openclaw.plugin.json +2 -2
  83. package/package.json +2 -2
  84. package/dist-ui/assets/index-BgmA7Hwe.js +0 -61
package/dist/shared.js CHANGED
@@ -9,13 +9,14 @@ import * as path from 'path';
9
9
  import { generateId } from './memory.js';
10
10
  import { initStore, loadAllEntries, loadIndex, loadSearchEntries, loadRecallSearchEntries, writeEntry, readEntry, } from './store.js';
11
11
  import { passesScopeFilterForRecall, passesCliRecallScopeFilter } from './recall-scope.js';
12
- import { search, hybridSearch } from './search.js';
12
+ import { search, hybridSearch, fitBudget } from './search.js';
13
13
  import { evalNow } from './ablation.js';
14
14
  import { deriveOriginProject, classifyOriginProject, resolveGlobalRootDir } from './project-identity.js';
15
15
  import { detectSecret } from './secret-detect.js';
16
16
  import { isQuarantineScope } from './quarantine.js';
17
17
  import { RejectedValueError } from './rejection.js';
18
18
  import { embedMemory, embedAll } from './embeddings.js';
19
+ import { duplicateKey, storedTextKeys } from './same-text.js';
19
20
  /**
20
21
  * Returns the path to the global Hippo store.
21
22
  * Resolution order: $HIPPO_HOME > $XDG_DATA_HOME/hippo > ~/.hippo/
@@ -70,11 +71,8 @@ export function promoteToGlobal(localRoot, id, opts) {
70
71
  origin_project: entry.origin_project ?? deriveOriginProject(path.dirname(path.resolve(localRoot))),
71
72
  };
72
73
  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(() => { });
74
+ // Fire-and-forget: embedMemory gates on availability and never rejects.
75
+ void embedMemory(globalRoot, globalEntry);
78
76
  return globalEntry;
79
77
  }
80
78
  /**
@@ -108,7 +106,7 @@ export function searchBoth(query, localRoot, globalRoot, options = {}) {
108
106
  // Remove duplicates by content (local/global IDs differ after promote/share)
109
107
  const seen = new Set();
110
108
  const deduped = tagged.filter((r) => {
111
- const key = r.entry.content.slice(0, 200).toLowerCase();
109
+ const key = duplicateKey(r.entry.content);
112
110
  if (seen.has(key))
113
111
  return false;
114
112
  seen.add(key);
@@ -136,7 +134,7 @@ export function searchBoth(query, localRoot, globalRoot, options = {}) {
136
134
  * Async version of searchBoth that calls hybridSearch instead of search.
137
135
  */
138
136
  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;
137
+ const { budget = 4000, now = evalNow(), embeddingWeight, explain, mmr, mmrLambda, localBump = 1.2, minResults, cost, scope, includeSuperseded, asOf, tenantId, summaryDeboost, summaryFreshness, entryFilter, recallScope } = options;
140
138
  // When an admission filter is active, lift the per-store candidate cap
141
139
  // (default 200): excluded rows matching the query could otherwise fill the
142
140
  // window before any admitted row is even loaded (codex gating round 6).
@@ -177,10 +175,10 @@ export async function searchBothHybrid(query, localRoot, globalRoot, options = {
177
175
  if (localEntries.length === 0 && globalEntries.length === 0)
178
176
  return [];
179
177
  const localResults = await hybridSearch(query, localEntries, {
180
- budget, now, hippoRoot: localRoot, embeddingWeight, explain, mmr, mmrLambda, minResults, scope, includeSuperseded, asOf, summaryDeboost, summaryFreshness,
178
+ budget, now, hippoRoot: localRoot, embeddingWeight, explain, mmr, mmrLambda, minResults, cost, scope, includeSuperseded, asOf, summaryDeboost, summaryFreshness,
181
179
  });
182
180
  const globalResults = await hybridSearch(query, globalEntries, {
183
- budget, now, hippoRoot: globalRoot, embeddingWeight, explain, mmr, mmrLambda, minResults, scope, includeSuperseded, asOf, summaryDeboost, summaryFreshness,
181
+ budget, now, hippoRoot: globalRoot, embeddingWeight, explain, mmr, mmrLambda, minResults, cost, scope, includeSuperseded, asOf, summaryDeboost, summaryFreshness,
184
182
  });
185
183
  // Tag global results. Local memories get a configurable priority bump.
186
184
  const tagged = [
@@ -197,7 +195,7 @@ export async function searchBothHybrid(query, localRoot, globalRoot, options = {
197
195
  // Remove duplicates by content (local/global IDs differ after promote/share)
198
196
  const seen = new Set();
199
197
  const deduped = tagged.filter((r) => {
200
- const key = r.entry.content.slice(0, 200).toLowerCase();
198
+ const key = duplicateKey(r.entry.content);
201
199
  if (seen.has(key))
202
200
  return false;
203
201
  seen.add(key);
@@ -206,17 +204,7 @@ export async function searchBothHybrid(query, localRoot, globalRoot, options = {
206
204
  // T2 note: PLAIN stable score sort on purpose -- see searchBoth above;
207
205
  // same rationale (deterministic inputs + stability; local-first on ties).
208
206
  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;
207
+ return fitBudget(deduped, budget, minResults ?? 1, cost);
220
208
  }
221
209
  // ---------------------------------------------------------------------------
222
210
  // Multi-agent shared memory
@@ -232,6 +220,19 @@ const TRANSFERABLE_TAGS = new Set([
232
220
  'powershell', 'quant', 'backtest', 'pattern', 'rule', 'gotcha',
233
221
  'sub-agent', 'review', 'best-practice',
234
222
  ]);
223
+ /** Tags whose rows only a hand-run share or promote may copy to the global store; derived rows inherit them. */
224
+ export const NEVER_AUTO_SHARE_TAGS = new Set([
225
+ 'git-learned',
226
+ 'session-digest',
227
+ ]);
228
+ export function neverAutoShareTags(sources) {
229
+ return [...NEVER_AUTO_SHARE_TAGS].filter((tag) => sources.some((s) => s.tags.includes(tag)));
230
+ }
231
+ /** Tags whose rows sleep keeps as written: never merged, never sent to LLM extraction. Conflict detection keeps its own list. */
232
+ export const NO_MERGE_TAGS = new Set([
233
+ 'extracted',
234
+ 'session-digest',
235
+ ]);
235
236
  /**
236
237
  * Estimate how well a memory would transfer to other projects.
237
238
  * Returns 0..1 where >0.5 = good candidate for sharing.
@@ -301,10 +302,9 @@ export function shareMemory(localRoot, id, options = {}) {
301
302
  // Single-row producer: embed here unless the caller opts out. autoShare
302
303
  // sets skipEmbed so it can batch its whole run through one embedAll() at
303
304
  // 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.
305
+ // the whole index JSON per call).
306
306
  if (!options.skipEmbed) {
307
- void embedMemory(globalRoot, globalEntry).catch(() => { });
307
+ void embedMemory(globalRoot, globalEntry);
308
308
  }
309
309
  return globalEntry;
310
310
  }
@@ -358,7 +358,7 @@ export function listPeers(globalRoot, tenantId) {
358
358
  .sort((a, b) => b.count - a.count);
359
359
  }
360
360
  /**
361
- * Auto-share: find local memories with high transfer scores that aren't already global.
361
+ * Auto-share: local memories with high transfer scores, not already global, no NEVER_AUTO_SHARE_TAGS tag.
362
362
  * Returns the list of shared entries.
363
363
  *
364
364
  * L9: `options.tenantId` is opt-in. When provided, the LOCAL-entries read is
@@ -392,17 +392,22 @@ export function autoShare(localRoot, options = {}) {
392
392
  // per-tenant filtering on the global root would defeat the purpose.
393
393
  const globalEntries = loadAllEntries(globalRoot);
394
394
  // Build set of global content hashes to avoid duplicates
395
- const globalContentSet = new Set(globalEntries.map((e) => e.content.toLowerCase().trim().slice(0, 200)));
395
+ const globalContentSet = storedTextKeys(globalEntries);
396
396
  const candidates = localEntries.filter((entry) => {
397
397
  // CD5: shareMemory refuses quarantined rows; filtering here keeps sleep from aborting on one.
398
398
  if (isQuarantineScope(entry.scope ?? null))
399
399
  return false;
400
+ // Before the score: these rows describe one project only, and a git seed's 'error' tag clears the bar.
401
+ if (entry.tags.some((t) => NEVER_AUTO_SHARE_TAGS.has(t))) {
402
+ if (options.stats)
403
+ options.stats.neverAutoShareSkipped = (options.stats.neverAutoShareSkipped ?? 0) + 1;
404
+ return false;
405
+ }
400
406
  const score = transferScore(entry);
401
407
  if (score < minScore)
402
408
  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))
409
+ // Skip if already shared (same text apart from spacing)
410
+ if (globalContentSet.has(duplicateKey(entry.content)))
406
411
  return false;
407
412
  // v39 S4 producer veto: secret rows never auto-share, regardless of
408
413
  // transfer score. (shareMemory would throw; filtering here keeps the
@@ -457,7 +462,7 @@ export function autoShare(localRoot, options = {}) {
457
462
  }
458
463
  /**
459
464
  * Copy all global memories into the local store.
460
- * Skips entries that already exist locally (by ID or by near-identical content).
465
+ * Skips entries that already exist locally, by ID or by text (promote and share copy under a new ID).
461
466
  * Returns the count of newly copied entries.
462
467
  */
463
468
  export function syncGlobalToLocal(localRoot, globalRoot, opts = {}) {
@@ -468,6 +473,8 @@ export function syncGlobalToLocal(localRoot, globalRoot, opts = {}) {
468
473
  // the local-root context provides one.
469
474
  const globalEntries = loadAllEntries(globalRoot);
470
475
  const localIndex = loadIndex(localRoot);
476
+ const textKey = (e) => `${e.tenantId}\n${e.content}`;
477
+ const localText = new Set(loadAllEntries(localRoot).map(textKey));
471
478
  // v39 (codex P1-4): syncing down must not re-import what ambient context
472
479
  // excludes - other-project rows are skipped by default and secret rows
473
480
  // are never copied. origin_project is preserved on the copy (writeEntry
@@ -484,6 +491,8 @@ export function syncGlobalToLocal(localRoot, globalRoot, opts = {}) {
484
491
  // Skip if already present by ID
485
492
  if (localIndex.entries[entry.id])
486
493
  continue;
494
+ if (localText.has(textKey(entry)))
495
+ continue;
487
496
  if (detectSecret(entry).flagged)
488
497
  continue;
489
498
  if (!opts.includeCrossProject &&
@@ -499,6 +508,7 @@ export function syncGlobalToLocal(localRoot, globalRoot, opts = {}) {
499
508
  }
500
509
  throw err;
501
510
  }
511
+ localText.add(textKey(entry));
502
512
  count++;
503
513
  }
504
514
  if (rejected > 0) {
package/dist/store.d.ts CHANGED
@@ -645,6 +645,7 @@ export declare function loadLatestHandoff(hippoRoot: string, tenantId: string, s
645
645
  unfinishedOnly?: boolean;
646
646
  maxAgeMs?: number;
647
647
  scopeFilter?: 'default-deny';
648
+ excludeSessionId?: string;
648
649
  }): SessionHandoff | null;
649
650
  /**
650
651
  * Load a specific handoff by its row ID.
@@ -652,12 +653,10 @@ export declare function loadLatestHandoff(hippoRoot: string, tenantId: string, s
652
653
  export declare function loadHandoffById(hippoRoot: string, tenantId: string, id: number): SessionHandoff | null;
653
654
  /** Stamp the outcome on a session's newest handoff, only if it has none yet. Returns rows changed. */
654
655
  export declare function stampHandoffOutcome(hippoRoot: string, tenantId: string, sessionId: string, outcome: HandoffOutcome): number;
655
- /**
656
- * Auto-write a handoff at session-end from the session's active snapshot (DF1 T3).
656
+ /** Auto-write a handoff at session-end (DF1 T3) from the session's active snapshot, else from `derived`, its transcript state.
657
657
  * @param evidence best-effort git state; outcome comes from the newest session_complete event.
658
- * @returns null unless the snapshot belongs to sessionId and no newer handoff already covers it.
659
- */
660
- export declare function writeSessionEndHandoff(hippoRoot: string, tenantId: string, sessionId: string, evidence: HandoffEvidence | null): SessionHandoff | null;
658
+ * @returns null when neither source is the session's, a newer handoff covers the snapshot, or the session's latest handoff was not read off its transcript. */
659
+ export declare function writeSessionEndHandoff(hippoRoot: string, tenantId: string, sessionId: string, evidence: HandoffEvidence | null, derived?: Pick<TaskSnapshot, 'task' | 'summary' | 'next_step'> | null): SessionHandoff | null;
661
660
  export declare function transitionCard(db: DatabaseSyncLike, tenantId: string, cardId: string, from: CardStatus[], to: CardStatus, extra?: {
662
661
  setSql?: string;
663
662
  whereSql?: string;
package/dist/store.js CHANGED
@@ -16,6 +16,7 @@ import { isRecallBoostAblated } from './ablation.js';
16
16
  import { rarestPromptTerms, RAREST_TERM_COUNT } from './prompt-recall.js';
17
17
  import { appendAuditEvent } from './audit.js';
18
18
  import { resolveTenantId } from './tenant.js';
19
+ import { redactSecretsStrict } from './secret-detect.js';
19
20
  import { deriveOriginProject, originFromSource, findHippoStoreDir, realpathOrResolve } from './project-identity.js';
20
21
  import { checkRejectionGuard, RejectedValueError, rejectionDigest, normalizeValueForRejection, insertRejectedValue, findRejectedValue, } from './rejection.js';
21
22
  // AT1 (plan §5): resolveConflict's kind-aware loser removal needs
@@ -2146,7 +2147,7 @@ export function saveActiveTaskSnapshot(hippoRoot, tenantId, snapshot) {
2146
2147
  const result = db.prepare(`
2147
2148
  INSERT INTO task_snapshots(task, summary, next_step, status, source, session_id, scope, tenant_id, created_at, updated_at)
2148
2149
  VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)
2149
- `).run(snapshot.task, snapshot.summary, snapshot.next_step, snapshot.source ?? 'cli', snapshot.session_id ?? null, snapshot.scope ?? null, tenantId, now, now);
2150
+ `).run(redactSecretsStrict(snapshot.task), redactSecretsStrict(snapshot.summary), redactSecretsStrict(snapshot.next_step), snapshot.source ?? 'cli', snapshot.session_id ?? null, snapshot.scope ?? null, tenantId, now, now);
2150
2151
  db.exec('COMMIT');
2151
2152
  const id = Number(result.lastInsertRowid ?? 0);
2152
2153
  // SAFETY: row's shape matches the ten columns named in the SELECT above.
@@ -2834,6 +2835,10 @@ export function loadLatestHandoff(hippoRoot, tenantId, sessionId, opts = {}) {
2834
2835
  conditions.push('session_id = ?');
2835
2836
  params.push(sessionId);
2836
2837
  }
2838
+ if (opts.excludeSessionId) {
2839
+ conditions.push('session_id != ?');
2840
+ params.push(opts.excludeSessionId);
2841
+ }
2837
2842
  if (opts.unfinishedOnly) {
2838
2843
  // codex P2: restrict to each session's newest revision first — stampHandoffOutcome
2839
2844
  // only stamps the newest row, so an older null-outcome revision must not resurrect.
@@ -2904,20 +2909,28 @@ export function stampHandoffOutcome(hippoRoot, tenantId, sessionId, outcome) {
2904
2909
  closeHippoDb(db);
2905
2910
  }
2906
2911
  }
2907
- /**
2908
- * Auto-write a handoff at session-end from the session's active snapshot (DF1 T3).
2912
+ /** Auto-write a handoff at session-end (DF1 T3) from the session's active snapshot, else from `derived`, its transcript state.
2909
2913
  * @param evidence best-effort git state; outcome comes from the newest session_complete event.
2910
- * @returns null unless the snapshot belongs to sessionId and no newer handoff already covers it.
2911
- */
2912
- export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence) {
2914
+ * @returns null when neither source is the session's, a newer handoff covers the snapshot, or the session's latest handoff was not read off its transcript. */
2915
+ export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence, derived = null) {
2913
2916
  assertTenantId('writeSessionEndHandoff', tenantId);
2914
- const snapshot = loadActiveTaskSnapshot(hippoRoot, tenantId);
2915
- if (!snapshot || snapshot.session_id !== sessionId)
2916
- return null;
2917
+ const active = loadActiveTaskSnapshot(hippoRoot, tenantId);
2917
2918
  const existing = loadLatestHandoff(hippoRoot, tenantId, sessionId);
2918
- // Strict '>': a same-millisecond tie must not swallow the session's only write (test 6e).
2919
- if (existing && existing.updatedAt > snapshot.updated_at)
2920
- return null;
2919
+ let snapshot;
2920
+ let handoffEvidence = evidence;
2921
+ if (active && active.session_id === sessionId) {
2922
+ // Strict '>': a same-millisecond tie must not swallow the session's only write (test 6e).
2923
+ if (existing && existing.updatedAt > active.updated_at)
2924
+ return null;
2925
+ snapshot = active;
2926
+ }
2927
+ else {
2928
+ // Only an earlier transcript read gives way; `hippo handoff create` and unmarked older handoffs keep winning.
2929
+ if (!derived || (existing && existing.evidence?.derivedFrom !== 'transcript'))
2930
+ return null;
2931
+ snapshot = { ...derived, scope: null };
2932
+ handoffEvidence = { ...evidence, derivedFrom: 'transcript' };
2933
+ }
2921
2934
  const db = openHippoDb(hippoRoot);
2922
2935
  let outcome = null;
2923
2936
  try {
@@ -2947,7 +2960,7 @@ export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence)
2947
2960
  nextAction: snapshot.next_step,
2948
2961
  artifacts: carryForward ? existing.artifacts : [],
2949
2962
  scope: snapshot.scope,
2950
- evidence,
2963
+ evidence: handoffEvidence,
2951
2964
  outcome,
2952
2965
  constraints: carryForward ? existing.constraints : undefined,
2953
2966
  targetRuntime: carryForward ? existing.targetRuntime : undefined,
@@ -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.7";
19
+ export declare const PACKAGE_VERSION = "1.52.9";
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