hippo-memory 1.45.0 → 1.47.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 (52) hide show
  1. package/README.md +58 -15
  2. package/bin/hippo.js +0 -0
  3. package/dist/ablation.d.ts +10 -1
  4. package/dist/ablation.js +17 -1
  5. package/dist/api.d.ts +66 -1
  6. package/dist/api.js +202 -7
  7. package/dist/audit.d.ts +1 -1
  8. package/dist/capture-error.d.ts +26 -0
  9. package/dist/capture-error.js +110 -0
  10. package/dist/capture.d.ts +25 -8
  11. package/dist/capture.js +100 -5
  12. package/dist/cli.d.ts +6 -1
  13. package/dist/cli.js +432 -48
  14. package/dist/config.d.ts +20 -0
  15. package/dist/config.js +35 -0
  16. package/dist/consolidate.d.ts +6 -0
  17. package/dist/consolidate.js +98 -13
  18. package/dist/db.js +81 -1
  19. package/dist/doctor.d.ts +34 -0
  20. package/dist/doctor.js +183 -0
  21. package/dist/dormant.d.ts +91 -0
  22. package/dist/dormant.js +121 -0
  23. package/dist/eval-stats.d.ts +123 -0
  24. package/dist/eval-stats.js +187 -0
  25. package/dist/failure-log.d.ts +49 -0
  26. package/dist/failure-log.js +58 -0
  27. package/dist/half-life-migration.d.ts +55 -0
  28. package/dist/half-life-migration.js +111 -0
  29. package/dist/hooks.d.ts +4 -0
  30. package/dist/hooks.js +47 -0
  31. package/dist/mcp/server.d.ts +6 -0
  32. package/dist/mcp/server.js +70 -13
  33. package/dist/memory.d.ts +16 -2
  34. package/dist/memory.js +27 -5
  35. package/dist/physics-config.js +5 -1
  36. package/dist/recall-scope.d.ts +24 -0
  37. package/dist/recall-scope.js +41 -0
  38. package/dist/reject-flow.d.ts +3 -3
  39. package/dist/reject-flow.js +10 -3
  40. package/dist/search.d.ts +4 -4
  41. package/dist/search.js +23 -18
  42. package/dist/server.js +11 -1
  43. package/dist/store.d.ts +12 -1
  44. package/dist/store.js +58 -12
  45. package/dist/token-ledger.d.ts +119 -0
  46. package/dist/token-ledger.js +181 -0
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  50. package/extensions/openclaw-plugin/package.json +1 -1
  51. package/openclaw.plugin.json +1 -1
  52. package/package.json +2 -1
@@ -7,9 +7,9 @@
7
7
  * SAME multi-step transaction + post-commit mirror-purge flow. Extracted
8
8
  * here (leaf module) so neither duplicates it.
9
9
  *
10
- * Module direction: this file imports from store.ts, rejection.ts, and
11
- * raw-archive.ts. Nothing imports FROM this file except cli.ts and api.ts,
12
- * so it introduces no cycle.
10
+ * Module direction: this file imports from store.ts, rejection.ts,
11
+ * raw-archive.ts and dormant.ts. Nothing imports FROM this file except
12
+ * cli.ts and api.ts, so it introduces no cycle.
13
13
  */
14
14
  import { type RejectedValueRow } from './rejection.js';
15
15
  export interface RejectFlowOpts {
@@ -7,13 +7,14 @@
7
7
  * SAME multi-step transaction + post-commit mirror-purge flow. Extracted
8
8
  * here (leaf module) so neither duplicates it.
9
9
  *
10
- * Module direction: this file imports from store.ts, rejection.ts, and
11
- * raw-archive.ts. Nothing imports FROM this file except cli.ts and api.ts,
12
- * so it introduces no cycle.
10
+ * Module direction: this file imports from store.ts, rejection.ts,
11
+ * raw-archive.ts and dormant.ts. Nothing imports FROM this file except
12
+ * cli.ts and api.ts, so it introduces no cycle.
13
13
  */
14
14
  import { openHippoDb, closeHippoDb } from './db.js';
15
15
  import { appendAuditEvent } from './audit.js';
16
16
  import { archiveRawMemory } from './raw-archive.js';
17
+ import { purgeDormantByDigest } from './dormant.js';
17
18
  import { initStore, deleteEntryCore, purgeMirrorBestEffort, writeIndexMirror, buildIndexFromDb, } from './store.js';
18
19
  import { rejectionDigest, normalizeValueForRejection, insertRejectedValue, deleteRejectedValue, listRejectedValues, } from './rejection.js';
19
20
  /**
@@ -102,6 +103,12 @@ export function rejectValue(opts) {
102
103
  }
103
104
  removedIds.push(row.id);
104
105
  }
106
+ // Dormant copies (src/dormant.ts) go too, in the same transaction: a
107
+ // rejected value may not linger where `hippo dormant restore` could
108
+ // bring it back. They have no markdown mirror, so the post-commit
109
+ // mirror purge below is a no-op for them; they join removedIds for the
110
+ // audit trail and the caller's report.
111
+ removedIds.push(...purgeDormantByDigest(db, opts.tenantId, digest));
105
112
  try {
106
113
  appendAuditEvent(db, {
107
114
  tenantId: opts.tenantId,
package/dist/search.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * BM25 search + optional embedding hybrid search for Hippo.
3
3
  * Zero external dependencies when embeddings are not available.
4
4
  */
5
+ import { estimateTokens } from './token-ledger.js';
5
6
  import { MemoryEntry } from './memory.js';
6
7
  import type { PhysicsConfig } from './physics-config.js';
7
8
  export declare function tokenize(text: string): string[];
@@ -17,10 +18,9 @@ export interface BM25Corpus {
17
18
  N: number;
18
19
  }
19
20
  export declare function buildCorpus(texts: string[]): BM25Corpus;
20
- /**
21
- * Rough token estimate: characters / 4 (works well for English text).
22
- */
23
- export declare function estimateTokens(text: string): number;
21
+ export { estimateTokens };
22
+ /** Retrieval-time outcome nudge in [0.85, 1.15]; the E1 bm25-outcome baseline ranks with it too. */
23
+ export declare function outcomeMultiplier(entry: MemoryEntry): number;
24
24
  export declare function detectTemporalDirection(query: string): 'recent' | 'oldest' | null;
25
25
  export interface TemporalRange {
26
26
  minTime: number;
package/dist/search.js CHANGED
@@ -2,8 +2,9 @@
2
2
  * BM25 search + optional embedding hybrid search for Hippo.
3
3
  * Zero external dependencies when embeddings are not available.
4
4
  */
5
- import { calculateStrength } from './memory.js';
6
- import { isOutcomeFastAblated, isRecallBoostAblated, evalNow } from './ablation.js';
5
+ import { estimateTokens } from './token-ledger.js';
6
+ import { calculateStrength, netWrong } from './memory.js';
7
+ import { isOutcomeFastAblated, isRecallBoostAblated, isRecencyAblated, evalRecencyScaleDays, evalNow } from './ablation.js';
7
8
  import { extractPathTags, pathBoostMultiplier } from './path-context.js';
8
9
  import { detectScope, scopeMatch } from './scope.js';
9
10
  import { cosineSimilarity, embeddingModelRequiresReindex, loadEmbeddingIndex, } from './embeddings.js';
@@ -68,20 +69,28 @@ function bm25Score(corpus, docIdx, queryTerms) {
68
69
  // ---------------------------------------------------------------------------
69
70
  // Token budget estimation
70
71
  // ---------------------------------------------------------------------------
71
- /**
72
- * Rough token estimate: characters / 4 (works well for English text).
73
- */
74
- export function estimateTokens(text) {
75
- return Math.ceil(text.length / 4);
76
- }
72
+ // Rough token estimate (characters / 4). Defined once in token-ledger.ts and
73
+ // re-exported here, where callers have always imported it from.
74
+ export { estimateTokens };
77
75
  // ---------------------------------------------------------------------------
78
76
  // Recency boost
79
77
  // ---------------------------------------------------------------------------
80
78
  function recencyBoost(entry, now) {
79
+ if (isRecencyAblated())
80
+ return 1; // EVAL-ONLY ablation (see ablation.ts)
81
81
  const created = new Date(entry.created);
82
82
  const ageDays = (now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24);
83
83
  // Exponential decay: memories < 1 day get boost ~1.0, older get less
84
- return Math.exp(-ageDays / 30);
84
+ return Math.exp(-ageDays / (evalRecencyScaleDays() ?? 30));
85
+ }
86
+ /** Retrieval-time outcome nudge in [0.85, 1.15]; the E1 bm25-outcome baseline ranks with it too. */
87
+ export function outcomeMultiplier(entry) {
88
+ const pos = entry.outcome_positive ?? 0;
89
+ const neg = entry.outcome_negative ?? 0;
90
+ // EVAL-ONLY ablation (see ablation.ts): the fast outcome channel.
91
+ if (isOutcomeFastAblated() || (pos === 0 && neg === 0))
92
+ return 1.0;
93
+ return Math.max(0.85, Math.min(1.15, 1 + 0.15 * Math.tanh((pos - neg) / 2)));
85
94
  }
86
95
  // ---------------------------------------------------------------------------
87
96
  // Temporal-aware scoring
@@ -364,12 +373,7 @@ export async function hybridSearch(query, entries, options = {}) {
364
373
  compositeScore *= pathBoost;
365
374
  // Retrieval-time outcome personalization: nudge up/down from user feedback.
366
375
  // Distinct from reward-factor-via-strength (slow); this is immediate.
367
- // EVAL-ONLY ablation (see ablation.ts): the fast outcome channel.
368
- const pos = entries[i].outcome_positive ?? 0;
369
- const neg = entries[i].outcome_negative ?? 0;
370
- const outcomeBoost = isOutcomeFastAblated() || (pos === 0 && neg === 0)
371
- ? 1.0
372
- : Math.max(0.85, Math.min(1.15, 1 + 0.15 * Math.tanh((pos - neg) / 2)));
376
+ const outcomeBoost = outcomeMultiplier(entries[i]);
373
377
  compositeScore *= outcomeBoost;
374
378
  // Scope boost: memories tagged with the active scope get 1.5x; mismatching scopes get 0.5x
375
379
  const scopeSignal = scopeMatch(entries[i].tags, activeScope);
@@ -929,12 +933,13 @@ export function markRetrieved(entries, now = evalNow()) {
929
933
  return entries.map((e) => {
930
934
  if (e.superseded_by)
931
935
  return e;
936
+ const wrong = netWrong(e) > 0;
932
937
  const updated = {
933
938
  ...e,
934
939
  retrieval_count: e.retrieval_count + 1,
935
- last_retrieved: now.toISOString(),
936
- // Extend half-life by +2 days per retrieval (PLAN.md)
937
- half_life_days: e.half_life_days + 2,
940
+ last_retrieved: wrong ? e.last_retrieved : now.toISOString(),
941
+ // +2 days half-life per retrieval (PLAN.md); a wrong memory keeps both, since last_retrieved is the decay anchor
942
+ half_life_days: wrong ? e.half_life_days : e.half_life_days + 2,
938
943
  };
939
944
  updated.strength = calculateStrength(updated, now);
940
945
  return updated;
package/dist/server.js CHANGED
@@ -22,7 +22,7 @@ export function __resetSessionRecallHistoryHttp() {
22
22
  import { PACKAGE_VERSION } from './version.js';
23
23
  import { validateApiKey } from './auth.js';
24
24
  import { createRateLimiter } from './rate-limit.js';
25
- import { remember, retrieve, RecallContractError, ForbiddenError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, adminActor, } from './api.js';
25
+ import { remember, retrieve, RecallContractError, ForbiddenError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, adminActor, recordTokens, } from './api.js';
26
26
  import { buildGraphModel } from './graph-view.js';
27
27
  import { MAX_ENTITY_NAME_LEN } from './graph.js';
28
28
  import { savePrediction, closePrediction, loadPredictionById, loadPredictionsByClass, loadOpenPredictions, computePredictionBaserate, VALID_CLOSURE_STATES, } from './predictions.js';
@@ -114,6 +114,8 @@ const VALID_AUDIT_OPS = new Set([
114
114
  'reject_refusal', // AT1 — emitted when the rejection guard refuses a write; lockstep
115
115
  'unreject_value', // AT1 — emitted by `hippo unreject`; lockstep
116
116
  'conflict_resolve', // AT1 — emitted by resolveConflict on every resolution path; lockstep
117
+ 'half_life_migrate', // Decay default change — emitted by migrateDefaultHalfLife; lockstep with AuditOp union
118
+ 'dormant_restore', // Dormant memories — emitted by api.restoreDormant; lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS
117
119
  ]);
118
120
  // Cap on GET /v1/audit?limit=. Matches docs/api.md (when written) and is large
119
121
  // enough to dump a small deployment's full audit log without paginating, but
@@ -303,6 +305,9 @@ function mapApiError(err) {
303
305
  if (/already superseded/.test(lower)) {
304
306
  return { status: 409, message };
305
307
  }
308
+ if (/requires admin role/.test(lower)) {
309
+ return { status: 403, message };
310
+ }
306
311
  return { status: 400, message };
307
312
  }
308
313
  function parseRequest(req) {
@@ -801,6 +806,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
801
806
  if (includeContinuity) {
802
807
  res.setHeader('Cache-Control', 'no-store');
803
808
  }
809
+ recordTokens(ctx, 'http_recall', { items: result.results.length, tokens: result.tokens + (result.continuityTokens ?? 0), sessionId: sessionId ?? null });
804
810
  sendJson(res, 200, result);
805
811
  return;
806
812
  }
@@ -842,6 +848,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
842
848
  if (scope !== undefined)
843
849
  assembleExtra.scope = scope;
844
850
  const result = assemble(ctx, assembleMatch.id, assembleExtra);
851
+ recordTokens(ctx, 'http_assemble', { items: result.items.length, tokens: result.tokens, sessionId: assembleMatch.id });
845
852
  sendJson(res, 200, result);
846
853
  return;
847
854
  }
@@ -1042,6 +1049,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1042
1049
  crossProject,
1043
1050
  currentProject: resolveProjectIdentity(dirname(resolve(opts.hippoRoot))).name,
1044
1051
  });
1052
+ recordTokens(ctx, 'http_context', { items: result.entries.length, tokens: result.tokens });
1045
1053
  sendJson(res, 200, result);
1046
1054
  return;
1047
1055
  }
@@ -3011,6 +3019,8 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
3011
3019
  tenantId: ctx.tenantId,
3012
3020
  // v1.12.0: McpContext.actor stays string; extract subject at the boundary.
3013
3021
  actor: ctx.actor.subject,
3022
+ // The caller's real role: MCP tools must not run a member key as admin.
3023
+ role: ctx.actor.role,
3014
3024
  clientKey: buildMcpClientKey(req),
3015
3025
  });
3016
3026
  }
package/dist/store.d.ts CHANGED
@@ -10,6 +10,7 @@ import { SessionHandoff, HandoffEvidence, HandoffOutcome } from './handoff.js';
10
10
  import { Card, CardStatus, CardRun, CardComment } from './card.js';
11
11
  import { type ResolveProjectIdentityOpts } from './project-identity.js';
12
12
  import { RejectedValueError } from './rejection.js';
13
+ import { type DormantMove } from './dormant.js';
13
14
  /** A value that round-trips through JSON.stringify/JSON.parse unchanged. */
14
15
  type JsonValue = string | number | boolean | null | JsonValue[] | {
15
16
  [key: string]: JsonValue;
@@ -108,6 +109,8 @@ export declare function assertNonEmpty<T>(arr: readonly T[], name: string): void
108
109
  export declare function getHippoRoot(cwd?: string, opts?: ResolveProjectIdentityOpts): string;
109
110
  export declare function isInitialized(hippoRoot: string): boolean;
110
111
  export declare function initStore(hippoRoot: string): void;
112
+ /** `meta` key holding the default half-life base a store's memories are on (src/half-life-migration.ts). */
113
+ export declare const HALF_LIFE_BASE_META_KEY = "default_half_life_base";
111
114
  /**
112
115
  * Serialize a MemoryEntry to markdown with YAML frontmatter.
113
116
  */
@@ -405,9 +408,15 @@ export declare function deleteEntry(hippoRoot: string, id: string, opts?: {
405
408
  automatic?: boolean;
406
409
  }): boolean;
407
410
  /** Consolidation's flush, one transaction. With `snapshot` (rows as the caller loaded them), a write keeps only
408
- * the fields the caller changed, takes the rest from the live row, and never resurrects a row that is gone. */
411
+ * the fields the caller changed, takes the rest from the live row, and never resurrects a row that is gone.
412
+ *
413
+ * `dormant` (src/dormant.ts): each move's snapshot is inserted into `dormant_memories` and its `memories` row
414
+ * leaves exactly like a delete (FTS row, DAG parent dirty-mark, mirrors), in the same transaction, so a memory
415
+ * is never in both places or in neither. Deletes and moves both skip rows that are no longer auto-deletable
416
+ * (pinned or raw since the caller decided). Returns the ids that left `memories`, deleted or moved. */
409
417
  export declare function batchWriteAndDelete(hippoRoot: string, toWrite: MemoryEntry[], toDeleteIds: string[], opts?: {
410
418
  snapshot?: ReadonlyMap<string, MemoryEntry>;
419
+ dormant?: DormantMove[];
411
420
  }): string[];
412
421
  /**
413
422
  * Load all entries from SQLite.
@@ -417,6 +426,8 @@ export declare function batchWriteAndDelete(hippoRoot: string, toWrite: MemoryEn
417
426
  * paths that surface results to a user MUST pass a resolved tenant.
418
427
  */
419
428
  export declare function loadAllEntries(hippoRoot: string, tenantId?: string): MemoryEntry[];
429
+ /** Every memory row on an open connection, so a caller can read inside its own transaction. */
430
+ export declare function selectAllEntries(db: DatabaseSyncLike, tenantId?: string): MemoryEntry[];
420
431
  export declare function loadAmbientCandidates(hippoRoot: string, tenantId: string, recentNeeded: number, admit: (e: MemoryEntry) => boolean): MemoryEntry[];
421
432
  /**
422
433
  * Load likely search candidates directly from SQLite.
package/dist/store.js CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
- import { Layer, generateId, AUTO_DELETABLE_SQL } from './memory.js';
9
+ import { Layer, generateId, AUTO_DELETABLE_SQL, DEFAULT_HALF_LIFE_DAYS } from './memory.js';
10
10
  import { dumpFrontmatter, parseFrontmatter } from './yaml.js';
11
11
  import { openHippoDb, closeHippoDb, getMeta, setMeta, isFtsAvailable, pruneConsolidationRuns, getHippoDbPath, } from './db.js';
12
12
  import { rowToSessionHandoff, isHandoffOutcome } from './handoff.js';
@@ -23,6 +23,7 @@ import { checkRejectionGuard, RejectedValueError, rejectionDigest, normalizeValu
23
23
  // inside function bodies (never at module-evaluation time), so the cycle
24
24
  // is the standard safe mutual-function-reference shape under NodeNext ESM.
25
25
  import { archiveRawMemory } from './raw-archive.js';
26
+ import { insertDormantRow } from './dormant.js';
26
27
  /**
27
28
  * Emit an audit event for a mutation against `db`. Wrapped so a broken audit
28
29
  * log can never crash the surrounding mutation — the SQLite store is still the
@@ -119,11 +120,27 @@ export function initStore(hippoRoot) {
119
120
  if (bootstrapped) {
120
121
  syncMirrorFiles(hippoRoot, db);
121
122
  }
123
+ recordHalfLifeBaseForNewStore(db);
122
124
  }
123
125
  finally {
124
126
  closeHippoDb(db);
125
127
  }
126
128
  }
129
+ /** `meta` key holding the default half-life base a store's memories are on (src/half-life-migration.ts). */
130
+ export const HALF_LIFE_BASE_META_KEY = 'default_half_life_base';
131
+ /**
132
+ * A store with no memories starts on the current default half-life base, so
133
+ * `hippo sleep` never migrates it. A store that already holds memories and
134
+ * no recorded base predates the record, and keeps reading as the legacy
135
+ * 7-day base until sleep migrates it.
136
+ */
137
+ function recordHalfLifeBaseForNewStore(db) {
138
+ if (getMeta(db, HALF_LIFE_BASE_META_KEY, '') !== '')
139
+ return;
140
+ if (db.prepare(`SELECT 1 AS x FROM memories LIMIT 1`).get() !== undefined)
141
+ return;
142
+ setMeta(db, HALF_LIFE_BASE_META_KEY, String(DEFAULT_HALF_LIFE_DAYS));
143
+ }
127
144
  function ensureMirrorDirectories(hippoRoot) {
128
145
  const dirs = [
129
146
  hippoRoot,
@@ -1613,9 +1630,15 @@ function mergeOwnChanges(base, ours, live) {
1613
1630
  return row;
1614
1631
  }
1615
1632
  /** Consolidation's flush, one transaction. With `snapshot` (rows as the caller loaded them), a write keeps only
1616
- * the fields the caller changed, takes the rest from the live row, and never resurrects a row that is gone. */
1633
+ * the fields the caller changed, takes the rest from the live row, and never resurrects a row that is gone.
1634
+ *
1635
+ * `dormant` (src/dormant.ts): each move's snapshot is inserted into `dormant_memories` and its `memories` row
1636
+ * leaves exactly like a delete (FTS row, DAG parent dirty-mark, mirrors), in the same transaction, so a memory
1637
+ * is never in both places or in neither. Deletes and moves both skip rows that are no longer auto-deletable
1638
+ * (pinned or raw since the caller decided). Returns the ids that left `memories`, deleted or moved. */
1617
1639
  export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds, opts) {
1618
- if (toWrite.length === 0 && toDeleteIds.length === 0)
1640
+ const dormantMoves = opts?.dormant ?? [];
1641
+ if (toWrite.length === 0 && toDeleteIds.length === 0 && dormantMoves.length === 0)
1619
1642
  return [];
1620
1643
  initStore(hippoRoot);
1621
1644
  const db = openHippoDb(hippoRoot);
@@ -1722,7 +1745,26 @@ export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds, opts) {
1722
1745
  tenantById.set(row.dag_parent_id, row.tenantId);
1723
1746
  }
1724
1747
  }
1725
- for (const id of deletableIds) {
1748
+ // Dormant moves: same eligibility and DAG bookkeeping as deletes.
1749
+ const movable = [];
1750
+ if (dormantMoves.length > 0) {
1751
+ const byId = new Map(dormantMoves.map((m) => [m.entry.id, m]));
1752
+ const placeholders = dormantMoves.map(() => '?').join(',');
1753
+ // SAFETY: rows' shape matches the three columns named in the SELECT.
1754
+ const rows = db.prepare(`SELECT id, dag_parent_id, tenant_id FROM memories WHERE id IN (${placeholders}) AND ${AUTO_DELETABLE_SQL}`).all(...byId.keys());
1755
+ for (const row of rows) {
1756
+ movable.push(byId.get(row.id));
1757
+ if (row.dag_parent_id) {
1758
+ dirtyParents.add(row.dag_parent_id);
1759
+ tenantById.set(row.dag_parent_id, row.tenant_id ?? 'default');
1760
+ }
1761
+ }
1762
+ }
1763
+ for (const move of movable) {
1764
+ insertDormantRow(db, move);
1765
+ }
1766
+ const removedIds = [...deletableIds, ...movable.map((m) => m.entry.id)];
1767
+ for (const id of removedIds) {
1726
1768
  db.prepare('DELETE FROM memories WHERE id = ?').run(id);
1727
1769
  deleteFtsRow(db, id);
1728
1770
  }
@@ -1742,10 +1784,10 @@ export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds, opts) {
1742
1784
  for (const entry of written)
1743
1785
  writeMarkdownMirror(hippoRoot, entry);
1744
1786
  });
1745
- for (const id of deletableIds)
1787
+ for (const id of removedIds)
1746
1788
  purgeMirrorBestEffort(hippoRoot, id, false, 'batchWriteAndDelete');
1747
1789
  writeIndexMirror(hippoRoot, buildIndexFromDb(db));
1748
- return deletableIds;
1790
+ return removedIds;
1749
1791
  }
1750
1792
  catch (error) {
1751
1793
  try {
@@ -1769,17 +1811,21 @@ export function loadAllEntries(hippoRoot, tenantId) {
1769
1811
  initStore(hippoRoot);
1770
1812
  const db = openHippoDb(hippoRoot);
1771
1813
  try {
1772
- // SAFETY: both branches select exactly MEMORY_SELECT_COLUMNS, matching
1773
- // MemoryRow's field set.
1774
- const rows = tenantId !== undefined
1775
- ? db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE tenant_id = ? ORDER BY created ASC, id ASC`).all(tenantId)
1776
- : db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories ORDER BY created ASC, id ASC`).all();
1777
- return rows.map(rowToEntry);
1814
+ return selectAllEntries(db, tenantId);
1778
1815
  }
1779
1816
  finally {
1780
1817
  closeHippoDb(db);
1781
1818
  }
1782
1819
  }
1820
+ /** Every memory row on an open connection, so a caller can read inside its own transaction. */
1821
+ export function selectAllEntries(db, tenantId) {
1822
+ // SAFETY: both branches select exactly MEMORY_SELECT_COLUMNS, matching
1823
+ // MemoryRow's field set.
1824
+ const rows = tenantId !== undefined
1825
+ ? db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE tenant_id = ? ORDER BY created ASC, id ASC`).all(tenantId)
1826
+ : db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories ORDER BY created ASC, id ASC`).all();
1827
+ return rows.map(rowToEntry);
1828
+ }
1783
1829
  // The pins plus the `recentNeeded` newest rows that pass `admit`, for ambient
1784
1830
  // injection. One connection: opening one costs ~5.8ms on a warm 1896-row store,
1785
1831
  // so a second handle loses more than the narrower scan saves.
@@ -0,0 +1,119 @@
1
+ import type { DatabaseSyncLike } from './db.js';
2
+ /**
3
+ * Where a block of memory text was sent.
4
+ * - `hook`: the per-prompt `UserPromptSubmit` hook (`hippo context --pinned-only`).
5
+ * - `context`, `recall`: the CLI commands.
6
+ * - `mcp_recall`, `mcp_context`: the MCP tools.
7
+ * - `http_recall`, `http_context`, `http_assemble`: the HTTP API.
8
+ */
9
+ export type TokenSurface = 'hook' | 'context' | 'recall' | 'mcp_recall' | 'mcp_context' | 'http_recall' | 'http_context' | 'http_assemble';
10
+ /** All surfaces, in report order. */
11
+ export declare const TOKEN_SURFACES: readonly TokenSurface[];
12
+ /**
13
+ * What happened to a block.
14
+ * - `inject`: sent to the agent.
15
+ * - `skip`: identical to the session's last injected block, so not sent again.
16
+ * - `reset`: the host compacted its context, so the next block must be sent.
17
+ */
18
+ export type TokenEvent = 'inject' | 'skip' | 'reset';
19
+ /** Rows older than this are pruned on write. */
20
+ export declare const TOKEN_LEDGER_RETENTION_DAYS = 90;
21
+ /**
22
+ * Rough token estimate: characters / 4. The single estimate behind every
23
+ * token budget and ledger count in hippo.
24
+ */
25
+ export declare function estimateTokens(text: string): number;
26
+ /** Stable 16-hex-char hash of a rendered block, for change detection. */
27
+ export declare function blockHash(text: string): string;
28
+ /** One ledger write. */
29
+ export interface TokenUse {
30
+ tenantId: string;
31
+ /** Host session id when known (hook payload, `HIPPO_SESSION_ID`); null otherwise. */
32
+ sessionId?: string | null;
33
+ surface: TokenSurface;
34
+ event: TokenEvent;
35
+ /** Memories (and continuity blocks) in the text. */
36
+ items: number;
37
+ /** Estimated tokens of the text; for a `skip`, the tokens not sent. */
38
+ tokens: number;
39
+ /** {@link blockHash} of the text, when the caller wants change detection. */
40
+ hash?: string | null;
41
+ /** Override the timestamp (tests). ISO string. */
42
+ now?: string;
43
+ }
44
+ /**
45
+ * Append one ledger row and prune rows past {@link TOKEN_LEDGER_RETENTION_DAYS}.
46
+ */
47
+ export declare function recordTokenUse(db: DatabaseSyncLike, use: TokenUse): void;
48
+ /** What a session last sent on a surface, for {@link lastSentState}. */
49
+ export interface LastSent {
50
+ /** {@link blockHash} of the last injected block. */
51
+ hash: string;
52
+ /** Consecutive `skip` rows since that injection. */
53
+ skipsSince: number;
54
+ }
55
+ /**
56
+ * The last block this session injected on `surface`, or null when the
57
+ * session has injected nothing, a `reset` came after it, or there is no
58
+ * session id (without one, the caller always injects).
59
+ */
60
+ export declare function lastSentState(db: DatabaseSyncLike, tenantId: string, sessionId: string | null | undefined, surface: TokenSurface): LastSent | null;
61
+ /**
62
+ * Whether a block identical to the session's last injection should be
63
+ * skipped. `refreshTurns` resends an unchanged block after that many
64
+ * consecutive skips, so a long session still sees its pinned rules near the
65
+ * latest turn; 0 never resends an unchanged block.
66
+ */
67
+ export declare function shouldSkipUnchanged(last: LastSent | null, hash: string, refreshTurns: number): boolean;
68
+ /** Per-surface totals for {@link summarizeTokenUse}. */
69
+ export interface TokenSurfaceSummary {
70
+ surface: TokenSurface;
71
+ /** Blocks sent. */
72
+ injected: number;
73
+ /** Tokens sent. */
74
+ tokens: number;
75
+ /** Blocks not sent because they were unchanged. */
76
+ skipped: number;
77
+ /** Tokens those skipped blocks would have cost. */
78
+ tokensAvoided: number;
79
+ /** Distinct session ids seen (rows without one are not counted). */
80
+ sessions: number;
81
+ }
82
+ /** Ledger totals over a window. */
83
+ export interface TokenSummary {
84
+ /** ISO start of the window (inclusive). */
85
+ since: string;
86
+ surfaces: TokenSurfaceSummary[];
87
+ totalTokens: number;
88
+ totalTokensAvoided: number;
89
+ /** Mean tokens sent per session, over rows that carry a session id. */
90
+ meanTokensPerSession: number;
91
+ }
92
+ /**
93
+ * Sum the ledger for one tenant since `sinceIso`. Surfaces with no rows are
94
+ * omitted.
95
+ */
96
+ export declare function summarizeTokenUse(db: DatabaseSyncLike, tenantId: string, sinceIso: string): TokenSummary;
97
+ /**
98
+ * The `session_id` of a Claude Code hook payload on stdin, or null when the
99
+ * text is empty, malformed, has no non-empty session id, or (with
100
+ * `requiredSource`) a different `source`.
101
+ */
102
+ export declare function hookPayloadSessionId(stdinText: string | undefined, requiredSource?: string | null): string | null;
103
+ /** Tokens hippo sent and skipped in one session, for {@link tokensBySession}. */
104
+ export interface SessionTokens {
105
+ sessionId: string;
106
+ /** Tokens of memory text sent to the agent. */
107
+ sent: number;
108
+ /** Tokens of unchanged blocks not sent. */
109
+ skipped: number;
110
+ /** Blocks sent. */
111
+ injections: number;
112
+ }
113
+ /**
114
+ * Ledger totals per session id since `sinceIso`, across every surface.
115
+ * Claude Code hook rows carry the host's session id, which is also the
116
+ * transcript file name, so these join to the host's own usage records.
117
+ */
118
+ export declare function tokensBySession(db: DatabaseSyncLike, tenantId: string, sinceIso: string): SessionTokens[];
119
+ //# sourceMappingURL=token-ledger.d.ts.map