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.
- package/README.md +58 -15
- package/bin/hippo.js +0 -0
- package/dist/ablation.d.ts +10 -1
- package/dist/ablation.js +17 -1
- package/dist/api.d.ts +66 -1
- package/dist/api.js +202 -7
- package/dist/audit.d.ts +1 -1
- package/dist/capture-error.d.ts +26 -0
- package/dist/capture-error.js +110 -0
- package/dist/capture.d.ts +25 -8
- package/dist/capture.js +100 -5
- package/dist/cli.d.ts +6 -1
- package/dist/cli.js +432 -48
- package/dist/config.d.ts +20 -0
- package/dist/config.js +35 -0
- package/dist/consolidate.d.ts +6 -0
- package/dist/consolidate.js +98 -13
- package/dist/db.js +81 -1
- package/dist/doctor.d.ts +34 -0
- package/dist/doctor.js +183 -0
- package/dist/dormant.d.ts +91 -0
- package/dist/dormant.js +121 -0
- package/dist/eval-stats.d.ts +123 -0
- package/dist/eval-stats.js +187 -0
- package/dist/failure-log.d.ts +49 -0
- package/dist/failure-log.js +58 -0
- package/dist/half-life-migration.d.ts +55 -0
- package/dist/half-life-migration.js +111 -0
- package/dist/hooks.d.ts +4 -0
- package/dist/hooks.js +47 -0
- package/dist/mcp/server.d.ts +6 -0
- package/dist/mcp/server.js +70 -13
- package/dist/memory.d.ts +16 -2
- package/dist/memory.js +27 -5
- package/dist/physics-config.js +5 -1
- package/dist/recall-scope.d.ts +24 -0
- package/dist/recall-scope.js +41 -0
- package/dist/reject-flow.d.ts +3 -3
- package/dist/reject-flow.js +10 -3
- package/dist/search.d.ts +4 -4
- package/dist/search.js +23 -18
- package/dist/server.js +11 -1
- package/dist/store.d.ts +12 -1
- package/dist/store.js +58 -12
- package/dist/token-ledger.d.ts +119 -0
- package/dist/token-ledger.js +181 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- 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.
|
|
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.
|
|
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) => {
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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": {
|