hippo-memory 1.52.8 → 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 (74) hide show
  1. package/README.md +158 -98
  2. package/dist/api.d.ts +51 -18
  3. package/dist/api.js +121 -76
  4. package/dist/audit.d.ts +2 -1
  5. package/dist/audit.js +63 -0
  6. package/dist/capture.d.ts +37 -0
  7. package/dist/capture.js +111 -81
  8. package/dist/cli.js +627 -654
  9. package/dist/codex-patch.d.ts +12 -0
  10. package/dist/codex-patch.js +71 -0
  11. package/dist/config.d.ts +0 -1
  12. package/dist/config.js +0 -4
  13. package/dist/connectors/slack/types.d.ts +0 -1
  14. package/dist/consolidate.js +85 -32
  15. package/dist/context-render.d.ts +36 -0
  16. package/dist/context-render.js +154 -0
  17. package/dist/dag.js +3 -2
  18. package/dist/db.js +6 -6
  19. package/dist/dedupe.d.ts +6 -6
  20. package/dist/dedupe.js +10 -9
  21. package/dist/doctor.d.ts +1 -1
  22. package/dist/doctor.js +35 -2
  23. package/dist/dormant.d.ts +4 -0
  24. package/dist/dormant.js +17 -2
  25. package/dist/embedding-provider.d.ts +2 -1
  26. package/dist/embedding-provider.js +2 -1
  27. package/dist/embeddings.js +23 -3
  28. package/dist/extract.js +5 -1
  29. package/dist/forward-claim-detector.d.ts +1 -1
  30. package/dist/forward-claim-detector.js +1 -1
  31. package/dist/graph-recall.d.ts +3 -1
  32. package/dist/graph-recall.js +5 -3
  33. package/dist/hooks.d.ts +15 -1
  34. package/dist/hooks.js +122 -27
  35. package/dist/importers.js +5 -12
  36. package/dist/judgment.d.ts +30 -0
  37. package/dist/judgment.js +122 -0
  38. package/dist/mcp/server.js +171 -210
  39. package/dist/merged-row.d.ts +6 -0
  40. package/dist/merged-row.js +35 -0
  41. package/dist/multihop.d.ts +2 -1
  42. package/dist/multihop.js +7 -4
  43. package/dist/physics-state.d.ts +0 -4
  44. package/dist/physics-state.js +0 -6
  45. package/dist/predictions.d.ts +2 -17
  46. package/dist/predictions.js +2 -15
  47. package/dist/reject-flow.d.ts +7 -5
  48. package/dist/reject-flow.js +41 -12
  49. package/dist/salience.js +12 -5
  50. package/dist/same-text.d.ts +17 -0
  51. package/dist/same-text.js +38 -0
  52. package/dist/scheduler.d.ts +4 -0
  53. package/dist/scheduler.js +8 -0
  54. package/dist/search.d.ts +7 -0
  55. package/dist/search.js +16 -32
  56. package/dist/secret-detect.d.ts +2 -0
  57. package/dist/secret-detect.js +6 -0
  58. package/dist/server-detect.js +9 -33
  59. package/dist/server.js +6 -62
  60. package/dist/session-digest.d.ts +79 -0
  61. package/dist/session-digest.js +528 -0
  62. package/dist/shared.d.ts +10 -2
  63. package/dist/shared.js +35 -30
  64. package/dist/store.d.ts +1 -0
  65. package/dist/store.js +4 -0
  66. package/dist/token-ledger.d.ts +46 -8
  67. package/dist/token-ledger.js +140 -21
  68. package/dist/version.d.ts +1 -1
  69. package/dist/version.js +1 -1
  70. package/extensions/openclaw-plugin/README.md +4 -4
  71. package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
  72. package/extensions/openclaw-plugin/package.json +1 -1
  73. package/openclaw.plugin.json +2 -2
  74. package/package.json +2 -2
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
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.
package/dist/store.js CHANGED
@@ -2835,6 +2835,10 @@ export function loadLatestHandoff(hippoRoot, tenantId, sessionId, opts = {}) {
2835
2835
  conditions.push('session_id = ?');
2836
2836
  params.push(sessionId);
2837
2837
  }
2838
+ if (opts.excludeSessionId) {
2839
+ conditions.push('session_id != ?');
2840
+ params.push(opts.excludeSessionId);
2841
+ }
2838
2842
  if (opts.unfinishedOnly) {
2839
2843
  // codex P2: restrict to each session's newest revision first — stampHandoffOutcome
2840
2844
  // 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.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
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.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 function compareSemver(a, b) {
22
22
  const parse = (v) => {
@@ -1,6 +1,6 @@
1
1
  # Hippo Memory - OpenClaw Plugin
2
2
 
3
- Biologically-inspired memory for OpenClaw agents. Memories decay by default, retrieval strengthens them, errors stick longer, and sleep consolidation compresses episodes into patterns.
3
+ Biologically-inspired memory for OpenClaw agents. Memories decay by default, retrieval strengthens them, errors stick longer, and sleep consolidation merges related episodes into one memory.
4
4
 
5
5
  ## Install
6
6
 
@@ -97,17 +97,17 @@ When `autoLearn` is enabled, the plugin captures tool errors as memories. To pre
97
97
  2. **Per-session rate limit.** Maximum 5 error memories per session. Prevents runaway error storms from flooding the store.
98
98
  3. **Per-session deduplication.** The same error from the same tool is only captured once per session, even if it fires repeatedly.
99
99
 
100
- Only genuinely novel, domain-specific errors make it through to `hippo remember`.
100
+ Errors that pass these three filters are stored with `hippo remember`.
101
101
 
102
102
  ### How it differs from claude-mem
103
103
 
104
104
  | | Hippo | claude-mem |
105
105
  |---|---|---|
106
- | Decay | Yes, 7-day half-life | No, saves everything |
106
+ | Decay | Yes, 365-day half-life by default | No, saves everything |
107
107
  | Retrieval strengthening | Yes | No |
108
108
  | Outcome feedback | Yes | No |
109
109
  | Cross-tool | Claude Code, Codex, Cursor, OpenClaw | Claude Code only |
110
- | API calls | Zero | Uses Claude API for compression |
110
+ | API calls | None by default. If `ANTHROPIC_API_KEY` is set, `hippo sleep` sends memory text to Anthropic; `{"extraction":{"enabled":false}}` stops it | Uses Claude API for compression |
111
111
  | Token cost | ~1500 tokens/session (configurable) | Variable |
112
112
  | Memecoin | No | Yes ($CMEM on Solana) |
113
113
 
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "id": "hippo-memory",
3
3
  "name": "Hippo Memory",
4
- "description": "Memory for AI agents that learns what is wrong and stops repeating it. Injects context at session start and captures errors.",
5
- "version": "1.52.8",
4
+ "description": "Memory for AI agents that learns what is wrong and ranks it down. Injects context at session start and captures errors.",
5
+ "version": "1.52.9",
6
6
 
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.52.8",
3
+ "version": "1.52.9",
4
4
  "type": "module",
5
5
  "description": "Hippo Memory plugin for OpenClaw - biologically-inspired agent memory",
6
6
  "main": "index.ts",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "id": "hippo-memory",
3
3
  "name": "Hippo Memory",
4
- "description": "Memory for AI agents that learns what is wrong and stops repeating it. Injects context at session start and captures errors.",
5
- "version": "1.52.8",
4
+ "description": "Memory for AI agents that learns what is wrong and ranks it down. Injects context at session start and captures errors.",
5
+ "version": "1.52.9",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.52.8",
4
- "description": "Memory for AI agents that learns what is wrong and stops repeating it. MCP server and hooks for Claude Code, Codex and Cursor. SQLite, zero runtime deps.",
3
+ "version": "1.52.9",
4
+ "description": "Memory for AI agents that learns what is wrong and ranks it down. MCP server, hooks for Claude Code, OpenCode and Codex, AGENTS.md instructions for Codex, Cursor, OpenClaw and Pi. SQLite, zero runtime deps.",
5
5
  "mcpName": "io.github.kitfunso/hippo-memory",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",