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
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,9 @@ 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';
59
65
  import { pushGoal, getActiveGoals, completeGoal, suspendGoal, resumeGoal, applyGoalStackBoost } from './goals.js';
60
66
  import { rowToGoal } from './goals.js';
61
67
  import { captureError, extractLessons, partitionLessons, deduplicateLesson, runWatched, fetchGitLog, isGitRepo, } from './autolearn.js';
@@ -65,7 +71,7 @@ import { detectScope } from './scope.js';
65
71
  import { getGlobalRoot, initGlobal, shareMemory, listPeers, autoShare, transferScore, searchBothHybrid, syncGlobalToLocal, } from './shared.js';
66
72
  import { DAILY_TASK_NAME, buildDailyRunnerCommand, listRegisteredWorkspaces, registerWorkspace, runDailyMaintenance, } from './scheduler.js';
67
73
  import { importChatGPT, importClaude, importCursor, importGenericFile, importMarkdown, importVault, } from './importers.js';
68
- import { cmdCapture, cmdPreCompact, resolveLastSessionTranscript, truncateCodePointSafe, sanitizeLogMessage } from './capture.js';
74
+ import { cmdCapture, cmdPreCompact, postCompactMessage, resolveLastSessionTranscript, truncateCodePointSafe, sanitizeLogMessage } from './capture.js';
69
75
  import { readStdinBounded } from './stdin.js';
70
76
  import { auditMemories, appendAuditEvent, } from './audit.js';
71
77
  import { listApiKeys, revokeApiKey } from './auth.js';
@@ -542,17 +548,17 @@ function autoInstallHooks(quiet) {
542
548
  // Skip if we already installed a hook into this file
543
549
  if (installed.has(targetPath))
544
550
  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
- }
551
+ // An instruction block already present is not rewritten, but the JSON
552
+ // hooks and plugins below still run: they are idempotent, and skipping
553
+ // them meant a store set up by an older hippo never got hooks added in
554
+ // later releases (PreCompact, PostToolUseFailure) on a re-run of init.
555
+ const blockPresent = fs.existsSync(targetPath)
556
+ && fs.readFileSync(targetPath, 'utf8').includes(HOOK_MARKERS.start);
551
557
  // Only patch the agent-instructions file if it already exists.
552
558
  // Never create a new CLAUDE.md / AGENTS.md / etc. just because a sibling
553
559
  // marker file (.claude/settings.json, .codex, etc.) was detected — that
554
560
  // pollutes dirs the user didn't intend to configure.
555
- if (fs.existsSync(targetPath)) {
561
+ if (!blockPresent && fs.existsSync(targetPath)) {
556
562
  const block = `${HOOK_MARKERS.start}\n${hookDef.content}\n${HOOK_MARKERS.end}`;
557
563
  const existing = fs.readFileSync(targetPath, 'utf8');
558
564
  const sep = existing.endsWith('\n') ? '\n' : '\n\n';
@@ -563,7 +569,7 @@ function autoInstallHooks(quiet) {
563
569
  // The Codex session-capture wrapper swaps the codex launcher binary, so
564
570
  // init never installs it silently — it points at the explicit opt-in
565
571
  // command instead (issue #133).
566
- if (hook === 'codex' && !isCodexWrapperInstalled()) {
572
+ if (hook === 'codex' && !blockPresent && !isCodexWrapperInstalled()) {
567
573
  console.log(' Codex detected. To capture Codex sessions: hippo hook install codex');
568
574
  }
569
575
  // For Claude Code, also install SessionEnd+SessionStart entries in its
@@ -586,6 +592,12 @@ function autoInstallHooks(quiet) {
586
592
  if (result.installedCompactResume) {
587
593
  console.log(` Auto-installed hippo compact-resume SessionStart(compact) hook in ${hook} settings`);
588
594
  }
595
+ if (result.installedPostCompact) {
596
+ console.log(` Auto-installed hippo post-compact PostCompact hook in ${hook} settings`);
597
+ }
598
+ if (result.installedCaptureError) {
599
+ console.log(` Auto-installed hippo capture-error PostToolUseFailure hook in ${hook} settings`);
600
+ }
589
601
  if (result.migratedFromStop) {
590
602
  console.log(` Migrated legacy Stop hook → SessionEnd (no longer runs every turn)`);
591
603
  }
@@ -1657,7 +1669,7 @@ async function cmdRecall(hippoRoot, query, flags) {
1657
1669
  sessionHandoff =
1658
1670
  rawHandoff && passesScopeFilterForRecall(rowScope(rawHandoff), effectiveScope) ? rawHandoff : null;
1659
1671
  recentSessionEvents = rawEvents.filter((e) => passesScopeFilterForRecall(rowScope(e), effectiveScope));
1660
- const tokenize = (s) => s ? Math.ceil(s.length / 4) : 0;
1672
+ const tokenize = (s) => s ? estimateTokens(s) : 0;
1661
1673
  continuityTokens =
1662
1674
  tokenize(activeSnapshot?.task) +
1663
1675
  tokenize(activeSnapshot?.summary) +
@@ -1675,6 +1687,16 @@ async function cmdRecall(hippoRoot, query, flags) {
1675
1687
  const hasContinuity = activeSnapshot !== null
1676
1688
  || sessionHandoff !== null
1677
1689
  || recentSessionEvents.length > 0;
1690
+ // TE0 token ledger: the memory text this recall hands back (results plus
1691
+ // any continuity block), recorded once per query.
1692
+ withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
1693
+ tenantId,
1694
+ sessionId: hostSessionId() ?? null,
1695
+ surface: 'recall',
1696
+ event: 'inject',
1697
+ items: results.length,
1698
+ tokens: results.reduce((acc, r) => acc + (r.tokens || 0), 0) + continuityTokens,
1699
+ }));
1678
1700
  if (results.length === 0) {
1679
1701
  // LC1 F1 structural fix (docs/plans/2026-08-02-lc1-recall-trace-persistence.md):
1680
1702
  // trace the zero-result recall too (result_count 0, no result rows) so
@@ -2748,6 +2770,14 @@ export function renderSleepResult(result) {
2748
2770
  console.log(`\nResults:`);
2749
2771
  console.log(` Active memories: ${result.active}`);
2750
2772
  console.log(` Removed (decayed): ${result.removed}`);
2773
+ // Only when dormant.enabled moved something, so every other render stays
2774
+ // byte-identical (tests/cli-context-render-snapshot.test.ts).
2775
+ if (result.dormant !== undefined && result.dormant > 0) {
2776
+ console.log(` Kept dormant: ${result.dormant} (hippo dormant to list)`);
2777
+ }
2778
+ if (result.dormantExpired !== undefined && result.dormantExpired > 0) {
2779
+ console.log(` Expired dormant: ${result.dormantExpired} (past dormant.retentionDays)`);
2780
+ }
2751
2781
  console.log(` Merged episodic: ${result.mergedEpisodic}`);
2752
2782
  console.log(` New semantic: ${result.newSemantic}`);
2753
2783
  if (result.details && result.details.length > 0) {
@@ -3506,6 +3536,12 @@ function cmdForget(hippoRoot, id, flags) {
3506
3536
  // memory, not a missing one. Point the user at the archive path.
3507
3537
  console.error(rawForgetRefusal(id));
3508
3538
  }
3539
+ else if (api.isDormant(ctx, id)) {
3540
+ // Sleep moved it to the dormant store: it is not in active memory, so
3541
+ // point at the command that owns it.
3542
+ console.error(`${id} is dormant, not in active memory. Delete it for good: hippo dormant forget ${id} ` +
3543
+ `(or bring it back: hippo dormant restore ${id})`);
3544
+ }
3509
3545
  else {
3510
3546
  console.error(`Memory not found: ${id}`);
3511
3547
  }
@@ -3789,6 +3825,110 @@ function cmdUnreject(hippoRoot, args, flags) {
3789
3825
  }
3790
3826
  console.log(`Unrejected [${outcome.digest.slice(0, 12)}...] (was: ${outcome.reason ?? 'none given'})`);
3791
3827
  }
3828
+ /**
3829
+ * `hippo dormant [list] [<query>...] [--limit <n>] [--json] [--global]`,
3830
+ * `hippo dormant restore <id>`, `hippo dormant forget <id>`.
3831
+ * Dormant memories are what sleep keeps instead of deleting (on by default;
3832
+ * `"dormant": { "enabled": false }` in .hippo/config.json deletes instead).
3833
+ */
3834
+ function cmdDormant(hippoRoot, args, flags) {
3835
+ const root = resolveAuthRoot(hippoRoot, flags);
3836
+ const ctx = {
3837
+ hippoRoot: root,
3838
+ tenantId: resolveTenantId({}),
3839
+ actor: api.adminActor('cli'),
3840
+ };
3841
+ const sub = args[0];
3842
+ if (sub === 'restore' || sub === 'forget') {
3843
+ const id = (args[1] ?? '').trim();
3844
+ if (!id) {
3845
+ console.error(`Usage: hippo dormant ${sub} <id>`);
3846
+ process.exit(1);
3847
+ }
3848
+ try {
3849
+ if (sub === 'restore') {
3850
+ api.restoreDormant(ctx, id);
3851
+ console.log(`Restored ${id} to active memory.`);
3852
+ }
3853
+ else {
3854
+ api.forgetDormant(ctx, id);
3855
+ console.log(`Forgot dormant memory ${id} permanently.`);
3856
+ }
3857
+ }
3858
+ catch (err) {
3859
+ if (err instanceof RejectedValueError) {
3860
+ console.error(`Cannot restore ${id}: its value was rejected (${err.reason ?? 'no reason given'}). Run \`hippo unreject\` first to allow it.`);
3861
+ }
3862
+ else {
3863
+ console.error(`Could not ${sub} ${id}: ${err instanceof Error ? err.message : String(err)}`);
3864
+ }
3865
+ process.exit(1);
3866
+ }
3867
+ return;
3868
+ }
3869
+ const queryArgs = sub === 'list' ? args.slice(1) : args;
3870
+ const limit = parseCountFlag(flags['limit']);
3871
+ const rows = api.listDormant(ctx, {
3872
+ query: queryArgs.join(' '),
3873
+ limit: limit > 0 ? limit : undefined,
3874
+ });
3875
+ if (flags['json']) {
3876
+ console.log(JSON.stringify({ dormant: rows }, null, 2));
3877
+ return;
3878
+ }
3879
+ if (rows.length === 0) {
3880
+ console.log(queryArgs.length > 0 ? 'No dormant memories match.' : 'No dormant memories.');
3881
+ if (!loadConfig(root).dormant.enabled) {
3882
+ console.log(`Sleep deletes faded memories. To keep them dormant instead, set "dormant": { "enabled": true } in ${path.join(root, 'config.json')}.`);
3883
+ }
3884
+ return;
3885
+ }
3886
+ console.log(`${rows.length} dormant memor${rows.length === 1 ? 'y' : 'ies'}${queryArgs.length > 0 ? ' matching' : ''} (newest first):\n`);
3887
+ for (const row of rows) {
3888
+ const preview = row.content.length > 100 ? `${row.content.slice(0, 100)}...` : row.content;
3889
+ console.log(`--- ${row.id}`);
3890
+ console.log(` ${preview}`);
3891
+ console.log(` Dormant since ${row.dormantAt.slice(0, 10)} (${row.reason}, strength ${row.strength.toFixed(3)})${row.tags.length > 0 ? ` tags: ${row.tags.join(', ')}` : ''}`);
3892
+ console.log('');
3893
+ }
3894
+ console.log('Bring one back: hippo dormant restore <id> Delete for good: hippo dormant forget <id>');
3895
+ }
3896
+ /**
3897
+ * `hippo tokens [--days <n>] [--json] [--global]`: the token ledger
3898
+ * (ROADMAP TE0). Tokens of memory text handed to agents per surface, blocks
3899
+ * the per-prompt hook skipped as unchanged (TE2) and the tokens that saved.
3900
+ * Counts are estimates (characters / 4), the same estimate every budget uses.
3901
+ */
3902
+ function cmdTokens(hippoRoot, flags) {
3903
+ const root = resolveAuthRoot(hippoRoot, flags);
3904
+ const ctx = {
3905
+ hippoRoot: root,
3906
+ tenantId: resolveTenantId({}),
3907
+ actor: api.adminActor('cli'),
3908
+ };
3909
+ const days = parseCountFlag(flags['days']);
3910
+ const summary = api.tokenSummary(ctx, { days: days > 0 ? days : undefined });
3911
+ if (flags['json']) {
3912
+ console.log(JSON.stringify(summary, null, 2));
3913
+ return;
3914
+ }
3915
+ const windowDays = days > 0 ? days : 30;
3916
+ if (summary.surfaces.length === 0) {
3917
+ console.log(`No memory text recorded in the last ${windowDays} days.`);
3918
+ return;
3919
+ }
3920
+ console.log(`Memory text handed to agents, last ${windowDays} days (estimated tokens)\n`);
3921
+ console.log(` ${'surface'.padEnd(14)}${'sent'.padStart(8)}${'tokens'.padStart(12)}${'skipped'.padStart(10)}${'saved'.padStart(12)}`);
3922
+ for (const row of summary.surfaces) {
3923
+ console.log(` ${row.surface.padEnd(14)}${String(row.injected).padStart(8)}${String(row.tokens).padStart(12)}`
3924
+ + `${String(row.skipped).padStart(10)}${String(row.tokensAvoided).padStart(12)}`);
3925
+ }
3926
+ console.log('');
3927
+ console.log(` Total sent: ${summary.totalTokens} tokens. Saved by skipping unchanged blocks: ${summary.totalTokensAvoided}.`);
3928
+ if (summary.meanTokensPerSession > 0) {
3929
+ console.log(` Mean per session (rows with a session id): ${summary.meanTokensPerSession} tokens.`);
3930
+ }
3931
+ }
3792
3932
  function cmdSnapshot(hippoRoot, args, flags) {
3793
3933
  requireInit(hippoRoot);
3794
3934
  const subcommand = args[0] ?? 'show';
@@ -6117,22 +6257,24 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6117
6257
  origin: r.origin ?? null,
6118
6258
  category: r.category ?? null,
6119
6259
  }));
6120
- console.log(JSON.stringify({
6260
+ const jsonText = JSON.stringify({
6121
6261
  query: query || '*',
6122
6262
  activeSnapshot: result.activeSnapshot ?? null,
6123
6263
  sessionHandoff: result.sessionHandoff ?? null,
6124
6264
  recentSessionEvents: result.recentEvents ?? [],
6125
6265
  memories: output,
6126
6266
  tokens: result.tokens,
6267
+ });
6268
+ console.log(jsonText);
6269
+ withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6270
+ tenantId: ctx.tenantId, sessionId: currentSessionId, surface: pinnedOnly ? 'hook' : 'context',
6271
+ event: 'inject', items: output.length, tokens: estimateTokens(jsonText),
6127
6272
  }));
6128
6273
  }
6129
6274
  else if (format === 'additional-context') {
6130
6275
  // Claude Code UserPromptSubmit hook JSON shape. Capture print* helpers'
6131
6276
  // 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 {
6277
+ const textBlock = captureConsole(() => {
6136
6278
  if (result.activeSnapshot)
6137
6279
  printActiveTaskSnapshot(result.activeSnapshot);
6138
6280
  if (result.sessionHandoff)
@@ -6141,16 +6283,37 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6141
6283
  printSessionEvents(result.recentEvents);
6142
6284
  }
6143
6285
  if (renderItems.length > 0) {
6144
- printContextMarkdown(renderItems, result.tokens, framing);
6286
+ // TE1: no live strength percentage, so an unchanged set of memories
6287
+ // renders byte-identically turn after turn.
6288
+ printContextMarkdown(renderItems, result.tokens, framing, { showStrength: false });
6145
6289
  }
6146
6290
  printCrossProjectSection(crossEntries);
6147
- }
6148
- finally {
6149
- console.log = realLog;
6150
- }
6151
- const textBlock = lines.join('\n');
6291
+ });
6152
6292
  if (!textBlock.trim())
6153
6293
  return;
6294
+ const surface = pinnedOnly ? 'hook' : 'context';
6295
+ const hash = blockHash(textBlock);
6296
+ const tokens = estimateTokens(textBlock);
6297
+ // TE2: the per-prompt hook skips a block identical to the one this
6298
+ // session already has, and resends it every refreshTurns skips. Only
6299
+ // with a session id from the hook payload itself: an inherited env id
6300
+ // (a manual run inside an agent's shell) must never suppress output.
6301
+ if (pinnedOnly && payloadSessionId !== undefined) {
6302
+ const injectCfg = loadConfig(hippoRoot).pinnedInject;
6303
+ if (injectCfg.skipUnchanged !== false) {
6304
+ const refreshTurns = Number.isFinite(injectCfg.refreshTurns) && injectCfg.refreshTurns >= 0
6305
+ ? injectCfg.refreshTurns
6306
+ : 10;
6307
+ const last = withLedgerDb(hippoRoot, (db) => lastSentState(db, ctx.tenantId, payloadSessionId, surface));
6308
+ if (shouldSkipUnchanged(last ?? null, hash, refreshTurns)) {
6309
+ withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6310
+ tenantId: ctx.tenantId, sessionId: payloadSessionId, surface, event: 'skip',
6311
+ items: renderItems.length, tokens, hash,
6312
+ }));
6313
+ return;
6314
+ }
6315
+ }
6316
+ }
6154
6317
  const payload = {
6155
6318
  hookSpecificOutput: {
6156
6319
  hookEventName: 'UserPromptSubmit',
@@ -6158,25 +6321,113 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6158
6321
  },
6159
6322
  };
6160
6323
  process.stdout.write(JSON.stringify(payload));
6324
+ withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6325
+ tenantId: ctx.tenantId, sessionId: currentSessionId, surface, event: 'inject',
6326
+ items: renderItems.length, tokens, hash,
6327
+ }));
6161
6328
  }
6162
6329
  else {
6163
6330
  // 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
- }
6331
+ const text = captureConsole(() => {
6332
+ if (result.activeSnapshot) {
6333
+ printActiveTaskSnapshot(result.activeSnapshot);
6334
+ }
6335
+ if (result.sessionHandoff) {
6336
+ printHandoff(result.sessionHandoff);
6337
+ }
6338
+ if (result.recentEvents && result.recentEvents.length > 0) {
6339
+ printSessionEvents(result.recentEvents);
6340
+ }
6341
+ if (renderItems.length > 0) {
6342
+ printContextMarkdown(renderItems, result.tokens, framing);
6343
+ }
6344
+ printCrossProjectSection(crossEntries);
6345
+ if (result.ambientState) {
6346
+ console.log(`\n${renderAmbientSummary(result.ambientState)}`);
6347
+ }
6348
+ });
6349
+ if (text.length > 0)
6350
+ console.log(text);
6351
+ withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6352
+ tenantId: ctx.tenantId, sessionId: currentSessionId, surface: pinnedOnly ? 'hook' : 'context',
6353
+ event: 'inject', items: renderItems.length, tokens: estimateTokens(text),
6354
+ }));
6355
+ }
6356
+ }
6357
+ /**
6358
+ * TE2: compaction drops the pinned blocks the per-prompt hook injected
6359
+ * earlier, so record a `reset` for the payload's session and the next prompt
6360
+ * injects again even if nothing changed. `requiredSource` limits it to hook
6361
+ * payloads with that `source` (SessionStart fires for other reasons too).
6362
+ * Best-effort and silent: a malformed payload records nothing.
6363
+ */
6364
+ function resetHookInjection(hippoRoot, stdinText, requiredSource) {
6365
+ const sessionId = hookPayloadSessionId(stdinText, requiredSource);
6366
+ if (sessionId === null)
6367
+ return;
6368
+ withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6369
+ tenantId: resolveTenantId({}), sessionId, surface: 'hook', event: 'reset', items: 0, tokens: 0,
6370
+ }));
6371
+ }
6372
+ /**
6373
+ * Run `fn` with console.log captured; returns the captured lines joined by
6374
+ * newlines (what the same calls would have printed, minus the final newline).
6375
+ */
6376
+ function captureConsole(fn) {
6377
+ const lines = [];
6378
+ const realLog = console.log;
6379
+ console.log = (...parts) => { lines.push(parts.map(String).join(' ')); };
6380
+ try {
6381
+ fn();
6382
+ }
6383
+ finally {
6384
+ console.log = realLog;
6385
+ }
6386
+ return lines.join('\n');
6387
+ }
6388
+ /**
6389
+ * The store a Claude Code hook writes to: the project store when there is
6390
+ * one, else an existing global store, else the project path (which the hook
6391
+ * then skips, since hooks fire in every directory and must not create one).
6392
+ * Pre-compact and compact-resume must agree, or a snapshot saved to one store
6393
+ * is looked for in the other.
6394
+ */
6395
+ function hookStoreRoot(hippoRoot) {
6396
+ if (isInitialized(hippoRoot))
6397
+ return hippoRoot;
6398
+ const globalRoot = getGlobalRoot();
6399
+ return isInitialized(globalRoot) ? globalRoot : hippoRoot;
6400
+ }
6401
+ /**
6402
+ * Run `fn` against the token ledger's store: the local store when it is
6403
+ * initialized, else the global one (the per-prompt hook runs in directories
6404
+ * without a local store). Best-effort: returns undefined and never throws,
6405
+ * because a ledger failure must not break context or recall.
6406
+ */
6407
+ function withLedgerDb(hippoRoot, fn) {
6408
+ let root = null;
6409
+ try {
6410
+ if (isInitialized(hippoRoot))
6411
+ root = hippoRoot;
6412
+ else if (isInitialized(getGlobalRoot()))
6413
+ root = getGlobalRoot();
6414
+ }
6415
+ catch {
6416
+ return undefined;
6417
+ }
6418
+ if (root === null)
6419
+ return undefined;
6420
+ let db;
6421
+ try {
6422
+ db = openHippoDb(root);
6423
+ return fn(db);
6424
+ }
6425
+ catch {
6426
+ return undefined;
6427
+ }
6428
+ finally {
6429
+ if (db)
6430
+ closeHippoDb(db);
6180
6431
  }
6181
6432
  }
6182
6433
  /**
@@ -6195,13 +6446,15 @@ function printCrossProjectSection(items) {
6195
6446
  }
6196
6447
  }
6197
6448
  /** @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') {
6449
+ export function printContextMarkdown(items, totalTokens, framing = 'observe', opts = {}) {
6199
6450
  const now = evalNow();
6451
+ const showStrength = opts.showStrength !== false;
6200
6452
  console.log(`## Project Memory (${items.length} entries, ${totalTokens} tokens)\n`);
6201
6453
  for (const item of items) {
6202
6454
  const e = item.entry;
6203
6455
  const tagStr = e.tags.length > 0 ? ` [${e.tags.join(', ')}]` : '';
6204
6456
  const strengthPct = Math.round(calculateStrength(e) * 100);
6457
+ const strengthStr = showStrength ? ` (${strengthPct}%)` : '';
6205
6458
  const globalPrefix = item.isGlobal ? '[global] ' : '';
6206
6459
  const effectiveConf = confidenceFacets(e, now).tier;
6207
6460
  const label = confidenceLabel(e, now);
@@ -6211,21 +6464,21 @@ export function printContextMarkdown(items, totalTokens, framing = 'observe') {
6211
6464
  const dateStr = new Date(e.created).toISOString().slice(0, 10);
6212
6465
  if (effectiveConf === 'verified') {
6213
6466
  // Verified: no date prefix, just the rule
6214
- console.log(`- **${confTag} ${globalPrefix}${e.content}**${tagStr} (${strengthPct}%)`);
6467
+ console.log(`- **${confTag} ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
6215
6468
  }
6216
6469
  else if (effectiveConf === 'stale') {
6217
- console.log(`- **${confTag} Previously observed (${dateStr}): ${globalPrefix}${e.content}**${tagStr} (${strengthPct}%)`);
6470
+ console.log(`- **${confTag} Previously observed (${dateStr}): ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
6218
6471
  }
6219
6472
  else {
6220
- console.log(`- **${confTag} Previously observed (${dateStr}): ${globalPrefix}${e.content}**${tagStr} (${strengthPct}%)`);
6473
+ console.log(`- **${confTag} Previously observed (${dateStr}): ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
6221
6474
  }
6222
6475
  }
6223
6476
  else if (framing === 'suggest') {
6224
- console.log(`- **${confTag} Consider checking: ${globalPrefix}${e.content}**${tagStr} (${strengthPct}%)`);
6477
+ console.log(`- **${confTag} Consider checking: ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
6225
6478
  }
6226
6479
  else {
6227
6480
  // framing === 'assert': no prefix (bare facts)
6228
- console.log(`- **${confTag} ${globalPrefix}${e.content}**${tagStr} (${strengthPct}%)`);
6481
+ console.log(`- **${confTag} ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
6229
6482
  }
6230
6483
  }
6231
6484
  }
@@ -6369,7 +6622,7 @@ async function cmdWatch(command, hippoRoot) {
6369
6622
  const existingWatch = loadAllEntries(hippoRoot, entry.tenantId);
6370
6623
  const watchFit = computeSchemaFit(entry.content, entry.tags, existingWatch);
6371
6624
  entry.schema_fit = watchFit;
6372
- entry.half_life_days = deriveHalfLife(7, entry);
6625
+ entry.half_life_days = deriveHalfLife(loadConfig(hippoRoot).defaultHalfLifeDays, entry);
6373
6626
  entry.strength = calculateStrength(entry);
6374
6627
  // AT1 (plan §3 containment): mechanical content from a failed command — a
6375
6628
  // rejection-guard refusal here must not crash the watcher. Skip silently
@@ -6408,7 +6661,9 @@ function learnFromRepo(hippoRoot, repoPath, days, label) {
6408
6661
  console.log(`${prefix}No fix/revert/bug commits found in the specified period.`);
6409
6662
  return { added: 0, skipped: 0, lowInfo: 0 };
6410
6663
  }
6411
- const parsedLessons = extractLessons(gitLog);
6664
+ // Same patterns as MCP hippo_learn: config.gitLearnPatterns (whose default
6665
+ // equals extractLessons' built-in list) so a custom list applies everywhere.
6666
+ const parsedLessons = extractLessons(gitLog, loadConfig(hippoRoot).gitLearnPatterns);
6412
6667
  if (parsedLessons.length === 0) {
6413
6668
  console.log(`${prefix}No fix/revert/bug commits found in the specified period.`);
6414
6669
  return { added: 0, skipped: 0, lowInfo: 0 };
@@ -6968,6 +7223,12 @@ function cmdHook(args, flags) {
6968
7223
  if (result.installedCompactResume) {
6969
7224
  console.log(`Installed hippo compact-resume SessionStart(compact) hook in ${result.target} settings`);
6970
7225
  }
7226
+ if (result.installedPostCompact) {
7227
+ console.log(`Installed hippo post-compact PostCompact hook in ${result.target} settings`);
7228
+ }
7229
+ if (result.installedCaptureError) {
7230
+ console.log(`Installed hippo capture-error PostToolUseFailure hook in ${result.target} settings`);
7231
+ }
6971
7232
  if (result.migratedFromStop) {
6972
7233
  console.log(`Migrated legacy Stop hook → SessionEnd (was running every turn; now fires once on session exit)`);
6973
7234
  }
@@ -7088,6 +7349,10 @@ function cmdSetup(flags) {
7088
7349
  bits.push('PreCompact (pre-compact)');
7089
7350
  if (result.installedCompactResume)
7090
7351
  bits.push('SessionStart(compact) (compact-resume)');
7352
+ if (result.installedPostCompact)
7353
+ bits.push('PostCompact (post-compact)');
7354
+ if (result.installedCaptureError)
7355
+ bits.push('PostToolUseFailure (capture-error)');
7091
7356
  if (result.migratedFromStop)
7092
7357
  bits.push('migrated legacy Stop');
7093
7358
  if (result.migratedSplitSessionEnd)
@@ -7629,6 +7894,8 @@ const VALID_AUDIT_OPS = new Set([
7629
7894
  'reject_refusal', // AT1 — emitted when the rejection guard refuses a write; lockstep
7630
7895
  'unreject_value', // AT1 — emitted by `hippo unreject`; lockstep
7631
7896
  'conflict_resolve', // AT1 — emitted by resolveConflict on every resolution path; lockstep
7897
+ 'half_life_migrate', // Decay default change — emitted by migrateDefaultHalfLife; lockstep with AuditOp union
7898
+ 'dormant_restore', // Dormant memories — emitted by api.restoreDormant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
7632
7899
  ]);
7633
7900
  function formatAuditRow(ev) {
7634
7901
  const target = ev.targetId ?? '-';
@@ -8354,6 +8621,23 @@ Commands:
8354
8621
  --global Operate on the global store
8355
8622
  unreject <digest-prefix> Delete a tombstone (the only escape hatch)
8356
8623
  --global Operate on the global store
8624
+ dormant [<query>] List faded memories sleep kept instead of deleting
8625
+ (on by default; "dormant": {"enabled": false} deletes instead)
8626
+ --limit <n> Max rows, newest first (default: 20)
8627
+ --json Output as JSON
8628
+ --global Operate on the global store
8629
+ dormant restore <id> Bring a dormant memory back to active memory
8630
+ dormant forget <id> Delete a dormant memory permanently
8631
+ capture-error Store a failed tool call as an error memory (reads the Claude Code
8632
+ PostToolUseFailure hook payload on stdin; skips routine failures)
8633
+ doctor Check the install: Node, store, schema, sleep, agent hooks
8634
+ --json Machine-readable report (exit code 1 on any failure)
8635
+ tokens Tokens of memory text hippo handed agents, per surface
8636
+ (hook, context, recall, MCP, HTTP), and what skipping
8637
+ unchanged hook blocks saved
8638
+ --days <n> Window in days (default: 30)
8639
+ --json Output as JSON
8640
+ --global Operate on the global store
8357
8641
  snapshot <sub> Persist or inspect the current active task
8358
8642
  snapshot save Save active task state
8359
8643
  --task <task>
@@ -8481,6 +8765,8 @@ Commands:
8481
8765
  pre-compact PreCompact hook: snapshot + capture the tail before compaction
8482
8766
  --log-file <p> Diagnostic log path (default: ~/.hippo/logs/pre-compact.log)
8483
8767
  compact-resume SessionStart(compact) hook: re-print the snapshot + session trail
8768
+ post-compact PostCompact hook: tell the user what pre-compact saved
8769
+ --log-file <p> Same log path as pre-compact (default: ~/.hippo/logs/pre-compact.log)
8484
8770
  codex-run [-- ...args] Launch real Codex behind Hippo's session-end wrapper
8485
8771
  hook <sub> [target] Manage framework integrations
8486
8772
  hook list Show available hooks
@@ -8645,6 +8931,9 @@ Examples:
8645
8931
  hippo reject --value "never store my key again" --reason "secret"
8646
8932
  hippo rejections
8647
8933
  hippo unreject a1b2c3d4e5f6
8934
+ hippo dormant "staging hostname"
8935
+ hippo dormant restore mem_abc123
8936
+ hippo tokens --days 7
8648
8937
  hippo session log --id sess_123 --task "Ship feature" --type progress --content "Build is green, next step is docs"
8649
8938
  hippo session latest --json
8650
8939
  hippo session resume
@@ -8893,16 +9182,45 @@ async function main(command, args, flags, hippoRoot) {
8893
9182
  case 'pre-compact': {
8894
9183
  // Bounded wait, not a TTY guard: an idle non-TTY pipe must not hang.
8895
9184
  const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
8896
- await cmdPreCompact(hippoRoot, {
9185
+ resetHookInjection(hippoRoot, stdinText, null);
9186
+ await cmdPreCompact(hookStoreRoot(hippoRoot), {
8897
9187
  stdinText,
8898
9188
  stdinTimedOut,
8899
9189
  logFile: typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined,
8900
9190
  });
8901
9191
  break;
8902
9192
  }
9193
+ case 'post-compact': {
9194
+ // PostCompact hook: tells the user what pre-compact saved. Plain text,
9195
+ // because Claude Code shows this hook's stdout as-is. Always exits 0.
9196
+ const { text } = await readStdinBounded();
9197
+ const logFlag = flags['log-file'];
9198
+ const message = postCompactMessage(text, logFlag === true || logFlag === false || Array.isArray(logFlag) ? undefined : logFlag);
9199
+ if (message !== null)
9200
+ console.log(message);
9201
+ break;
9202
+ }
9203
+ case 'capture-error': {
9204
+ // PostToolUseFailure hook: every path exits 0, and nothing is created
9205
+ // when no store exists (the hook fires in every directory).
9206
+ const { text } = await readStdinBounded();
9207
+ try {
9208
+ const root = hookStoreRoot(hippoRoot);
9209
+ const payload = (text ?? '').trim();
9210
+ if (isInitialized(root) && payload) {
9211
+ // SAFETY: JSON.parse returns a JSON value by definition.
9212
+ captureToolFailure(root, resolveTenantId({}), JSON.parse(payload));
9213
+ }
9214
+ }
9215
+ catch {
9216
+ // A malformed payload or store error must never fail the agent's tool call.
9217
+ }
9218
+ break;
9219
+ }
8903
9220
  case 'compact-resume': {
8904
9221
  const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
8905
- cmdCompactResume(hippoRoot, stdinText, stdinTimedOut);
9222
+ resetHookInjection(hippoRoot, stdinText, 'compact');
9223
+ cmdCompactResume(hookStoreRoot(hippoRoot), stdinText, stdinTimedOut);
8906
9224
  break;
8907
9225
  }
8908
9226
  case 'codex-run':
@@ -9049,6 +9367,21 @@ async function main(command, args, flags, hippoRoot) {
9049
9367
  case 'unreject':
9050
9368
  cmdUnreject(hippoRoot, args, flags);
9051
9369
  break;
9370
+ case 'dormant':
9371
+ cmdDormant(hippoRoot, args, flags);
9372
+ break;
9373
+ case 'tokens':
9374
+ cmdTokens(hippoRoot, flags);
9375
+ break;
9376
+ case 'doctor': {
9377
+ // SAFETY: package.json always carries a string "version" (checked at release by check-manifest-versions).
9378
+ const pkg = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'));
9379
+ const report = runDoctor({ version: pkg.version });
9380
+ console.log(flags['json'] ? JSON.stringify(report, null, 2) : formatDoctor(report));
9381
+ if (!report.ok)
9382
+ process.exit(1);
9383
+ break;
9384
+ }
9052
9385
  case 'snapshot':
9053
9386
  cmdSnapshot(hippoRoot, args, flags);
9054
9387
  break;