hippo-memory 1.45.0 → 1.46.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 (49) hide show
  1. package/README.md +57 -15
  2. package/dist/ablation.d.ts +10 -1
  3. package/dist/ablation.js +17 -1
  4. package/dist/api.d.ts +60 -1
  5. package/dist/api.js +189 -7
  6. package/dist/audit.d.ts +1 -1
  7. package/dist/capture-error.d.ts +20 -0
  8. package/dist/capture-error.js +82 -0
  9. package/dist/capture.d.ts +25 -8
  10. package/dist/capture.js +100 -5
  11. package/dist/cli.d.ts +6 -1
  12. package/dist/cli.js +381 -48
  13. package/dist/config.d.ts +20 -0
  14. package/dist/config.js +35 -0
  15. package/dist/consolidate.d.ts +6 -0
  16. package/dist/consolidate.js +98 -13
  17. package/dist/db.js +57 -1
  18. package/dist/doctor.d.ts +34 -0
  19. package/dist/doctor.js +174 -0
  20. package/dist/dormant.d.ts +91 -0
  21. package/dist/dormant.js +121 -0
  22. package/dist/eval-stats.d.ts +123 -0
  23. package/dist/eval-stats.js +187 -0
  24. package/dist/half-life-migration.d.ts +55 -0
  25. package/dist/half-life-migration.js +111 -0
  26. package/dist/hooks.d.ts +4 -0
  27. package/dist/hooks.js +47 -0
  28. package/dist/mcp/server.d.ts +6 -0
  29. package/dist/mcp/server.js +70 -13
  30. package/dist/memory.d.ts +16 -2
  31. package/dist/memory.js +27 -5
  32. package/dist/physics-config.js +5 -1
  33. package/dist/recall-scope.d.ts +24 -0
  34. package/dist/recall-scope.js +41 -0
  35. package/dist/reject-flow.d.ts +3 -3
  36. package/dist/reject-flow.js +10 -3
  37. package/dist/search.d.ts +4 -4
  38. package/dist/search.js +23 -18
  39. package/dist/server.js +11 -1
  40. package/dist/store.d.ts +12 -1
  41. package/dist/store.js +58 -12
  42. package/dist/token-ledger.d.ts +119 -0
  43. package/dist/token-ledger.js +181 -0
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  47. package/extensions/openclaw-plugin/package.json +1 -1
  48. package/openclaw.plugin.json +1 -1
  49. package/package.json +2 -1
@@ -0,0 +1,20 @@
1
+ import type { JsonValue } from './working-memory.js';
2
+ /** Why a failure was not stored, or `stored`. */
3
+ export type CaptureErrorOutcome = 'stored' | 'duplicate' | 'skipped-interrupt' | 'skipped-routine' | 'skipped-invalid';
4
+ /** Normalised form used to spot repeats of the same failure. */
5
+ export declare function failureSignature(text: string): string;
6
+ /**
7
+ * The memory text for a failure payload, or the reason it is not stored.
8
+ * Pure: no store access.
9
+ */
10
+ export declare function lessonFromFailure(payload: JsonValue): {
11
+ text: string;
12
+ } | {
13
+ skip: Exclude<CaptureErrorOutcome, 'stored' | 'duplicate'>;
14
+ };
15
+ /**
16
+ * Store a failure payload as an error memory unless it is routine or a
17
+ * repeat of an auto-captured error already in the store.
18
+ */
19
+ export declare function captureToolFailure(hippoRoot: string, tenantId: string, payload: JsonValue): CaptureErrorOutcome;
20
+ //# sourceMappingURL=capture-error.d.ts.map
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `hippo capture-error`: turn a failed tool call into an error memory. Run by
3
+ * the Claude Code `PostToolUseFailure` hook (both `hippo hook install
4
+ * claude-code` and the plugin), which writes the failure as JSON on stdin.
5
+ *
6
+ * Most failed tool calls are not lessons. An interrupt, a permission the
7
+ * user declined, a search that found nothing or a `grep` that exits 1 is
8
+ * routine, and error memories decay slower than other memories, so storing
9
+ * them would crowd out real lessons. Those are dropped here, repeats of the
10
+ * same failure are stored once, and what is stored is marked `observed`
11
+ * (auto-captured, not verified): outcome feedback, not capture, is what
12
+ * should strengthen it.
13
+ */
14
+ import { createMemory } from './memory.js';
15
+ import { writeEntry, loadAllEntries } from './store.js';
16
+ import { loadConfig } from './config.js';
17
+ const MAX_LEN = 200;
18
+ /** Failures that are routine, not lessons. */
19
+ const ROUTINE_PATTERNS = [
20
+ /user (?:doesn't|does not) want|denied by (?:the )?user|user (?:rejected|declined|denied)|permission (?:denied|to use)|was blocked by (?:a )?hook/i,
21
+ /\bno (?:matches|files|results) found\b/i,
22
+ ];
23
+ /** Shell commands whose exit code 1 means "nothing found" or "differs", not an error. */
24
+ const QUIET_EXIT_1 = /^\s*(?:grep|rg|egrep|fgrep|find|test|\[|diff|cmp|git diff|git grep)\b/;
25
+ function isString(v) {
26
+ return v !== undefined && v !== null && v.constructor === String;
27
+ }
28
+ function isObject(v) {
29
+ return v !== undefined && v !== null && !Array.isArray(v) && v.constructor === Object;
30
+ }
31
+ /** Normalised form used to spot repeats of the same failure. */
32
+ export function failureSignature(text) {
33
+ return text.toLowerCase().replace(/[0-9a-f]{7,}/g, '#').replace(/\d+/g, '#').replace(/\s+/g, ' ').trim();
34
+ }
35
+ /**
36
+ * The memory text for a failure payload, or the reason it is not stored.
37
+ * Pure: no store access.
38
+ */
39
+ export function lessonFromFailure(payload) {
40
+ if (!isObject(payload))
41
+ return { skip: 'skipped-invalid' };
42
+ // SAFETY: isObject narrowed payload to a plain JSON object; the fields read are all optional.
43
+ const p = payload;
44
+ if (p.is_interrupt === true)
45
+ return { skip: 'skipped-interrupt' };
46
+ if (!isString(p.error) || p.error.trim().length < 12)
47
+ return { skip: 'skipped-invalid' };
48
+ const tool = isString(p.tool_name) ? p.tool_name : 'tool';
49
+ const error = p.error.replace(/\s+/g, ' ').trim();
50
+ if (ROUTINE_PATTERNS.some((re) => re.test(error)))
51
+ return { skip: 'skipped-routine' };
52
+ if (tool === 'Grep' || tool === 'Glob')
53
+ return { skip: 'skipped-routine' };
54
+ if (tool === 'Bash' && isObject(p.tool_input) && isString(p.tool_input['command'])
55
+ && QUIET_EXIT_1.test(p.tool_input['command']) && /exit code 1\b/i.test(error)) {
56
+ return { skip: 'skipped-routine' };
57
+ }
58
+ return { text: `${tool}: ${error}`.slice(0, MAX_LEN) };
59
+ }
60
+ /**
61
+ * Store a failure payload as an error memory unless it is routine or a
62
+ * repeat of an auto-captured error already in the store.
63
+ */
64
+ export function captureToolFailure(hippoRoot, tenantId, payload) {
65
+ const lesson = lessonFromFailure(payload);
66
+ if ('skip' in lesson)
67
+ return lesson.skip;
68
+ const sig = failureSignature(lesson.text);
69
+ const repeat = loadAllEntries(hippoRoot, tenantId).some((e) => e.tags.includes('auto-captured') && failureSignature(e.content) === sig);
70
+ if (repeat)
71
+ return 'duplicate';
72
+ const entry = createMemory(lesson.text, {
73
+ tags: ['error', 'auto-captured'],
74
+ source: 'tool-failure',
75
+ confidence: 'observed',
76
+ tenantId,
77
+ baseHalfLifeDays: loadConfig(hippoRoot).defaultHalfLifeDays,
78
+ });
79
+ writeEntry(hippoRoot, entry);
80
+ return 'stored';
81
+ }
82
+ //# sourceMappingURL=capture-error.js.map
package/dist/capture.d.ts CHANGED
@@ -45,14 +45,6 @@ export interface CaptureOptions {
45
45
  */
46
46
  tenantId?: string;
47
47
  }
48
- /**
49
- * Build a compact text summary from a Claude Code / OpenCode JSONL transcript.
50
- * Keeps plain user messages and the final chunk of assistant text, drops
51
- * thinking blocks, tool_use, and tool_result noise. Output is fed to the
52
- * existing `extractFromText` pipeline.
53
- *
54
- * Exported for tests.
55
- */
56
48
  export declare function summariseTranscript(jsonl: string): string;
57
49
  /**
58
50
  * Resolve a transcript path for `--last-session`.
@@ -111,6 +103,24 @@ export declare function readTranscriptTail(transcriptPath: string, capBytes?: nu
111
103
  * guard, not per-file copies.
112
104
  */
113
105
  export declare function sanitizeLogMessage(message: string): string;
106
+ /** What one pre-compact run saved, reported to the user after compaction. */
107
+ export interface PreCompactReport {
108
+ snapshotSaved: boolean;
109
+ captured: number;
110
+ /** Claude Code session the run belonged to, when the payload named one. */
111
+ sessionId: string | null;
112
+ }
113
+ /**
114
+ * The line shown to the user after compaction, or null when nothing was
115
+ * saved. Without it the only sign was Claude Code's generic
116
+ * "PreCompact [...] completed successfully".
117
+ */
118
+ export declare function preCompactMessage(report: Pick<PreCompactReport, 'snapshotSaved' | 'captured'>): string | null;
119
+ /**
120
+ * Where pre-compact leaves its report for `hippo post-compact`: next to the
121
+ * pre-compact log, so both hooks find it from the same `--log-file`.
122
+ */
123
+ export declare function preCompactReportPath(logFile: string): string;
114
124
  export interface PreCompactOptions {
115
125
  stdinText?: string;
116
126
  stdinTimedOut?: boolean;
@@ -124,4 +134,11 @@ export interface PreCompactOptions {
124
134
  * could turn a caught-and-logged failure back into a non-zero exit.
125
135
  */
126
136
  export declare function cmdPreCompact(hippoRoot: string, options: PreCompactOptions): Promise<void>;
137
+ /**
138
+ * The message for the PostCompact hook, from the report pre-compact left,
139
+ * or null when there is nothing to say. Consumes the report, so the message
140
+ * shows once. A report from another session, or an old one, is dropped.
141
+ * Never throws.
142
+ */
143
+ export declare function postCompactMessage(stdinText: string | undefined, logFile?: string, now?: Date): string | null;
127
144
  //# sourceMappingURL=capture.d.ts.map
package/dist/capture.js CHANGED
@@ -21,6 +21,7 @@ import { defaultPreCompactLogPath } from './hooks.js';
21
21
  import { redactSecrets } from './secret-detect.js';
22
22
  import { RejectedValueError, checkRejectionGuard } from './rejection.js';
23
23
  import { openHippoDb, closeHippoDb } from './db.js';
24
+ import { loadConfig } from './config.js';
24
25
  // Sentence-level patterns
25
26
  //
26
27
  // T1 (DF2): each pattern now carries TWO capture groups — group 1 is the
@@ -489,6 +490,7 @@ function writeExtractedItems(hippoRoot, tenantId, extracted) {
489
490
  let captured = 0;
490
491
  let skipped = 0;
491
492
  let rejected = 0;
493
+ const baseHalfLifeDays = loadConfig(hippoRoot).defaultHalfLifeDays;
492
494
  for (const item of extracted) {
493
495
  if (isDuplicate(item.content, existing)) {
494
496
  skipped++;
@@ -500,6 +502,7 @@ function writeExtractedItems(hippoRoot, tenantId, extracted) {
500
502
  source: 'capture',
501
503
  confidence: 'observed',
502
504
  tenantId,
505
+ baseHalfLifeDays,
503
506
  });
504
507
  // AT1 (plan §3 containment): a refusal is per-VALUE — one rejected
505
508
  // extraction must not abort the rest of this transcript's captures.
@@ -551,6 +554,14 @@ function errorMessage(cause) {
551
554
  *
552
555
  * Exported for tests.
553
556
  */
557
+ /** Leading markers of the command lines Claude Code writes with type 'user'. */
558
+ const CLAUDE_CODE_COMMAND_PREFIXES = ['<local-command-', '<command-name>', '<command-message>', '<command-args>'];
559
+ function isNonHumanUserLine(entry, content) {
560
+ if (entry.isMeta === true || entry.isSidechain === true || entry.isCompactSummary === true)
561
+ return true;
562
+ const head = content.trimStart();
563
+ return CLAUDE_CODE_COMMAND_PREFIXES.some((p) => head.startsWith(p));
564
+ }
554
565
  export function summariseTranscript(jsonl) {
555
566
  const lines = jsonl.split('\n').filter((l) => l.trim());
556
567
  const userMessages = [];
@@ -571,8 +582,9 @@ export function summariseTranscript(jsonl) {
571
582
  continue;
572
583
  const content = 'content' in message ? message.content : undefined;
573
584
  if (entry.type === 'user') {
574
- // Plain text user messages only (skip tool_result arrays)
575
- if (isStringValue(content) && content.trim()) {
585
+ // Plain text user messages only (skip tool_result arrays), and only
586
+ // ones the human wrote (see isNonHumanUserLine).
587
+ if (isStringValue(content) && content.trim() && !isNonHumanUserLine(entry, content)) {
576
588
  userMessages.push(content.trim());
577
589
  }
578
590
  }
@@ -1030,7 +1042,7 @@ function lastPlainUserMessage(jsonl) {
1030
1042
  if (!message)
1031
1043
  continue;
1032
1044
  const content = 'content' in message ? message.content : undefined;
1033
- if (isStringValue(content) && content.trim())
1045
+ if (isStringValue(content) && content.trim() && !isNonHumanUserLine(entry, content))
1034
1046
  return content.trim();
1035
1047
  }
1036
1048
  return '';
@@ -1109,12 +1121,34 @@ function isReadableFile(filePath) {
1109
1121
  return false;
1110
1122
  }
1111
1123
  }
1124
+ /**
1125
+ * The line shown to the user after compaction, or null when nothing was
1126
+ * saved. Without it the only sign was Claude Code's generic
1127
+ * "PreCompact [...] completed successfully".
1128
+ */
1129
+ export function preCompactMessage(report) {
1130
+ if (!report.snapshotSaved && report.captured === 0)
1131
+ return null;
1132
+ const parts = [];
1133
+ if (report.snapshotSaved)
1134
+ parts.push('your task snapshot');
1135
+ if (report.captured > 0)
1136
+ parts.push(`${report.captured} new memor${report.captured === 1 ? 'y' : 'ies'}`);
1137
+ return `Hippo saved ${parts.join(' and ')} before compacting.${report.snapshotSaved ? ' The snapshot is restored into the new context.' : ''}`;
1138
+ }
1139
+ /**
1140
+ * Where pre-compact leaves its report for `hippo post-compact`: next to the
1141
+ * pre-compact log, so both hooks find it from the same `--log-file`.
1142
+ */
1143
+ export function preCompactReportPath(logFile) {
1144
+ return path.join(path.dirname(logFile), 'pre-compact-last.json');
1145
+ }
1112
1146
  /**
1113
1147
  * Runs the PreCompact producer. Returns any `embedMemory` promises kicked
1114
1148
  * off along the way (empty on every skip path) so `cmdPreCompact` can await
1115
1149
  * them, bounded, before it exits (X6).
1116
1150
  */
1117
- function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile) {
1151
+ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
1118
1152
  // X3: the PreCompact hook fires in every Claude Code project, including
1119
1153
  // ones that never ran `hippo init`, so gate before any store-opening call
1120
1154
  // (saveActiveTaskSnapshot etc. call initStore, which would create one).
@@ -1156,6 +1190,7 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile) {
1156
1190
  }
1157
1191
  if ('session_id' in payload && isStringValue(payload.session_id))
1158
1192
  sessionId = payload.session_id;
1193
+ report.sessionId = sessionId;
1159
1194
  payloadTranscriptPath = payload.transcript_path;
1160
1195
  }
1161
1196
  // X11: payload transcript_path must end .jsonl. No directory-containment
@@ -1291,6 +1326,7 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile) {
1291
1326
  session_id: sessionId,
1292
1327
  });
1293
1328
  appendPreCompactLog(logFile, 'snapshot saved');
1329
+ report.snapshotSaved = true;
1294
1330
  }
1295
1331
  catch (err) {
1296
1332
  appendPreCompactLog(logFile, `snapshot save failed: ${errorMessage(err)}`);
@@ -1300,6 +1336,7 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile) {
1300
1336
  // the next SessionEnd capture (existing dedup absorbs the overlap).
1301
1337
  try {
1302
1338
  const { captured, skipped, rejected, embeds } = writeExtractedItems(hippoRoot, tenantId, extracted);
1339
+ report.captured = captured;
1303
1340
  appendPreCompactLog(logFile, `capture: ${captured} items captured, ${skipped} skipped` +
1304
1341
  (rejected > 0 ? `, ${rejected} rejected` : ''));
1305
1342
  return embeds;
@@ -1324,8 +1361,9 @@ const EMBED_SETTLE_TIMEOUT_MS = 3000;
1324
1361
  export async function cmdPreCompact(hippoRoot, options) {
1325
1362
  const logFile = options.logFile ?? defaultPreCompactLogPath();
1326
1363
  let embeds = [];
1364
+ const report = { snapshotSaved: false, captured: 0, sessionId: null };
1327
1365
  try {
1328
- embeds = runPreCompact(hippoRoot, options.stdinText, options.stdinTimedOut ?? false, logFile);
1366
+ embeds = runPreCompact(hippoRoot, options.stdinText, options.stdinTimedOut ?? false, logFile, report);
1329
1367
  }
1330
1368
  catch (err) {
1331
1369
  appendPreCompactLog(logFile, `pre-compact failed: ${errorMessage(err)}`);
@@ -1341,6 +1379,63 @@ export async function cmdPreCompact(hippoRoot, options) {
1341
1379
  clearTimeout(timer);
1342
1380
  appendPreCompactLog(logFile, outcome === 'settled' ? 'embeddings settled' : 'embeddings timeout');
1343
1381
  }
1382
+ // Nothing goes to stdout: Claude Code passes PreCompact stdout to the
1383
+ // summarising model as extra instructions. The PostCompact hook
1384
+ // (`hippo post-compact`) tells the user instead, from this report.
1385
+ if (report.snapshotSaved || report.captured > 0) {
1386
+ try {
1387
+ fs.writeFileSync(preCompactReportPath(logFile), JSON.stringify({ ...report, at: new Date().toISOString() }));
1388
+ }
1389
+ catch {
1390
+ // Losing the message must never fail the hook.
1391
+ }
1392
+ }
1344
1393
  process.exit(0);
1345
1394
  }
1395
+ /** How old a pre-compact report may be and still describe this compaction. */
1396
+ const POST_COMPACT_REPORT_MAX_AGE_MS = 10 * 60_000;
1397
+ /**
1398
+ * The message for the PostCompact hook, from the report pre-compact left,
1399
+ * or null when there is nothing to say. Consumes the report, so the message
1400
+ * shows once. A report from another session, or an old one, is dropped.
1401
+ * Never throws.
1402
+ */
1403
+ export function postCompactMessage(stdinText, logFile = defaultPreCompactLogPath(), now = new Date()) {
1404
+ const reportFile = preCompactReportPath(logFile);
1405
+ let raw;
1406
+ try {
1407
+ raw = fs.readFileSync(reportFile, 'utf8');
1408
+ fs.rmSync(reportFile, { force: true });
1409
+ }
1410
+ catch {
1411
+ return null;
1412
+ }
1413
+ try {
1414
+ const report = JSON.parse(raw);
1415
+ if (!isObjectLike(report))
1416
+ return null;
1417
+ const at = 'at' in report && isStringValue(report.at) ? Date.parse(report.at) : Number.NaN;
1418
+ if (Number.isNaN(at) || now.getTime() - at > POST_COMPACT_REPORT_MAX_AGE_MS)
1419
+ return null;
1420
+ let payloadSession = null;
1421
+ try {
1422
+ const payload = JSON.parse((stdinText ?? '').trim() || 'null');
1423
+ if (isObjectLike(payload) && 'session_id' in payload && isStringValue(payload.session_id))
1424
+ payloadSession = payload.session_id;
1425
+ }
1426
+ catch {
1427
+ // No usable payload: fall back to the age check alone.
1428
+ }
1429
+ const reportSession = 'sessionId' in report && isStringValue(report.sessionId) ? report.sessionId : null;
1430
+ if (payloadSession !== null && reportSession !== null && payloadSession !== reportSession)
1431
+ return null;
1432
+ return preCompactMessage({
1433
+ snapshotSaved: 'snapshotSaved' in report && report.snapshotSaved === true,
1434
+ captured: 'captured' in report && Number.isInteger(report.captured) ? Number(report.captured) : 0,
1435
+ });
1436
+ }
1437
+ catch {
1438
+ return null;
1439
+ }
1440
+ }
1346
1441
  //# sourceMappingURL=capture.js.map
package/dist/cli.d.ts CHANGED
@@ -19,6 +19,9 @@
19
19
  * hippo reject <id>|--value "<text>" --reason "<why>"
20
20
  * hippo rejections
21
21
  * hippo unreject <digest-prefix>
22
+ * hippo dormant [<query>] [--limit <n>] [--json] | restore <id> | forget <id>
23
+ * hippo tokens [--days <n>] [--json] [--global]
24
+ * hippo doctor [--json]
22
25
  * hippo inspect <id>
23
26
  * hippo embed [--status]
24
27
  * hippo watch "<command>"
@@ -58,6 +61,8 @@ export declare function printContextMarkdown(items: Array<{
58
61
  score: number;
59
62
  tokens: number;
60
63
  isGlobal: boolean;
61
- }>, totalTokens: number, framing?: string): void;
64
+ }>, totalTokens: number, framing?: string, opts?: {
65
+ showStrength?: boolean;
66
+ }): void;
62
67
  export declare function runCli(argv?: string[]): Promise<void>;
63
68
  //# sourceMappingURL=cli.d.ts.map