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
package/dist/cli.js 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>"
@@ -45,7 +48,7 @@ import { isHandoffOutcome, formatHandoffEvidenceLine } from './handoff.js';
45
48
  import { isCardStatus } from './card.js';
46
49
  import { loadCardDetail } from './card-detail.js';
47
50
  import { passesScopeFilterForRecall } from './recall-scope.js';
48
- import { hybridSearch, physicsSearch, explainMatch, textOverlap, tokenize as tokenizeQuery } from './search.js';
51
+ import { estimateTokens, hybridSearch, physicsSearch, explainMatch, textOverlap, tokenize as tokenizeQuery } from './search.js';
49
52
  import { compareEntryIdentity } from './compare.js';
50
53
  import { renderTraceContent, parseSteps } from './trace.js';
51
54
  import { writeRecallTraceAtRoot } from './recall-trace.js';
@@ -56,6 +59,10 @@ import { loadPhysicsState, resetAllPhysicsState } from './physics-state.js';
56
59
  import { computeSystemEnergy, vecNorm } from './physics.js';
57
60
  import { loadConfig } from './config.js';
58
61
  import { openHippoDb, closeHippoDb } from './db.js';
62
+ import { runDoctor, formatDoctor } from './doctor.js';
63
+ import { captureToolFailure } from './capture-error.js';
64
+ import { blockHash, hookPayloadSessionId, lastSentState, recordTokenUse, shouldSkipUnchanged } from './token-ledger.js';
65
+ import { FAILURE_LOG_RETENTION_DAYS } from './failure-log.js';
59
66
  import { pushGoal, getActiveGoals, completeGoal, suspendGoal, resumeGoal, applyGoalStackBoost } from './goals.js';
60
67
  import { rowToGoal } from './goals.js';
61
68
  import { captureError, extractLessons, partitionLessons, deduplicateLesson, runWatched, fetchGitLog, isGitRepo, } from './autolearn.js';
@@ -65,7 +72,7 @@ import { detectScope } from './scope.js';
65
72
  import { getGlobalRoot, initGlobal, shareMemory, listPeers, autoShare, transferScore, searchBothHybrid, syncGlobalToLocal, } from './shared.js';
66
73
  import { DAILY_TASK_NAME, buildDailyRunnerCommand, listRegisteredWorkspaces, registerWorkspace, runDailyMaintenance, } from './scheduler.js';
67
74
  import { importChatGPT, importClaude, importCursor, importGenericFile, importMarkdown, importVault, } from './importers.js';
68
- import { cmdCapture, cmdPreCompact, resolveLastSessionTranscript, truncateCodePointSafe, sanitizeLogMessage } from './capture.js';
75
+ import { cmdCapture, cmdPreCompact, postCompactMessage, resolveLastSessionTranscript, truncateCodePointSafe, sanitizeLogMessage } from './capture.js';
69
76
  import { readStdinBounded } from './stdin.js';
70
77
  import { auditMemories, appendAuditEvent, } from './audit.js';
71
78
  import { listApiKeys, revokeApiKey } from './auth.js';
@@ -542,17 +549,17 @@ function autoInstallHooks(quiet) {
542
549
  // Skip if we already installed a hook into this file
543
550
  if (installed.has(targetPath))
544
551
  continue;
545
- // Skip if hook already present
546
- if (fs.existsSync(targetPath)) {
547
- const content = fs.readFileSync(targetPath, 'utf8');
548
- if (content.includes(HOOK_MARKERS.start))
549
- continue;
550
- }
552
+ // An instruction block already present is not rewritten, but the JSON
553
+ // hooks and plugins below still run: they are idempotent, and skipping
554
+ // them meant a store set up by an older hippo never got hooks added in
555
+ // later releases (PreCompact, PostToolUseFailure) on a re-run of init.
556
+ const blockPresent = fs.existsSync(targetPath)
557
+ && fs.readFileSync(targetPath, 'utf8').includes(HOOK_MARKERS.start);
551
558
  // Only patch the agent-instructions file if it already exists.
552
559
  // Never create a new CLAUDE.md / AGENTS.md / etc. just because a sibling
553
560
  // marker file (.claude/settings.json, .codex, etc.) was detected — that
554
561
  // pollutes dirs the user didn't intend to configure.
555
- if (fs.existsSync(targetPath)) {
562
+ if (!blockPresent && fs.existsSync(targetPath)) {
556
563
  const block = `${HOOK_MARKERS.start}\n${hookDef.content}\n${HOOK_MARKERS.end}`;
557
564
  const existing = fs.readFileSync(targetPath, 'utf8');
558
565
  const sep = existing.endsWith('\n') ? '\n' : '\n\n';
@@ -563,7 +570,7 @@ function autoInstallHooks(quiet) {
563
570
  // The Codex session-capture wrapper swaps the codex launcher binary, so
564
571
  // init never installs it silently — it points at the explicit opt-in
565
572
  // command instead (issue #133).
566
- if (hook === 'codex' && !isCodexWrapperInstalled()) {
573
+ if (hook === 'codex' && !blockPresent && !isCodexWrapperInstalled()) {
567
574
  console.log(' Codex detected. To capture Codex sessions: hippo hook install codex');
568
575
  }
569
576
  // For Claude Code, also install SessionEnd+SessionStart entries in its
@@ -586,6 +593,12 @@ function autoInstallHooks(quiet) {
586
593
  if (result.installedCompactResume) {
587
594
  console.log(` Auto-installed hippo compact-resume SessionStart(compact) hook in ${hook} settings`);
588
595
  }
596
+ if (result.installedPostCompact) {
597
+ console.log(` Auto-installed hippo post-compact PostCompact hook in ${hook} settings`);
598
+ }
599
+ if (result.installedCaptureError) {
600
+ console.log(` Auto-installed hippo capture-error PostToolUseFailure hook in ${hook} settings`);
601
+ }
589
602
  if (result.migratedFromStop) {
590
603
  console.log(` Migrated legacy Stop hook → SessionEnd (no longer runs every turn)`);
591
604
  }
@@ -1657,7 +1670,7 @@ async function cmdRecall(hippoRoot, query, flags) {
1657
1670
  sessionHandoff =
1658
1671
  rawHandoff && passesScopeFilterForRecall(rowScope(rawHandoff), effectiveScope) ? rawHandoff : null;
1659
1672
  recentSessionEvents = rawEvents.filter((e) => passesScopeFilterForRecall(rowScope(e), effectiveScope));
1660
- const tokenize = (s) => s ? Math.ceil(s.length / 4) : 0;
1673
+ const tokenize = (s) => s ? estimateTokens(s) : 0;
1661
1674
  continuityTokens =
1662
1675
  tokenize(activeSnapshot?.task) +
1663
1676
  tokenize(activeSnapshot?.summary) +
@@ -1675,6 +1688,16 @@ async function cmdRecall(hippoRoot, query, flags) {
1675
1688
  const hasContinuity = activeSnapshot !== null
1676
1689
  || sessionHandoff !== null
1677
1690
  || recentSessionEvents.length > 0;
1691
+ // TE0 token ledger: the memory text this recall hands back (results plus
1692
+ // any continuity block), recorded once per query.
1693
+ withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
1694
+ tenantId,
1695
+ sessionId: hostSessionId() ?? null,
1696
+ surface: 'recall',
1697
+ event: 'inject',
1698
+ items: results.length,
1699
+ tokens: results.reduce((acc, r) => acc + (r.tokens || 0), 0) + continuityTokens,
1700
+ }));
1678
1701
  if (results.length === 0) {
1679
1702
  // LC1 F1 structural fix (docs/plans/2026-08-02-lc1-recall-trace-persistence.md):
1680
1703
  // trace the zero-result recall too (result_count 0, no result rows) so
@@ -2748,6 +2771,14 @@ export function renderSleepResult(result) {
2748
2771
  console.log(`\nResults:`);
2749
2772
  console.log(` Active memories: ${result.active}`);
2750
2773
  console.log(` Removed (decayed): ${result.removed}`);
2774
+ // Only when dormant.enabled moved something, so every other render stays
2775
+ // byte-identical (tests/cli-context-render-snapshot.test.ts).
2776
+ if (result.dormant !== undefined && result.dormant > 0) {
2777
+ console.log(` Kept dormant: ${result.dormant} (hippo dormant to list)`);
2778
+ }
2779
+ if (result.dormantExpired !== undefined && result.dormantExpired > 0) {
2780
+ console.log(` Expired dormant: ${result.dormantExpired} (past dormant.retentionDays)`);
2781
+ }
2751
2782
  console.log(` Merged episodic: ${result.mergedEpisodic}`);
2752
2783
  console.log(` New semantic: ${result.newSemantic}`);
2753
2784
  if (result.details && result.details.length > 0) {
@@ -3506,6 +3537,12 @@ function cmdForget(hippoRoot, id, flags) {
3506
3537
  // memory, not a missing one. Point the user at the archive path.
3507
3538
  console.error(rawForgetRefusal(id));
3508
3539
  }
3540
+ else if (api.isDormant(ctx, id)) {
3541
+ // Sleep moved it to the dormant store: it is not in active memory, so
3542
+ // point at the command that owns it.
3543
+ console.error(`${id} is dormant, not in active memory. Delete it for good: hippo dormant forget ${id} ` +
3544
+ `(or bring it back: hippo dormant restore ${id})`);
3545
+ }
3509
3546
  else {
3510
3547
  console.error(`Memory not found: ${id}`);
3511
3548
  }
@@ -3789,6 +3826,152 @@ function cmdUnreject(hippoRoot, args, flags) {
3789
3826
  }
3790
3827
  console.log(`Unrejected [${outcome.digest.slice(0, 12)}...] (was: ${outcome.reason ?? 'none given'})`);
3791
3828
  }
3829
+ /**
3830
+ * `hippo dormant [list] [<query>...] [--limit <n>] [--json] [--global]`,
3831
+ * `hippo dormant restore <id>`, `hippo dormant forget <id>`.
3832
+ * Dormant memories are what sleep keeps instead of deleting (on by default;
3833
+ * `"dormant": { "enabled": false }` in .hippo/config.json deletes instead).
3834
+ */
3835
+ function cmdDormant(hippoRoot, args, flags) {
3836
+ const root = resolveAuthRoot(hippoRoot, flags);
3837
+ const ctx = {
3838
+ hippoRoot: root,
3839
+ tenantId: resolveTenantId({}),
3840
+ actor: api.adminActor('cli'),
3841
+ };
3842
+ const sub = args[0];
3843
+ if (sub === 'restore' || sub === 'forget') {
3844
+ const id = (args[1] ?? '').trim();
3845
+ if (!id) {
3846
+ console.error(`Usage: hippo dormant ${sub} <id>`);
3847
+ process.exit(1);
3848
+ }
3849
+ try {
3850
+ if (sub === 'restore') {
3851
+ api.restoreDormant(ctx, id);
3852
+ console.log(`Restored ${id} to active memory.`);
3853
+ }
3854
+ else {
3855
+ api.forgetDormant(ctx, id);
3856
+ console.log(`Forgot dormant memory ${id} permanently.`);
3857
+ }
3858
+ }
3859
+ catch (err) {
3860
+ if (err instanceof RejectedValueError) {
3861
+ console.error(`Cannot restore ${id}: its value was rejected (${err.reason ?? 'no reason given'}). Run \`hippo unreject\` first to allow it.`);
3862
+ }
3863
+ else {
3864
+ console.error(`Could not ${sub} ${id}: ${err instanceof Error ? err.message : String(err)}`);
3865
+ }
3866
+ process.exit(1);
3867
+ }
3868
+ return;
3869
+ }
3870
+ const queryArgs = sub === 'list' ? args.slice(1) : args;
3871
+ const limit = parseCountFlag(flags['limit']);
3872
+ const rows = api.listDormant(ctx, {
3873
+ query: queryArgs.join(' '),
3874
+ limit: limit > 0 ? limit : undefined,
3875
+ });
3876
+ if (flags['json']) {
3877
+ console.log(JSON.stringify({ dormant: rows }, null, 2));
3878
+ return;
3879
+ }
3880
+ if (rows.length === 0) {
3881
+ console.log(queryArgs.length > 0 ? 'No dormant memories match.' : 'No dormant memories.');
3882
+ if (!loadConfig(root).dormant.enabled) {
3883
+ console.log(`Sleep deletes faded memories. To keep them dormant instead, set "dormant": { "enabled": true } in ${path.join(root, 'config.json')}.`);
3884
+ }
3885
+ return;
3886
+ }
3887
+ console.log(`${rows.length} dormant memor${rows.length === 1 ? 'y' : 'ies'}${queryArgs.length > 0 ? ' matching' : ''} (newest first):\n`);
3888
+ for (const row of rows) {
3889
+ const preview = row.content.length > 100 ? `${row.content.slice(0, 100)}...` : row.content;
3890
+ console.log(`--- ${row.id}`);
3891
+ console.log(` ${preview}`);
3892
+ console.log(` Dormant since ${row.dormantAt.slice(0, 10)} (${row.reason}, strength ${row.strength.toFixed(3)})${row.tags.length > 0 ? ` tags: ${row.tags.join(', ')}` : ''}`);
3893
+ console.log('');
3894
+ }
3895
+ console.log('Bring one back: hippo dormant restore <id> Delete for good: hippo dormant forget <id>');
3896
+ }
3897
+ /**
3898
+ * `hippo tokens [--days <n>] [--json] [--global]`: the token ledger
3899
+ * (ROADMAP TE0). Tokens of memory text handed to agents per surface, blocks
3900
+ * the per-prompt hook skipped as unchanged (TE2) and the tokens that saved.
3901
+ * Counts are estimates (characters / 4), the same estimate every budget uses.
3902
+ */
3903
+ function cmdTokens(hippoRoot, flags) {
3904
+ const root = resolveAuthRoot(hippoRoot, flags);
3905
+ const ctx = {
3906
+ hippoRoot: root,
3907
+ tenantId: resolveTenantId({}),
3908
+ actor: api.adminActor('cli'),
3909
+ };
3910
+ const days = parseCountFlag(flags['days']);
3911
+ const summary = api.tokenSummary(ctx, { days: days > 0 ? days : undefined });
3912
+ if (flags['json']) {
3913
+ console.log(JSON.stringify(summary, null, 2));
3914
+ return;
3915
+ }
3916
+ const windowDays = days > 0 ? days : 30;
3917
+ if (summary.surfaces.length === 0) {
3918
+ console.log(`No memory text recorded in the last ${windowDays} days.`);
3919
+ return;
3920
+ }
3921
+ console.log(`Memory text handed to agents, last ${windowDays} days (estimated tokens)\n`);
3922
+ console.log(` ${'surface'.padEnd(14)}${'sent'.padStart(8)}${'tokens'.padStart(12)}${'skipped'.padStart(10)}${'saved'.padStart(12)}`);
3923
+ for (const row of summary.surfaces) {
3924
+ console.log(` ${row.surface.padEnd(14)}${String(row.injected).padStart(8)}${String(row.tokens).padStart(12)}`
3925
+ + `${String(row.skipped).padStart(10)}${String(row.tokensAvoided).padStart(12)}`);
3926
+ }
3927
+ console.log('');
3928
+ console.log(` Total sent: ${summary.totalTokens} tokens. Saved by skipping unchanged blocks: ${summary.totalTokensAvoided}.`);
3929
+ if (summary.meanTokensPerSession > 0) {
3930
+ console.log(` Mean per session (rows with a session id): ${summary.meanTokensPerSession} tokens.`);
3931
+ }
3932
+ }
3933
+ /** `hippo failures [--days <n>] [--json] [--global]`: failed tool calls by outcome, and repeats across sessions (CD13). */
3934
+ function cmdFailures(hippoRoot, flags) {
3935
+ // The store the capture-error hook writes to; a report never creates one.
3936
+ const root = flags['global'] ? getGlobalRoot() : hookStoreRoot(hippoRoot);
3937
+ requireInit(root);
3938
+ const ctx = {
3939
+ hippoRoot: root,
3940
+ tenantId: resolveTenantId({}),
3941
+ actor: api.adminActor('cli'),
3942
+ };
3943
+ const days = parseCountFlag(flags['days']);
3944
+ const summary = api.failureSummary(ctx, { days: days > 0 ? days : undefined });
3945
+ if (flags['json']) {
3946
+ console.log(JSON.stringify(summary, null, 2));
3947
+ return;
3948
+ }
3949
+ const windowDays = days > 0 ? days : 30;
3950
+ const kept = windowDays > FAILURE_LOG_RETENTION_DAYS ? ` (rows are kept ${FAILURE_LOG_RETENTION_DAYS} days)` : '';
3951
+ if (summary.total === 0) {
3952
+ console.log(`No failed tool calls recorded in the last ${windowDays} days${kept}.`);
3953
+ return;
3954
+ }
3955
+ const o = summary.outcomes;
3956
+ const errors = o.stored + o.duplicate + o['store-failed'];
3957
+ const unsaved = o['store-failed'] > 0 ? `, ${o['store-failed']} could not be saved` : '';
3958
+ const rows = [
3959
+ ['errors', errors, `(${o.stored} new, ${o.duplicate} already in memory${unsaved})`],
3960
+ ['routine', o['skipped-routine'], ''],
3961
+ ['interrupted', o['skipped-interrupt'], ''],
3962
+ ['unreadable', o['skipped-invalid'], ''],
3963
+ ];
3964
+ console.log(`Failed tool calls seen by the capture-error hook, last ${windowDays} days${kept}\n`);
3965
+ for (const [label, count, note] of rows) {
3966
+ console.log(` ${label.padEnd(13)}${String(count).padStart(6)} ${note}`.trimEnd());
3967
+ }
3968
+ // Counts, not a rate: a share means little without a holdout arm to compare against (CD11).
3969
+ if (summary.rated > 0) {
3970
+ const noSession = errors - summary.rated;
3971
+ const unrated = noSession > 0 ? ` ${noSession} more had no session id.` : '';
3972
+ console.log(`\n Repeats: ${summary.repeats} of ${summary.rated} errors first happened in another session.${unrated}`);
3973
+ }
3974
+ }
3792
3975
  function cmdSnapshot(hippoRoot, args, flags) {
3793
3976
  requireInit(hippoRoot);
3794
3977
  const subcommand = args[0] ?? 'show';
@@ -6117,22 +6300,24 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6117
6300
  origin: r.origin ?? null,
6118
6301
  category: r.category ?? null,
6119
6302
  }));
6120
- console.log(JSON.stringify({
6303
+ const jsonText = JSON.stringify({
6121
6304
  query: query || '*',
6122
6305
  activeSnapshot: result.activeSnapshot ?? null,
6123
6306
  sessionHandoff: result.sessionHandoff ?? null,
6124
6307
  recentSessionEvents: result.recentEvents ?? [],
6125
6308
  memories: output,
6126
6309
  tokens: result.tokens,
6310
+ });
6311
+ console.log(jsonText);
6312
+ withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6313
+ tenantId: ctx.tenantId, sessionId: currentSessionId, surface: pinnedOnly ? 'hook' : 'context',
6314
+ event: 'inject', items: output.length, tokens: estimateTokens(jsonText),
6127
6315
  }));
6128
6316
  }
6129
6317
  else if (format === 'additional-context') {
6130
6318
  // Claude Code UserPromptSubmit hook JSON shape. Capture print* helpers'
6131
6319
  // output into a string buffer and wrap as `additionalContext`.
6132
- const lines = [];
6133
- const realLog = console.log;
6134
- console.log = (...parts) => { lines.push(parts.map(String).join(' ')); };
6135
- try {
6320
+ const textBlock = captureConsole(() => {
6136
6321
  if (result.activeSnapshot)
6137
6322
  printActiveTaskSnapshot(result.activeSnapshot);
6138
6323
  if (result.sessionHandoff)
@@ -6141,16 +6326,37 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6141
6326
  printSessionEvents(result.recentEvents);
6142
6327
  }
6143
6328
  if (renderItems.length > 0) {
6144
- printContextMarkdown(renderItems, result.tokens, framing);
6329
+ // TE1: no live strength percentage, so an unchanged set of memories
6330
+ // renders byte-identically turn after turn.
6331
+ printContextMarkdown(renderItems, result.tokens, framing, { showStrength: false });
6145
6332
  }
6146
6333
  printCrossProjectSection(crossEntries);
6147
- }
6148
- finally {
6149
- console.log = realLog;
6150
- }
6151
- const textBlock = lines.join('\n');
6334
+ });
6152
6335
  if (!textBlock.trim())
6153
6336
  return;
6337
+ const surface = pinnedOnly ? 'hook' : 'context';
6338
+ const hash = blockHash(textBlock);
6339
+ const tokens = estimateTokens(textBlock);
6340
+ // TE2: the per-prompt hook skips a block identical to the one this
6341
+ // session already has, and resends it every refreshTurns skips. Only
6342
+ // with a session id from the hook payload itself: an inherited env id
6343
+ // (a manual run inside an agent's shell) must never suppress output.
6344
+ if (pinnedOnly && payloadSessionId !== undefined) {
6345
+ const injectCfg = loadConfig(hippoRoot).pinnedInject;
6346
+ if (injectCfg.skipUnchanged !== false) {
6347
+ const refreshTurns = Number.isFinite(injectCfg.refreshTurns) && injectCfg.refreshTurns >= 0
6348
+ ? injectCfg.refreshTurns
6349
+ : 10;
6350
+ const last = withLedgerDb(hippoRoot, (db) => lastSentState(db, ctx.tenantId, payloadSessionId, surface));
6351
+ if (shouldSkipUnchanged(last ?? null, hash, refreshTurns)) {
6352
+ withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6353
+ tenantId: ctx.tenantId, sessionId: payloadSessionId, surface, event: 'skip',
6354
+ items: renderItems.length, tokens, hash,
6355
+ }));
6356
+ return;
6357
+ }
6358
+ }
6359
+ }
6154
6360
  const payload = {
6155
6361
  hookSpecificOutput: {
6156
6362
  hookEventName: 'UserPromptSubmit',
@@ -6158,25 +6364,113 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6158
6364
  },
6159
6365
  };
6160
6366
  process.stdout.write(JSON.stringify(payload));
6367
+ withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6368
+ tenantId: ctx.tenantId, sessionId: currentSessionId, surface, event: 'inject',
6369
+ items: renderItems.length, tokens, hash,
6370
+ }));
6161
6371
  }
6162
6372
  else {
6163
6373
  // markdown (default)
6164
- if (result.activeSnapshot) {
6165
- printActiveTaskSnapshot(result.activeSnapshot);
6166
- }
6167
- if (result.sessionHandoff) {
6168
- printHandoff(result.sessionHandoff);
6169
- }
6170
- if (result.recentEvents && result.recentEvents.length > 0) {
6171
- printSessionEvents(result.recentEvents);
6172
- }
6173
- if (renderItems.length > 0) {
6174
- printContextMarkdown(renderItems, result.tokens, framing);
6175
- }
6176
- printCrossProjectSection(crossEntries);
6177
- if (result.ambientState) {
6178
- console.log(`\n${renderAmbientSummary(result.ambientState)}`);
6179
- }
6374
+ const text = captureConsole(() => {
6375
+ if (result.activeSnapshot) {
6376
+ printActiveTaskSnapshot(result.activeSnapshot);
6377
+ }
6378
+ if (result.sessionHandoff) {
6379
+ printHandoff(result.sessionHandoff);
6380
+ }
6381
+ if (result.recentEvents && result.recentEvents.length > 0) {
6382
+ printSessionEvents(result.recentEvents);
6383
+ }
6384
+ if (renderItems.length > 0) {
6385
+ printContextMarkdown(renderItems, result.tokens, framing);
6386
+ }
6387
+ printCrossProjectSection(crossEntries);
6388
+ if (result.ambientState) {
6389
+ console.log(`\n${renderAmbientSummary(result.ambientState)}`);
6390
+ }
6391
+ });
6392
+ if (text.length > 0)
6393
+ console.log(text);
6394
+ withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6395
+ tenantId: ctx.tenantId, sessionId: currentSessionId, surface: pinnedOnly ? 'hook' : 'context',
6396
+ event: 'inject', items: renderItems.length, tokens: estimateTokens(text),
6397
+ }));
6398
+ }
6399
+ }
6400
+ /**
6401
+ * TE2: compaction drops the pinned blocks the per-prompt hook injected
6402
+ * earlier, so record a `reset` for the payload's session and the next prompt
6403
+ * injects again even if nothing changed. `requiredSource` limits it to hook
6404
+ * payloads with that `source` (SessionStart fires for other reasons too).
6405
+ * Best-effort and silent: a malformed payload records nothing.
6406
+ */
6407
+ function resetHookInjection(hippoRoot, stdinText, requiredSource) {
6408
+ const sessionId = hookPayloadSessionId(stdinText, requiredSource);
6409
+ if (sessionId === null)
6410
+ return;
6411
+ withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6412
+ tenantId: resolveTenantId({}), sessionId, surface: 'hook', event: 'reset', items: 0, tokens: 0,
6413
+ }));
6414
+ }
6415
+ /**
6416
+ * Run `fn` with console.log captured; returns the captured lines joined by
6417
+ * newlines (what the same calls would have printed, minus the final newline).
6418
+ */
6419
+ function captureConsole(fn) {
6420
+ const lines = [];
6421
+ const realLog = console.log;
6422
+ console.log = (...parts) => { lines.push(parts.map(String).join(' ')); };
6423
+ try {
6424
+ fn();
6425
+ }
6426
+ finally {
6427
+ console.log = realLog;
6428
+ }
6429
+ return lines.join('\n');
6430
+ }
6431
+ /**
6432
+ * The store a Claude Code hook writes to: the project store when there is
6433
+ * one, else an existing global store, else the project path (which the hook
6434
+ * then skips, since hooks fire in every directory and must not create one).
6435
+ * Pre-compact and compact-resume must agree, or a snapshot saved to one store
6436
+ * is looked for in the other.
6437
+ */
6438
+ function hookStoreRoot(hippoRoot) {
6439
+ if (isInitialized(hippoRoot))
6440
+ return hippoRoot;
6441
+ const globalRoot = getGlobalRoot();
6442
+ return isInitialized(globalRoot) ? globalRoot : hippoRoot;
6443
+ }
6444
+ /**
6445
+ * Run `fn` against the token ledger's store: the local store when it is
6446
+ * initialized, else the global one (the per-prompt hook runs in directories
6447
+ * without a local store). Best-effort: returns undefined and never throws,
6448
+ * because a ledger failure must not break context or recall.
6449
+ */
6450
+ function withLedgerDb(hippoRoot, fn) {
6451
+ let root = null;
6452
+ try {
6453
+ if (isInitialized(hippoRoot))
6454
+ root = hippoRoot;
6455
+ else if (isInitialized(getGlobalRoot()))
6456
+ root = getGlobalRoot();
6457
+ }
6458
+ catch {
6459
+ return undefined;
6460
+ }
6461
+ if (root === null)
6462
+ return undefined;
6463
+ let db;
6464
+ try {
6465
+ db = openHippoDb(root);
6466
+ return fn(db);
6467
+ }
6468
+ catch {
6469
+ return undefined;
6470
+ }
6471
+ finally {
6472
+ if (db)
6473
+ closeHippoDb(db);
6180
6474
  }
6181
6475
  }
6182
6476
  /**
@@ -6195,13 +6489,15 @@ function printCrossProjectSection(items) {
6195
6489
  }
6196
6490
  }
6197
6491
  /** @internal — exported for snapshot tests (tests/cli-context-render-snapshot.test.ts). NOT a stable public API. */
6198
- export function printContextMarkdown(items, totalTokens, framing = 'observe') {
6492
+ export function printContextMarkdown(items, totalTokens, framing = 'observe', opts = {}) {
6199
6493
  const now = evalNow();
6494
+ const showStrength = opts.showStrength !== false;
6200
6495
  console.log(`## Project Memory (${items.length} entries, ${totalTokens} tokens)\n`);
6201
6496
  for (const item of items) {
6202
6497
  const e = item.entry;
6203
6498
  const tagStr = e.tags.length > 0 ? ` [${e.tags.join(', ')}]` : '';
6204
6499
  const strengthPct = Math.round(calculateStrength(e) * 100);
6500
+ const strengthStr = showStrength ? ` (${strengthPct}%)` : '';
6205
6501
  const globalPrefix = item.isGlobal ? '[global] ' : '';
6206
6502
  const effectiveConf = confidenceFacets(e, now).tier;
6207
6503
  const label = confidenceLabel(e, now);
@@ -6211,21 +6507,21 @@ export function printContextMarkdown(items, totalTokens, framing = 'observe') {
6211
6507
  const dateStr = new Date(e.created).toISOString().slice(0, 10);
6212
6508
  if (effectiveConf === 'verified') {
6213
6509
  // Verified: no date prefix, just the rule
6214
- console.log(`- **${confTag} ${globalPrefix}${e.content}**${tagStr} (${strengthPct}%)`);
6510
+ console.log(`- **${confTag} ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
6215
6511
  }
6216
6512
  else if (effectiveConf === 'stale') {
6217
- console.log(`- **${confTag} Previously observed (${dateStr}): ${globalPrefix}${e.content}**${tagStr} (${strengthPct}%)`);
6513
+ console.log(`- **${confTag} Previously observed (${dateStr}): ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
6218
6514
  }
6219
6515
  else {
6220
- console.log(`- **${confTag} Previously observed (${dateStr}): ${globalPrefix}${e.content}**${tagStr} (${strengthPct}%)`);
6516
+ console.log(`- **${confTag} Previously observed (${dateStr}): ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
6221
6517
  }
6222
6518
  }
6223
6519
  else if (framing === 'suggest') {
6224
- console.log(`- **${confTag} Consider checking: ${globalPrefix}${e.content}**${tagStr} (${strengthPct}%)`);
6520
+ console.log(`- **${confTag} Consider checking: ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
6225
6521
  }
6226
6522
  else {
6227
6523
  // framing === 'assert': no prefix (bare facts)
6228
- console.log(`- **${confTag} ${globalPrefix}${e.content}**${tagStr} (${strengthPct}%)`);
6524
+ console.log(`- **${confTag} ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
6229
6525
  }
6230
6526
  }
6231
6527
  }
@@ -6369,7 +6665,7 @@ async function cmdWatch(command, hippoRoot) {
6369
6665
  const existingWatch = loadAllEntries(hippoRoot, entry.tenantId);
6370
6666
  const watchFit = computeSchemaFit(entry.content, entry.tags, existingWatch);
6371
6667
  entry.schema_fit = watchFit;
6372
- entry.half_life_days = deriveHalfLife(7, entry);
6668
+ entry.half_life_days = deriveHalfLife(loadConfig(hippoRoot).defaultHalfLifeDays, entry);
6373
6669
  entry.strength = calculateStrength(entry);
6374
6670
  // AT1 (plan §3 containment): mechanical content from a failed command — a
6375
6671
  // rejection-guard refusal here must not crash the watcher. Skip silently
@@ -6408,7 +6704,9 @@ function learnFromRepo(hippoRoot, repoPath, days, label) {
6408
6704
  console.log(`${prefix}No fix/revert/bug commits found in the specified period.`);
6409
6705
  return { added: 0, skipped: 0, lowInfo: 0 };
6410
6706
  }
6411
- const parsedLessons = extractLessons(gitLog);
6707
+ // Same patterns as MCP hippo_learn: config.gitLearnPatterns (whose default
6708
+ // equals extractLessons' built-in list) so a custom list applies everywhere.
6709
+ const parsedLessons = extractLessons(gitLog, loadConfig(hippoRoot).gitLearnPatterns);
6412
6710
  if (parsedLessons.length === 0) {
6413
6711
  console.log(`${prefix}No fix/revert/bug commits found in the specified period.`);
6414
6712
  return { added: 0, skipped: 0, lowInfo: 0 };
@@ -6968,6 +7266,12 @@ function cmdHook(args, flags) {
6968
7266
  if (result.installedCompactResume) {
6969
7267
  console.log(`Installed hippo compact-resume SessionStart(compact) hook in ${result.target} settings`);
6970
7268
  }
7269
+ if (result.installedPostCompact) {
7270
+ console.log(`Installed hippo post-compact PostCompact hook in ${result.target} settings`);
7271
+ }
7272
+ if (result.installedCaptureError) {
7273
+ console.log(`Installed hippo capture-error PostToolUseFailure hook in ${result.target} settings`);
7274
+ }
6971
7275
  if (result.migratedFromStop) {
6972
7276
  console.log(`Migrated legacy Stop hook → SessionEnd (was running every turn; now fires once on session exit)`);
6973
7277
  }
@@ -7088,6 +7392,10 @@ function cmdSetup(flags) {
7088
7392
  bits.push('PreCompact (pre-compact)');
7089
7393
  if (result.installedCompactResume)
7090
7394
  bits.push('SessionStart(compact) (compact-resume)');
7395
+ if (result.installedPostCompact)
7396
+ bits.push('PostCompact (post-compact)');
7397
+ if (result.installedCaptureError)
7398
+ bits.push('PostToolUseFailure (capture-error)');
7091
7399
  if (result.migratedFromStop)
7092
7400
  bits.push('migrated legacy Stop');
7093
7401
  if (result.migratedSplitSessionEnd)
@@ -7629,6 +7937,8 @@ const VALID_AUDIT_OPS = new Set([
7629
7937
  'reject_refusal', // AT1 — emitted when the rejection guard refuses a write; lockstep
7630
7938
  'unreject_value', // AT1 — emitted by `hippo unreject`; lockstep
7631
7939
  'conflict_resolve', // AT1 — emitted by resolveConflict on every resolution path; lockstep
7940
+ 'half_life_migrate', // Decay default change — emitted by migrateDefaultHalfLife; lockstep with AuditOp union
7941
+ 'dormant_restore', // Dormant memories — emitted by api.restoreDormant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
7632
7942
  ]);
7633
7943
  function formatAuditRow(ev) {
7634
7944
  const target = ev.targetId ?? '-';
@@ -8354,6 +8664,28 @@ Commands:
8354
8664
  --global Operate on the global store
8355
8665
  unreject <digest-prefix> Delete a tombstone (the only escape hatch)
8356
8666
  --global Operate on the global store
8667
+ dormant [<query>] List faded memories sleep kept instead of deleting
8668
+ (on by default; "dormant": {"enabled": false} deletes instead)
8669
+ --limit <n> Max rows, newest first (default: 20)
8670
+ --json Output as JSON
8671
+ --global Operate on the global store
8672
+ dormant restore <id> Bring a dormant memory back to active memory
8673
+ dormant forget <id> Delete a dormant memory permanently
8674
+ capture-error Store a failed tool call as an error memory (reads the Claude Code
8675
+ PostToolUseFailure hook payload on stdin; skips routine failures)
8676
+ doctor Check the install: Node, store, schema, sleep, agent hooks
8677
+ --json Machine-readable report (exit code 1 on any failure)
8678
+ tokens Tokens of memory text hippo handed agents, per surface
8679
+ (hook, context, recall, MCP, HTTP), and what skipping
8680
+ unchanged hook blocks saved
8681
+ --days <n> Window in days (default: 30)
8682
+ --json Output as JSON
8683
+ --global Operate on the global store
8684
+ failures Failed tool calls capture-error saw, by outcome, and how
8685
+ many errors first happened in another session
8686
+ --days <n> Window in days (default: 30)
8687
+ --json Output as JSON
8688
+ --global Operate on the global store
8357
8689
  snapshot <sub> Persist or inspect the current active task
8358
8690
  snapshot save Save active task state
8359
8691
  --task <task>
@@ -8481,6 +8813,8 @@ Commands:
8481
8813
  pre-compact PreCompact hook: snapshot + capture the tail before compaction
8482
8814
  --log-file <p> Diagnostic log path (default: ~/.hippo/logs/pre-compact.log)
8483
8815
  compact-resume SessionStart(compact) hook: re-print the snapshot + session trail
8816
+ post-compact PostCompact hook: tell the user what pre-compact saved
8817
+ --log-file <p> Same log path as pre-compact (default: ~/.hippo/logs/pre-compact.log)
8484
8818
  codex-run [-- ...args] Launch real Codex behind Hippo's session-end wrapper
8485
8819
  hook <sub> [target] Manage framework integrations
8486
8820
  hook list Show available hooks
@@ -8645,6 +8979,9 @@ Examples:
8645
8979
  hippo reject --value "never store my key again" --reason "secret"
8646
8980
  hippo rejections
8647
8981
  hippo unreject a1b2c3d4e5f6
8982
+ hippo dormant "staging hostname"
8983
+ hippo dormant restore mem_abc123
8984
+ hippo tokens --days 7
8648
8985
  hippo session log --id sess_123 --task "Ship feature" --type progress --content "Build is green, next step is docs"
8649
8986
  hippo session latest --json
8650
8987
  hippo session resume
@@ -8893,16 +9230,45 @@ async function main(command, args, flags, hippoRoot) {
8893
9230
  case 'pre-compact': {
8894
9231
  // Bounded wait, not a TTY guard: an idle non-TTY pipe must not hang.
8895
9232
  const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
8896
- await cmdPreCompact(hippoRoot, {
9233
+ resetHookInjection(hippoRoot, stdinText, null);
9234
+ await cmdPreCompact(hookStoreRoot(hippoRoot), {
8897
9235
  stdinText,
8898
9236
  stdinTimedOut,
8899
9237
  logFile: typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined,
8900
9238
  });
8901
9239
  break;
8902
9240
  }
9241
+ case 'post-compact': {
9242
+ // PostCompact hook: tells the user what pre-compact saved. Plain text,
9243
+ // because Claude Code shows this hook's stdout as-is. Always exits 0.
9244
+ const { text } = await readStdinBounded();
9245
+ const logFlag = flags['log-file'];
9246
+ const message = postCompactMessage(text, logFlag === true || logFlag === false || Array.isArray(logFlag) ? undefined : logFlag);
9247
+ if (message !== null)
9248
+ console.log(message);
9249
+ break;
9250
+ }
9251
+ case 'capture-error': {
9252
+ // PostToolUseFailure hook: every path exits 0, and nothing is created
9253
+ // when no store exists (the hook fires in every directory).
9254
+ const { text } = await readStdinBounded();
9255
+ try {
9256
+ const root = hookStoreRoot(hippoRoot);
9257
+ const payload = (text ?? '').trim();
9258
+ if (isInitialized(root) && payload) {
9259
+ // SAFETY: JSON.parse returns a JSON value by definition.
9260
+ captureToolFailure(root, resolveTenantId({}), JSON.parse(payload));
9261
+ }
9262
+ }
9263
+ catch {
9264
+ // A malformed payload or store error must never fail the agent's tool call.
9265
+ }
9266
+ break;
9267
+ }
8903
9268
  case 'compact-resume': {
8904
9269
  const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
8905
- cmdCompactResume(hippoRoot, stdinText, stdinTimedOut);
9270
+ resetHookInjection(hippoRoot, stdinText, 'compact');
9271
+ cmdCompactResume(hookStoreRoot(hippoRoot), stdinText, stdinTimedOut);
8906
9272
  break;
8907
9273
  }
8908
9274
  case 'codex-run':
@@ -9049,6 +9415,24 @@ async function main(command, args, flags, hippoRoot) {
9049
9415
  case 'unreject':
9050
9416
  cmdUnreject(hippoRoot, args, flags);
9051
9417
  break;
9418
+ case 'dormant':
9419
+ cmdDormant(hippoRoot, args, flags);
9420
+ break;
9421
+ case 'tokens':
9422
+ cmdTokens(hippoRoot, flags);
9423
+ break;
9424
+ case 'failures':
9425
+ cmdFailures(hippoRoot, flags);
9426
+ break;
9427
+ case 'doctor': {
9428
+ // SAFETY: package.json always carries a string "version" (checked at release by check-manifest-versions).
9429
+ const pkg = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'));
9430
+ const report = runDoctor({ version: pkg.version });
9431
+ console.log(flags['json'] ? JSON.stringify(report, null, 2) : formatDoctor(report));
9432
+ if (!report.ok)
9433
+ process.exit(1);
9434
+ break;
9435
+ }
9052
9436
  case 'snapshot':
9053
9437
  cmdSnapshot(hippoRoot, args, flags);
9054
9438
  break;