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
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Token ledger (ROADMAP Part IX, TE0): what memory text hippo hands agents,
3
+ * and how many tokens it costs.
4
+ *
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.
9
+ *
10
+ * It also backs TE2, inject only on change: the per-prompt hook compares the
11
+ * hash of the block it is about to send with the last block it sent in the
12
+ * same session and records a `skip` instead of sending it again.
13
+ *
14
+ * Token counts use {@link estimateTokens} (characters / 4), the same estimate
15
+ * every budget in hippo uses. Rows hold counts, surfaces, session ids and
16
+ * hashes, never memory content or query text.
17
+ *
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.
20
+ */
21
+ import { createHash } from 'node:crypto';
22
+ /** All surfaces, in report order. */
23
+ export const TOKEN_SURFACES = [
24
+ 'hook', 'context', 'recall', 'mcp_recall', 'mcp_context',
25
+ 'http_recall', 'http_context', 'http_assemble',
26
+ ];
27
+ /** Rows older than this are pruned on write. */
28
+ export const TOKEN_LEDGER_RETENTION_DAYS = 90;
29
+ /**
30
+ * Rough token estimate: characters / 4. The single estimate behind every
31
+ * token budget and ledger count in hippo.
32
+ */
33
+ export function estimateTokens(text) {
34
+ return Math.ceil(text.length / 4);
35
+ }
36
+ /** Stable 16-hex-char hash of a rendered block, for change detection. */
37
+ export function blockHash(text) {
38
+ return createHash('sha256').update(text).digest('hex').slice(0, 16);
39
+ }
40
+ /**
41
+ * Append one ledger row and prune rows past {@link TOKEN_LEDGER_RETENTION_DAYS}.
42
+ */
43
+ export function recordTokenUse(db, use) {
44
+ const now = use.now ?? new Date().toISOString();
45
+ db.prepare(`INSERT INTO token_ledger (ts, tenant_id, session_id, surface, event, items, tokens, block_hash)
46
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(now, use.tenantId, use.sessionId ?? null, use.surface, use.event, Math.max(0, Math.round(use.items)), Math.max(0, Math.round(use.tokens)), use.hash ?? null);
47
+ const cutoff = new Date(Date.parse(now) - TOKEN_LEDGER_RETENTION_DAYS * 86_400_000).toISOString();
48
+ db.prepare(`DELETE FROM token_ledger WHERE ts < ?`).run(cutoff);
49
+ }
50
+ /**
51
+ * The last block this session injected on `surface`, or null when the
52
+ * session has injected nothing, a `reset` came after it, or there is no
53
+ * session id (without one, the caller always injects).
54
+ */
55
+ export function lastSentState(db, tenantId, sessionId, surface) {
56
+ if (!sessionId)
57
+ return null;
58
+ // SAFETY: the SELECT names exactly these three columns.
59
+ const anchor = db.prepare(`SELECT id, event, block_hash FROM token_ledger
60
+ WHERE tenant_id = ? AND session_id = ? AND surface = ? AND event IN ('inject', 'reset')
61
+ ORDER BY id DESC LIMIT 1`).get(tenantId, sessionId, surface);
62
+ if (!anchor || anchor.event === 'reset' || !anchor.block_hash)
63
+ return null;
64
+ // SAFETY: the SELECT names exactly this one aggregate column.
65
+ const skips = db.prepare(`SELECT COUNT(*) AS n FROM token_ledger
66
+ WHERE tenant_id = ? AND session_id = ? AND surface = ? AND event = 'skip' AND id > ?`).get(tenantId, sessionId, surface, anchor.id);
67
+ return { hash: anchor.block_hash, skipsSince: Number(skips?.n ?? 0) };
68
+ }
69
+ /**
70
+ * Whether a block identical to the session's last injection should be
71
+ * skipped. `refreshTurns` resends an unchanged block after that many
72
+ * consecutive skips, so a long session still sees its pinned rules near the
73
+ * latest turn; 0 never resends an unchanged block.
74
+ */
75
+ export function shouldSkipUnchanged(last, hash, refreshTurns) {
76
+ if (!last || last.hash !== hash)
77
+ return false;
78
+ if (refreshTurns > 0 && last.skipsSince >= refreshTurns)
79
+ return false;
80
+ return true;
81
+ }
82
+ /**
83
+ * Sum the ledger for one tenant since `sinceIso`. Surfaces with no rows are
84
+ * omitted.
85
+ */
86
+ export function summarizeTokenUse(db, tenantId, sinceIso) {
87
+ // SAFETY: the SELECT names exactly these columns, all aggregates or TEXT.
88
+ const rows = db.prepare(`SELECT surface,
89
+ SUM(CASE WHEN event = 'inject' THEN 1 ELSE 0 END) AS injected,
90
+ SUM(CASE WHEN event = 'inject' THEN tokens ELSE 0 END) AS tokens,
91
+ SUM(CASE WHEN event = 'skip' THEN 1 ELSE 0 END) AS skipped,
92
+ SUM(CASE WHEN event = 'skip' THEN tokens ELSE 0 END) AS avoided,
93
+ COUNT(DISTINCT session_id) AS sessions
94
+ FROM token_ledger
95
+ WHERE tenant_id = ? AND ts >= ?
96
+ GROUP BY surface`).all(tenantId, sinceIso);
97
+ const bySurface = new Map(rows.map((r) => [r.surface, r]));
98
+ const surfaces = [];
99
+ for (const surface of TOKEN_SURFACES) {
100
+ const r = bySurface.get(surface);
101
+ if (!r)
102
+ continue;
103
+ surfaces.push({
104
+ surface,
105
+ injected: Number(r.injected),
106
+ tokens: Number(r.tokens),
107
+ skipped: Number(r.skipped),
108
+ tokensAvoided: Number(r.avoided),
109
+ sessions: Number(r.sessions),
110
+ });
111
+ }
112
+ // SAFETY: the SELECT names exactly these two aggregate columns.
113
+ const perSession = db.prepare(`SELECT COUNT(DISTINCT session_id) AS sessions,
114
+ SUM(CASE WHEN event = 'inject' THEN tokens ELSE 0 END) AS tokens
115
+ FROM token_ledger
116
+ WHERE tenant_id = ? AND ts >= ? AND session_id IS NOT NULL`).get(tenantId, sinceIso);
117
+ const sessionCount = Number(perSession?.sessions ?? 0);
118
+ const sessionTokens = Number(perSession?.tokens ?? 0);
119
+ return {
120
+ since: sinceIso,
121
+ surfaces,
122
+ totalTokens: surfaces.reduce((s, x) => s + x.tokens, 0),
123
+ totalTokensAvoided: surfaces.reduce((s, x) => s + x.tokensAvoided, 0),
124
+ meanTokensPerSession: sessionCount > 0 ? Math.round(sessionTokens / sessionCount) : 0,
125
+ };
126
+ }
127
+ /** JSON-value string check without a runtime `typeof` (anti-slop rule). */
128
+ function isJsonString(value) {
129
+ return value !== undefined && value !== null && value.constructor === String;
130
+ }
131
+ /** JSON-value plain-object check (excludes arrays and null). */
132
+ function isJsonObject(value) {
133
+ return value !== undefined && value !== null && !Array.isArray(value) && value.constructor === Object;
134
+ }
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) {
141
+ if (!stdinText || stdinText.trim() === '')
142
+ return null;
143
+ let payload;
144
+ try {
145
+ // SAFETY: JSON.parse returns a JSON value by definition.
146
+ payload = JSON.parse(stdinText.trim());
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ if (!isJsonObject(payload))
152
+ return null;
153
+ const sessionId = payload.session_id;
154
+ if (!isJsonString(sessionId) || sessionId.trim() === '')
155
+ return null;
156
+ if (requiredSource !== null && payload.source !== requiredSource)
157
+ return null;
158
+ return sessionId;
159
+ }
160
+ /**
161
+ * Ledger totals per session id since `sinceIso`, across every surface.
162
+ * Claude Code hook rows carry the host's session id, which is also the
163
+ * transcript file name, so these join to the host's own usage records.
164
+ */
165
+ export function tokensBySession(db, tenantId, sinceIso) {
166
+ // SAFETY: the SELECT names exactly these columns, all aggregates or TEXT.
167
+ const rows = db.prepare(`SELECT session_id,
168
+ SUM(CASE WHEN event = 'inject' THEN tokens ELSE 0 END) AS sent,
169
+ SUM(CASE WHEN event = 'skip' THEN tokens ELSE 0 END) AS skipped,
170
+ SUM(CASE WHEN event = 'inject' THEN 1 ELSE 0 END) AS injections
171
+ FROM token_ledger
172
+ WHERE tenant_id = ? AND ts >= ? AND session_id IS NOT NULL
173
+ GROUP BY session_id`).all(tenantId, sinceIso);
174
+ return rows.map((r) => ({
175
+ sessionId: r.session_id,
176
+ sent: Number(r.sent),
177
+ skipped: Number(r.skipped),
178
+ injections: Number(r.injections),
179
+ }));
180
+ }
181
+ //# 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.45.0";
19
+ export declare const PACKAGE_VERSION = "1.47.0";
20
20
  /** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
21
21
  export declare function compareSemver(a: string, b: string): number;
22
22
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * an ESM `import` can resolve cleanly, and a hardcoded constant survives
17
17
  * any packager that drops .json files.
18
18
  */
19
- export const PACKAGE_VERSION = '1.45.0';
19
+ export const PACKAGE_VERSION = '1.47.0';
20
20
  /** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
21
21
  export function compareSemver(a, b) {
22
22
  const parse = (v) => {
@@ -2,7 +2,7 @@
2
2
  "id": "hippo-memory",
3
3
  "name": "Hippo Memory",
4
4
  "description": "Biologically-inspired memory for AI agents. Decay by default, retrieval strengthening, sleep consolidation.",
5
- "version": "1.45.0",
5
+ "version": "1.47.0",
6
6
 
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.45.0",
3
+ "version": "1.47.0",
4
4
  "type": "module",
5
5
  "description": "Hippo Memory plugin for OpenClaw - biologically-inspired agent memory",
6
6
  "main": "index.ts",
@@ -2,7 +2,7 @@
2
2
  "id": "hippo-memory",
3
3
  "name": "Hippo Memory",
4
4
  "description": "Biologically-inspired memory for AI agents. Decay by default, retrieval strengthening, sleep consolidation.",
5
- "version": "1.45.0",
5
+ "version": "1.47.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.45.0",
3
+ "version": "1.47.0",
4
4
  "description": "Biologically-inspired memory for AI agents. Zero runtime deps, SQLite, MCP server, and an opt-in hosted TypeSafe Jev reranker. Decay, retrieval strengthening, consolidation.",
5
+ "mcpName": "io.github.kitfunso/hippo-memory",
5
6
  "type": "module",
6
7
  "main": "./dist/index.js",
7
8
  "exports": {