hippo-memory 1.52.7 → 1.52.9

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 (84) hide show
  1. package/README.md +159 -99
  2. package/dist/api.d.ts +52 -18
  3. package/dist/api.js +155 -86
  4. package/dist/audit.d.ts +2 -1
  5. package/dist/audit.js +63 -0
  6. package/dist/autolearn.js +3 -2
  7. package/dist/capture.d.ts +40 -0
  8. package/dist/capture.js +141 -119
  9. package/dist/churn-git.js +2 -2
  10. package/dist/cli.d.ts +1 -4
  11. package/dist/cli.js +695 -702
  12. package/dist/codex-patch.d.ts +12 -0
  13. package/dist/codex-patch.js +71 -0
  14. package/dist/config.d.ts +0 -1
  15. package/dist/config.js +0 -4
  16. package/dist/connectors/slack/types.d.ts +0 -1
  17. package/dist/consolidate.js +85 -32
  18. package/dist/context-render.d.ts +36 -0
  19. package/dist/context-render.js +154 -0
  20. package/dist/dag.js +3 -2
  21. package/dist/dashboard.js +4 -0
  22. package/dist/db.js +6 -6
  23. package/dist/dedupe.d.ts +6 -6
  24. package/dist/dedupe.js +10 -9
  25. package/dist/doctor.d.ts +1 -1
  26. package/dist/doctor.js +35 -2
  27. package/dist/dormant.d.ts +4 -0
  28. package/dist/dormant.js +17 -2
  29. package/dist/embedding-provider.d.ts +2 -1
  30. package/dist/embedding-provider.js +2 -1
  31. package/dist/embeddings.js +23 -3
  32. package/dist/extensions/openclaw-plugin/index.js +1 -0
  33. package/dist/extract.js +5 -1
  34. package/dist/forward-claim-detector.d.ts +1 -1
  35. package/dist/forward-claim-detector.js +1 -1
  36. package/dist/graph-recall.d.ts +3 -1
  37. package/dist/graph-recall.js +13 -10
  38. package/dist/handoff.d.ts +2 -0
  39. package/dist/hooks.d.ts +19 -5
  40. package/dist/hooks.js +131 -30
  41. package/dist/importers.js +5 -12
  42. package/dist/judgment.d.ts +30 -0
  43. package/dist/judgment.js +122 -0
  44. package/dist/mcp/server.js +174 -213
  45. package/dist/merged-row.d.ts +6 -0
  46. package/dist/merged-row.js +35 -0
  47. package/dist/multihop.d.ts +2 -1
  48. package/dist/multihop.js +7 -4
  49. package/dist/physics-state.d.ts +0 -4
  50. package/dist/physics-state.js +0 -6
  51. package/dist/predictions.d.ts +2 -17
  52. package/dist/predictions.js +2 -15
  53. package/dist/reject-flow.d.ts +7 -5
  54. package/dist/reject-flow.js +41 -12
  55. package/dist/salience.js +12 -5
  56. package/dist/same-text.d.ts +17 -0
  57. package/dist/same-text.js +38 -0
  58. package/dist/scheduler.d.ts +4 -0
  59. package/dist/scheduler.js +8 -0
  60. package/dist/search.d.ts +7 -0
  61. package/dist/search.js +16 -32
  62. package/dist/secret-detect.d.ts +2 -0
  63. package/dist/secret-detect.js +9 -3
  64. package/dist/server-detect.js +9 -33
  65. package/dist/server.js +6 -62
  66. package/dist/session-digest.d.ts +79 -0
  67. package/dist/session-digest.js +528 -0
  68. package/dist/shared.d.ts +11 -3
  69. package/dist/shared.js +41 -31
  70. package/dist/store.d.ts +4 -5
  71. package/dist/store.js +26 -13
  72. package/dist/token-ledger.d.ts +46 -8
  73. package/dist/token-ledger.js +140 -21
  74. package/dist/version.d.ts +1 -1
  75. package/dist/version.js +1 -1
  76. package/dist-ui/assets/index-BhT8RvO6.js +61 -0
  77. package/dist-ui/index.html +1 -1
  78. package/extensions/openclaw-plugin/README.md +4 -4
  79. package/extensions/openclaw-plugin/index.ts +1 -0
  80. package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
  81. package/extensions/openclaw-plugin/package.json +1 -1
  82. package/openclaw.plugin.json +2 -2
  83. package/package.json +2 -2
  84. package/dist-ui/assets/index-BgmA7Hwe.js +0 -61
package/dist/cli.js CHANGED
@@ -37,24 +37,25 @@ import * as path from 'path';
37
37
  import * as fs from 'fs';
38
38
  import * as os from 'os';
39
39
  import { fileURLToPath } from 'node:url';
40
- import { execFileSync, execSync, spawn } from 'child_process';
41
- import { installJsonHooks, uninstallJsonHooks, resolveJsonHookPaths, detectInstalledTools, defaultSleepLogPath, ensureCodexWrapperInstalled, installCodexWrapper, isCodexWrapperInstalled, repairCodexWrapperIfInstalled, uninstallCodexWrapper, resolveCodexSessionTranscript, resolveCodexWrapperPaths, installOpencodePlugin, uninstallOpencodePlugin, resolveOpencodePluginPath, } from './hooks.js';
40
+ import { execFileSync, execSync, spawn, spawnSync } from 'child_process';
41
+ import { installJsonHooks, uninstallJsonHooks, resolveJsonHookPaths, detectInstalledTools, defaultSleepLogPath, ensureCodexWrapperInstalled, installCodexWrapper, detectRealCodexPath, isCodexPresent, isCodexWrapperInstalled, CODEX_TRUST_LINE, repairCodexWrapperIfInstalled, uninstallCodexWrapper, resolveCodexSessionTranscript, resolveCodexWrapperPaths, installOpencodePlugin, uninstallOpencodePlugin, resolveOpencodePluginPath, } from './hooks.js';
42
42
  import { createMemory, calculateStrength, calculateRewardFactor, deriveHalfLife, resolveConfidence, confidenceFacets, confidenceLabel, computeSchemaFit, Layer, } from './memory.js';
43
43
  import { detectSecret } from './secret-detect.js';
44
44
  import { getHippoRoot, isInitialized, initStore, writeEntry, strengthenRetrieved, readEntry, deleteEntry, loadAllEntries, loadSearchEntries, loadRecallSearchEntries, loadIndex, saveIndex, loadStats, updateStats, saveActiveTaskSnapshot, loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, closeTaskSnapshotsForSession, clearActiveTaskSnapshot, appendSessionEvent, listSessionEvents, listMemoryConflicts, resolveConflict, saveSessionHandoff, loadLatestHandoff, loadHandoffById, stampHandoffOutcome, writeSessionEndHandoff, createCard, loadCard, listCards, loadCardRuns, claimCard, heartbeatCard, blockCard, reviewCard, completeCard, reclaimExpiredCards, addCardComment, } from './store.js';
45
45
  import { rejectValue, unrejectValue, listRejectionsForTenant } from './reject-flow.js';
46
46
  import { RejectedValueError } from './rejection.js';
47
47
  import { isHandoffOutcome, formatHandoffEvidenceLine } from './handoff.js';
48
+ import { readSessionScan, recordSessionDigest } from './session-digest.js';
48
49
  import { isCardStatus } from './card.js';
49
50
  import { loadCardDetail } from './card-detail.js';
50
51
  import { passesScopeFilterForRecall } from './recall-scope.js';
51
- import { estimateTokens, hybridSearch, physicsSearch, explainMatch, textOverlap, tokenize as tokenizeQuery } from './search.js';
52
+ import { estimateTokens, fitBudget, hybridSearch, physicsSearch, explainMatch, textOverlap, tokenize as tokenizeQuery } from './search.js';
52
53
  import { compareEntryIdentity } from './compare.js';
53
54
  import { renderTraceContent, parseSteps } from './trace.js';
54
55
  import { writeRecallTraceAtRoot } from './recall-trace.js';
55
56
  import { deduplicateStore } from './dedupe.js';
56
57
  import { isEmbeddingAvailable, embedAll, embedMemory, loadEmbeddingIndex, resolveEmbeddingModel, embeddingModelRequiresReindex, } from './embeddings.js';
57
- import { isEmbeddingConfigured, resolveEmbeddingProvider } from './embedding-provider.js';
58
+ import { resolveEmbeddingProvider } from './embedding-provider.js';
58
59
  import { loadPhysicsState, resetAllPhysicsState } from './physics-state.js';
59
60
  import { computeSystemEnergy, vecNorm } from './physics.js';
60
61
  import { loadConfig } from './config.js';
@@ -63,21 +64,22 @@ import { runDoctor, formatDoctor } from './doctor.js';
63
64
  import { buildSupportBundle, TAIL_MAX_LINES } from './support-bundle.js';
64
65
  import { PACKAGE_VERSION } from './version.js';
65
66
  import { captureToolFailure } from './capture-error.js';
66
- import { blockHash, hookPayloadSessionId, lastSentState, recordTokenUse, shouldSkipUnchanged } from './token-ledger.js';
67
+ import { blockHash, hookPayloadSessionId, isSubagentPayload, lastSentState, readApiCalls, recordRereads, recordTokenUse, shouldSkipUnchanged, } from './token-ledger.js';
67
68
  import { FAILURE_LOG_RETENTION_DAYS } from './failure-log.js';
68
69
  import { pushGoal, getActiveGoals, completeGoal, suspendGoal, resumeGoal, applyGoalStackBoost } from './goals.js';
69
70
  import { rowToGoal } from './goals.js';
70
- import { captureError, extractLessons, partitionLessons, deduplicateLesson, runWatched, fetchGitLog, isGitRepo, } from './autolearn.js';
71
+ import { captureError, extractLessons, partitionLessons, runWatched, fetchGitLog, isGitRepo, } from './autolearn.js';
72
+ import { dropHeldCopies, duplicateKey, storedTextKeys } from './same-text.js';
71
73
  import { extractInvalidationTarget, invalidateMatching, detectChurnStale } from './invalidation.js';
72
- import { resolveProjectIdentity } from './project-identity.js';
74
+ import { deriveOriginProject, realpathOrResolve, resolveProjectIdentity } from './project-identity.js';
73
75
  import { extractPathTags } from './path-context.js';
74
76
  import { detectScope } from './scope.js';
75
77
  import { getGlobalRoot, initGlobal, shareMemory, listPeers, autoShare, transferScore, searchBothHybrid, syncGlobalToLocal, } from './shared.js';
76
- import { DAILY_TASK_NAME, buildDailyRunnerCommand, listRegisteredWorkspaces, registerWorkspace, runDailyMaintenance, } from './scheduler.js';
78
+ import { DAILY_TASK_NAME, buildDailyRunnerCommand, buildSchtasksCreateArgs, buildWindowsTaskRun, listRegisteredWorkspaces, registerWorkspace, runDailyMaintenance, } from './scheduler.js';
77
79
  import { importChatGPT, importClaude, importCursor, importGenericFile, importMarkdown, importVault, } from './importers.js';
78
- import { cmdCapture, cmdPreCompact, postCompactMessage, resolveLastSessionTranscript, truncateCodePointSafe, sanitizeLogMessage } from './capture.js';
80
+ import { cmdCapture, cmdPreCompact, postCompactMessage, resolveLastSessionTranscript, truncateCodePointSafe, sanitizeLogMessage, transcriptWorkingState } from './capture.js';
79
81
  import { readStdinBounded } from './stdin.js';
80
- import { auditMemories, appendAuditEvent, } from './audit.js';
82
+ import { auditMemories, appendAuditEvent, AUDIT_OPS, } from './audit.js';
81
83
  import { listApiKeys, revokeApiKey } from './auth.js';
82
84
  import { buildProvenanceCoverage } from './provenance-coverage.js';
83
85
  import { buildCorrectionLatency } from './correction-latency.js';
@@ -132,6 +134,7 @@ import { getReranker } from './rerankers/index.js';
132
134
  import { JEV_DEFAULT_TOP_K } from './rerankers/jev.js';
133
135
  import { computeSalience } from './salience.js';
134
136
  import { renderAmbientSummary } from './ambient.js';
137
+ import { assembleCost, assembleHeading, contextCost, contextHeading, contextLine, crossProjectHeading, crossProjectLine, drillCost, handoffText, printedTokens, sessionTrailText, settleTokens, snapshotText, } from './context-render.js';
135
138
  import { validateOwner, isStrictOwnerEnv } from './owner-validation.js';
136
139
  import { pruneAuditLog, parseOlderThanFlag } from './audit-prune.js';
137
140
  import { listDlq, replayDlqEntry } from './connectors/slack/dlq.js';
@@ -203,7 +206,7 @@ function requireInit(hippoRoot) {
203
206
  }
204
207
  /** FE2: run detectChurnStale against every store this repo's memories can live in. */
205
208
  function runChurnStaleForRepo(hippoRoot, dryRun) {
206
- const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: process.cwd(), encoding: 'utf8' }).trim();
209
+ const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: process.cwd(), encoding: 'utf8', windowsHide: true }).trim();
207
210
  const projectName = resolveProjectIdentity(process.cwd()).name;
208
211
  const globalRoot = getGlobalRoot();
209
212
  const roots = globalRoot !== hippoRoot && isInitialized(globalRoot) ? [hippoRoot, globalRoot] : [hippoRoot];
@@ -407,6 +410,71 @@ export function parseArgs(argv) {
407
410
  function fmt(n, digits = 2) {
408
411
  return n.toFixed(digits);
409
412
  }
413
+ // What `hippo recall` prints for one result; the budget prices this same text.
414
+ function recallEntryText(r, query, showWhy, isGlobal) {
415
+ const e = r.entry;
416
+ const label = confidenceLabel(e);
417
+ const confLabel = label.warn ? `[${label.text}] ⚠️` : `[${label.text}]`;
418
+ const bars = Math.round(e.strength * 10);
419
+ const graphMark = r.graphVia ? ` [graph: ${r.graphVia.hops}hop ${r.graphVia.relType}]` : '';
420
+ const lines = [
421
+ `--- ${e.id} [${e.layer}] ${confLabel}${isGlobal ? ' [global]' : ''}${e.superseded_by ? ' [superseded]' : ''}${graphMark} score=${fmt(r.score, 3)} strength=${fmt(e.strength)}`,
422
+ ` [${'█'.repeat(bars)}${'░'.repeat(10 - bars)}] tags: ${e.tags.join(', ') || 'none'} | retrieved: ${e.retrieval_count}x`,
423
+ ];
424
+ if (showWhy) {
425
+ const explanation = explainMatch(query, r);
426
+ lines.push(` source:${isGlobal ? ' [global]' : ' [local]'} | layer: [${e.layer}] | confidence: [${label.text}]`, ` reason: ${explanation.reason}`);
427
+ const env = explanation.envelope;
428
+ if (env) {
429
+ lines.push(` kind: ${env.kind}`);
430
+ if (env.scope)
431
+ lines.push(` scope: ${env.scope}`);
432
+ if (env.owner)
433
+ lines.push(` owner: ${env.owner}`);
434
+ if (env.artifact_ref)
435
+ lines.push(` artifact_ref: ${env.artifact_ref}`);
436
+ if (env.session_id)
437
+ lines.push(` session_id: ${env.session_id}`);
438
+ lines.push(` confidence: ${env.confidence}`);
439
+ }
440
+ // A7 recall-trace, e.g. "ranking: base 0.420 -> interference x0.30 -> 0.126 -> goal-boost x1.50 -> 0.189".
441
+ if (r.rerankTrace && r.rerankTrace.length > 0) {
442
+ const parts = [`base ${fmt(r.rerankTrace[0].scoreBefore, 3)}`];
443
+ for (const step of r.rerankTrace) {
444
+ parts.push(`${step.stage}${step.multiplier !== undefined ? ` x${fmt(step.multiplier, 2)}` : ''}`, fmt(step.scoreAfter, 3));
445
+ }
446
+ lines.push(` ranking: ${parts.join(' -> ')}`);
447
+ }
448
+ }
449
+ lines.push('', e.content, '');
450
+ return lines.join('\n');
451
+ }
452
+ function recallHeading(entries, tokens, query) {
453
+ return `Found ${entries} memories (${tokens} tokens) for: "${query}"\n`;
454
+ }
455
+ // JSON.stringify keeps quotes or parens in the matched phrase from blurring the line.
456
+ function planningLine(p) {
457
+ if (p.hint)
458
+ return `Planning fallacy hint (class: ${p.hint.classTag}): ${p.hint.baserateSummary} [detected: ${JSON.stringify(p.hint.detectedPhrase)}]`;
459
+ if (p.watching)
460
+ return `Planning fallacy: watching this query (reason: ${p.watching.reason}). ${p.watching.suggestion} [detected: ${JSON.stringify(p.watching.detectedPhrase)}]`;
461
+ return null;
462
+ }
463
+ function cutoffLine(shown, s) {
464
+ const clauses = [];
465
+ // The residual covers rank, budget and limit drops alike, and fires with no --limit at all.
466
+ if (s.droppedByBudget > 0)
467
+ clauses.push(`${s.droppedByBudget} not shown (rank, budget or limit)`);
468
+ if (s.droppedPreRank > 0)
469
+ clauses.push(`${s.droppedPreRank} filtered pre-rank`);
470
+ if (s.summarySubstitutionsAdded > 0)
471
+ clauses.push(`${s.summarySubstitutionsAdded} summary substitutions added`);
472
+ if (s.freshTailAdded > 0)
473
+ clauses.push(`${s.freshTailAdded} fresh-tail added`);
474
+ if (s.suppressedByInterference > 0)
475
+ clauses.push(`${s.suppressedByInterference} suppressed by interference`);
476
+ return clauses.length > 0 ? `Cutoff: showing ${shown} of ${s.totalCandidates} candidates; ${clauses.join('; ')}.` : null;
477
+ }
410
478
  // ---------------------------------------------------------------------------
411
479
  // Commands
412
480
  // ---------------------------------------------------------------------------
@@ -484,7 +552,7 @@ function cmdInitScan(scanDir, flags) {
484
552
  (totalLowInfo > 0 ? `, ${totalLowInfo} low-information subject(s) dropped` : '') +
485
553
  '.');
486
554
  console.log(`Global store: ${globalRoot}`);
487
- if (!flags['no-hooks']) {
555
+ if (initInstallsIntegrations(flags)) {
488
556
  // User-level hooks only: a hippo block in each repo's CLAUDE.md or AGENTS.md would leave a diff in every repo.
489
557
  const agents = detectAgentHooks(repos);
490
558
  if (agents.length === 0) {
@@ -526,8 +594,7 @@ function cmdInit(hippoRoot, flags) {
526
594
  }
527
595
  const globalRoot = getGlobalRoot();
528
596
  registerWorkspace(globalRoot, path.dirname(hippoRoot));
529
- // Auto-detect and install hooks (unless --no-hooks)
530
- if (!flags['no-hooks']) {
597
+ if (initInstallsIntegrations(flags)) {
531
598
  autoInstallHooks();
532
599
  }
533
600
  // Auto-setup daily schedule (unless --no-schedule)
@@ -547,13 +614,21 @@ function cmdInit(hippoRoot, flags) {
547
614
  console.log(` No matching commits found in git history.`);
548
615
  }
549
616
  }
550
- // Also import from Claude Code / agent MEMORY.md files
551
617
  const memImported = learnFromMemoryMd(hippoRoot);
552
618
  if (memImported > 0) {
553
- console.log(` Imported ${memImported} memories from agent MEMORY.md files.`);
619
+ console.log(` Imported ${memImported} memories from this project's Claude Code auto memory.`);
554
620
  }
555
621
  }
556
622
  }
623
+ /** Every write init makes into agent config (instruction blocks, hooks, plugins) is an automatic integration, so one switch skips them all. */
624
+ function initInstallsIntegrations(flags) {
625
+ if (flags['no-hooks'])
626
+ return false;
627
+ if (process.env.HIPPO_SKIP_AUTO_INTEGRATIONS !== '1')
628
+ return true;
629
+ console.log(' HIPPO_SKIP_AUTO_INTEGRATIONS=1, so init left agent instruction files and hooks alone.');
630
+ return false;
631
+ }
557
632
  /** Plain init: patch the detected agents' instruction files in cwd, then install their user-level hooks. */
558
633
  function autoInstallHooks() {
559
634
  const cwd = process.cwd();
@@ -629,13 +704,32 @@ function refreshShippedBlock(filePath, text, hook) {
629
704
  fs.writeFileSync(filePath, `${text.slice(0, start)}${eol}${HOOKS[owner].content.replace(/\n/g, eol)}${eol}${text.slice(end)}`, 'utf8');
630
705
  console.log(` Refreshed the ${owner} hippo block in ${name}`);
631
706
  }
632
- /** Claude Code settings hooks and the OpenCode plugin, under the home directory; idempotent, so re-running init adds newer hooks. */
707
+ /** Adds hippo's two Codex hooks and says what changed; each install ends on the trust reminder, since Codex skips an untrusted hook. */
708
+ function installCodexMemoryHooks(indent) {
709
+ const result = installJsonHooks('codex');
710
+ if (result.invalidJson) {
711
+ console.log(`${indent}WARNING: ${result.settingsPath} is not a hooks file hippo can merge into; fix it, then run \`hippo hook install codex\`.`);
712
+ return;
713
+ }
714
+ const added = [
715
+ result.installedUserPromptSubmit ? 'UserPromptSubmit' : '',
716
+ result.installedCompactResume ? 'SessionStart(compact)' : '',
717
+ ].filter(Boolean);
718
+ console.log(added.length > 0
719
+ ? `${indent}Installed hippo's Codex memory hooks (${added.join(', ')}) in ${result.settingsPath}`
720
+ : `${indent}hippo's Codex memory hooks already in ${result.settingsPath}`);
721
+ console.log(`${indent}${CODEX_TRUST_LINE}`);
722
+ }
723
+ /** Claude Code settings hooks, Codex's hooks.json and the OpenCode plugin, under the home directory; idempotent, so re-running init adds newer hooks. */
633
724
  function installUserLevelHooks(agents, codexHint) {
634
725
  for (const hook of agents) {
635
726
  // The Codex capture wrapper swaps the codex launcher binary, so init only points at the opt-in (issue #133).
636
727
  if (hook === 'codex' && codexHint && !isCodexWrapperInstalled()) {
637
728
  console.log(' Codex detected. To capture Codex sessions: hippo hook install codex');
638
729
  }
730
+ // Checked first so init never creates ~/.codex on a machine without Codex.
731
+ if (hook === 'codex' && isCodexPresent())
732
+ installCodexMemoryHooks(' ');
639
733
  // For Claude Code, also install SessionEnd+SessionStart entries in its
640
734
  // settings.json. Keeps `hippo init` in lockstep with `hippo hook install
641
735
  // claude-code` and `hippo setup`.
@@ -711,7 +805,7 @@ function setupDailySchedule(globalRoot) {
711
805
  if (isWindows) {
712
806
  // Check if task already exists
713
807
  try {
714
- const existing = execSync(`schtasks /query /tn "${taskName}" 2>nul`, { encoding: 'utf-8' });
808
+ const existing = execSync(`schtasks /query /tn "${taskName}" 2>nul`, { encoding: 'utf-8', windowsHide: true });
715
809
  if (existing.includes(taskName)) {
716
810
  return; // already scheduled
717
811
  }
@@ -720,26 +814,26 @@ function setupDailySchedule(globalRoot) {
720
814
  // Task doesn't exist, create it
721
815
  }
722
816
  try {
723
- execSync(`schtasks /create /tn "${taskName}" /tr "cmd /c ${cmd.replace(/"/g, '""')}" /sc daily /st 06:15 /f`, { stdio: 'pipe' });
817
+ execFileSync('schtasks', buildSchtasksCreateArgs(taskName, cmd), { stdio: 'pipe', windowsHide: true });
724
818
  console.log(` Scheduled machine-level daily runner (6:15am) via Task Scheduler: ${taskName}`);
725
819
  }
726
820
  catch {
727
821
  // No admin rights or schtasks unavailable, fall back to printing instructions
728
822
  console.log(` To schedule the machine-level daily runner, run:`);
729
- console.log(` schtasks /create /tn "${taskName}" /tr "cmd /c ${cmd}" /sc daily /st 06:15`);
823
+ console.log(` schtasks /create /tn "${taskName}" /tr "${buildWindowsTaskRun(cmd).replace(/"/g, '\\"')}" /sc daily /st 06:15`);
730
824
  }
731
825
  }
732
826
  else {
733
827
  // Unix: check crontab for existing entry
734
828
  const marker = `# hippo:${taskName}`;
735
829
  try {
736
- const existing = execSync('crontab -l 2>/dev/null', { encoding: 'utf-8' });
830
+ const existing = execSync('crontab -l 2>/dev/null', { encoding: 'utf-8', windowsHide: true });
737
831
  if (existing.includes(marker)) {
738
832
  return; // already scheduled
739
833
  }
740
834
  const cronLine = `15 6 * * * ${cmd} ${marker}`;
741
835
  const newCrontab = existing.trimEnd() + '\n' + cronLine + '\n';
742
- execSync('crontab -', { input: newCrontab, stdio: ['pipe', 'pipe', 'pipe'] });
836
+ execSync('crontab -', { input: newCrontab, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
743
837
  console.log(` Scheduled machine-level daily runner (6:15am) via crontab`);
744
838
  }
745
839
  catch {
@@ -749,6 +843,25 @@ function setupDailySchedule(globalRoot) {
749
843
  }
750
844
  }
751
845
  }
846
+ // Shared by the direct write and the routed request so both store the same tags.
847
+ function rememberTags(flags, cwd) {
848
+ const requested = Array.isArray(flags['tag']) ? [...flags['tag']] : [];
849
+ if (flags['error'])
850
+ requested.push('error');
851
+ const all = [...requested];
852
+ for (const pt of extractPathTags(cwd)) {
853
+ if (!all.includes(pt))
854
+ all.push(pt);
855
+ }
856
+ const explicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
857
+ const activeScope = explicitScope || detectScope();
858
+ if (activeScope) {
859
+ const scopeTag = `scope:${activeScope}`;
860
+ if (!all.includes(scopeTag))
861
+ all.push(scopeTag);
862
+ }
863
+ return { requested, all };
864
+ }
752
865
  async function cmdRemember(hippoRoot, text, flags) {
753
866
  const useGlobal = Boolean(flags['global']);
754
867
  const targetRoot = useGlobal ? getGlobalRoot() : hippoRoot;
@@ -758,9 +871,7 @@ async function cmdRemember(hippoRoot, text, flags) {
758
871
  else {
759
872
  requireInit(hippoRoot);
760
873
  }
761
- const rawTags = Array.isArray(flags['tag']) ? flags['tag'] : [];
762
- if (flags['error'])
763
- rawTags.push('error');
874
+ const { requested: requestedTags, all: allTags } = rememberTags(flags, process.cwd());
764
875
  // Resolve explicit confidence flag (default: 'verified' for manual remember)
765
876
  let confidence = 'verified';
766
877
  if (flags['observed'])
@@ -769,9 +880,9 @@ async function cmdRemember(hippoRoot, text, flags) {
769
880
  confidence = 'inferred';
770
881
  if (flags['verified'])
771
882
  confidence = 'verified';
772
- // Compute schema fit against existing memories
883
+ // Schema fit needs the store, which the routed request has no access to, so it stays here.
773
884
  const existing = loadAllEntries(targetRoot, resolveTenantId({}));
774
- const schemaFit = computeSchemaFit(text, rawTags, existing);
885
+ const schemaFit = computeSchemaFit(text, requestedTags, existing);
775
886
  // A3 envelope flags
776
887
  const kindFlagRaw = typeof flags['kind'] === 'string' ? flags['kind'] : undefined;
777
888
  const kindFlag = kindFlagRaw === undefined ? undefined : kindFlagRaw.toLowerCase();
@@ -803,7 +914,7 @@ async function cmdRemember(hippoRoot, text, flags) {
803
914
  const rememberConfig = loadConfig(targetRoot);
804
915
  const entry = createMemory(text, {
805
916
  layer: Layer.Episodic,
806
- tags: rawTags,
917
+ tags: allTags,
807
918
  pinned: Boolean(flags['pin']),
808
919
  source: useGlobal ? 'cli-global' : 'cli',
809
920
  confidence,
@@ -815,20 +926,6 @@ async function cmdRemember(hippoRoot, text, flags) {
815
926
  tenantId,
816
927
  baseHalfLifeDays: rememberConfig.defaultHalfLifeDays,
817
928
  });
818
- // Auto-tag with path context
819
- const pathTags = extractPathTags(process.cwd());
820
- for (const pt of pathTags) {
821
- if (!entry.tags.includes(pt))
822
- entry.tags.push(pt);
823
- }
824
- // Scope tagging: explicit --scope or auto-detected
825
- const explicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
826
- const activeScope = explicitScope || detectScope();
827
- if (activeScope) {
828
- const scopeTag = `scope:${activeScope}`;
829
- if (!entry.tags.includes(scopeTag))
830
- entry.tags.push(scopeTag);
831
- }
832
929
  // Salience gate: decide if this memory is worth storing
833
930
  if (rememberConfig.salience.enabled && !Boolean(flags['pin']) && !Boolean(flags['force'])) {
834
931
  const salienceResult = computeSalience(text, entry.tags, existing, {
@@ -856,12 +953,7 @@ async function cmdRemember(hippoRoot, text, flags) {
856
953
  console.log(` Tags: ${entry.tags.join(', ')}`);
857
954
  if (entry.pinned)
858
955
  console.log(' Pinned (no decay)');
859
- // Auto-embed if available (provider-aware: local dep installed, or API key present)
860
- if (isEmbeddingConfigured(targetRoot)) {
861
- embedMemory(targetRoot, entry).catch(() => {
862
- // Silently ignore embedding errors
863
- });
864
- }
956
+ void embedMemory(targetRoot, entry);
865
957
  const config = loadConfig(targetRoot);
866
958
  const shouldExtract = flags['extract'] || config.extraction.enabled === true;
867
959
  const apiKey = process.env.ANTHROPIC_API_KEY ?? '';
@@ -1097,6 +1189,12 @@ async function cmdRecall(hippoRoot, query, flags) {
1097
1189
  }
1098
1190
  graphStreamSeeds = s;
1099
1191
  }
1192
+ // Engines spend the budget on the text each result prints as, less the header, so selection and print agree.
1193
+ const localIndex = loadIndex(hippoRoot);
1194
+ const globalOn = isInitialized(globalRoot);
1195
+ const entryText = (r) => recallEntryText(r, query, showWhy, globalOn && !localIndex.entries[r.entry.id]);
1196
+ const printCost = (r) => printedTokens(entryText(r));
1197
+ const entryBudget = Math.max(0, budget - printedTokens(recallHeading(budget, budget, query)));
1100
1198
  let results;
1101
1199
  if (useGraphStream) {
1102
1200
  if (!isEmbeddingAvailable()) {
@@ -1112,16 +1210,18 @@ async function cmdRecall(hippoRoot, query, flags) {
1112
1210
  // tail; on a pool with <= seedCount candidates EVERY candidate is a seed and the stream
1113
1211
  // is inert (it degrades to the 2-list fusion). Tune the anchor count with --graph-seeds.
1114
1212
  results = await hybridSearch(query, localEntries, {
1115
- budget, hippoRoot, mmr: mmrEnabled, mmrLambda, minResults, scope: recallActiveScope,
1213
+ budget: entryBudget, cost: printCost, hippoRoot, mmr: mmrEnabled, mmrLambda, minResults, scope: recallActiveScope,
1116
1214
  includeSuperseded, asOf,
1117
1215
  scoring: 'rrf',
1118
1216
  graphStream: { weight: DEFAULT_GRAPH_STREAM_WEIGHT, tenantId, hops: graphStreamHops, seedCount: graphStreamSeeds },
1119
1217
  });
1120
1218
  }
1121
1219
  else if (useMultihop) {
1122
- const allEntries = [...localEntries, ...globalEntries];
1220
+ // Unlike searchBothHybrid below, multihop ranks one pooled list, so a shared memory's two copies both compete.
1221
+ const allEntries = api.oneCopyPerMemory(localEntries, globalEntries, evalNow()).flat();
1123
1222
  results = multihopSearch(query, allEntries, {
1124
- budget,
1223
+ budget: entryBudget,
1224
+ cost: printCost,
1125
1225
  hippoRoot,
1126
1226
  minResults,
1127
1227
  includeSuperseded,
@@ -1130,7 +1230,8 @@ async function cmdRecall(hippoRoot, query, flags) {
1130
1230
  }
1131
1231
  else if (usePhysics && !hasGlobal) {
1132
1232
  results = await physicsSearch(query, localEntries, {
1133
- budget,
1233
+ budget: entryBudget,
1234
+ cost: printCost,
1134
1235
  hippoRoot,
1135
1236
  physicsConfig: config.physics,
1136
1237
  minResults,
@@ -1145,7 +1246,7 @@ async function cmdRecall(hippoRoot, query, flags) {
1145
1246
  // so the scope rule must be plumbed in — the filtered localEntries /
1146
1247
  // globalEntries above are NOT what this path ranks.
1147
1248
  results = await searchBothHybrid(query, hippoRoot, globalRoot, {
1148
- budget, mmr: mmrEnabled, mmrLambda, localBump, minResults, scope: recallActiveScope, tenantId,
1249
+ budget: entryBudget, cost: printCost, mmr: mmrEnabled, mmrLambda, localBump, minResults, scope: recallActiveScope, tenantId,
1149
1250
  includeSuperseded, asOf,
1150
1251
  recallScope: recallExplicitScope
1151
1252
  ? { requested: recallExplicitScope, additive: true }
@@ -1154,7 +1255,7 @@ async function cmdRecall(hippoRoot, query, flags) {
1154
1255
  }
1155
1256
  else {
1156
1257
  results = await hybridSearch(query, localEntries, {
1157
- budget, hippoRoot, mmr: mmrEnabled, mmrLambda, minResults, scope: recallActiveScope,
1258
+ budget: entryBudget, cost: printCost, hippoRoot, mmr: mmrEnabled, mmrLambda, minResults, scope: recallActiveScope,
1158
1259
  includeSuperseded, asOf,
1159
1260
  });
1160
1261
  }
@@ -1211,7 +1312,8 @@ async function cmdRecall(hippoRoot, query, flags) {
1211
1312
  tenantId,
1212
1313
  includeSuperseded,
1213
1314
  asOf,
1214
- budget,
1315
+ budget: entryBudget,
1316
+ cost: printCost,
1215
1317
  minResults: minResults ?? 1,
1216
1318
  recallScope: recallExplicitScope
1217
1319
  ? { requested: recallExplicitScope, additive: true }
@@ -1590,132 +1692,13 @@ async function cmdRecall(hippoRoot, query, flags) {
1590
1692
  if (limit < results.length) {
1591
1693
  results = results.slice(0, limit);
1592
1694
  }
1593
- const droppedByBudgetCountCmd = Math.max(0, totalCandidatesCountCmd + graphAddedCountCmd - droppedPreRankCountCmd - results.length);
1594
- // v0.33 / J1 — CLI per-pipeline anchoring detector. Each pipeline (api.recall,
1595
- // cmdRecall, MCP) computes its own AnchoringHint via the shared detectAnchoring
1596
- // helper against its own top-1 + its own per-(tenant, session) ring buffer.
1597
- // HIPPO_ANCHORING=off short-circuits BEFORE both the ring lookup and the
1598
- // detect call so disabled tenants pay truly zero work. When sessionId is
1599
- // absent we emit recall_anchor_skipped_no_session for J1-v2 telemetry.
1600
- let cmdAnchoringHint = null;
1601
- if (process.env.HIPPO_ANCHORING !== 'off') {
1602
- if (sessionId) {
1603
- const ringKey = buildSessionKey(tenantId, sessionId);
1604
- const ring = getOrCreateRing(sessionRecallHistoryCli, ringKey);
1605
- const queryHash = hashQueryText(query);
1606
- const topId = results[0]?.entry.id ?? null;
1607
- cmdAnchoringHint = detectAnchoring(snapshotRing(ring), queryHash, topId);
1608
- // Append AFTER detect (snapshot was taken before). anchoredOn carries
1609
- // the memoryId of any hint that fired, feeding the cooldown logic for
1610
- // the NEXT cmdRecall on this session.
1611
- appendRecall(ring, queryHash, topId, cmdAnchoringHint?.memoryId);
1612
- }
1613
- else {
1614
- // Telemetry: caller had no sessionId so ring tracking is skipped.
1615
- // Per the recall-audit convention at api.ts:854, use SHA-256/16
1616
- // for prompt hashing (NOT hashQueryText which is FNV-1a 32-bit
1617
- // for recall matching; brute-force trivial for low-entropy
1618
- // queries). Codex round-1 P1 / round-2 P2 catch.
1619
- emitCliAudit(hippoRoot, 'recall_anchor_skipped_no_session', undefined, {
1620
- query_hash: createHash('sha256').update(query).digest('hex').slice(0, 16),
1621
- query_length: query.length,
1622
- });
1623
- }
1624
- }
1625
- // v1.12.13 / C5 — Build suppressionSummary for cmdRecall pipeline. Surfaced
1626
- // in --why text output and in the --json JSON output. cmdRecall does not
1627
- // run the summarizeOverflow path (api.recall does) and does not currently
1628
- // expose fresh-tail in the CLI, so those two counters are 0 here.
1629
- // v0.33 / J1: suppressedByInterference is bumped by 1 when cmdAnchoringHint
1630
- // fires with reason='memory_dominance' (the only reason that counts as
1631
- // interference; query_repeat is a re-ask, not memory competition).
1632
- const cmdSuppressedByInterference = cmdAnchoringHint?.reason === 'memory_dominance' ? 1 : 0;
1633
- const cmdSuppressionSummary = api.buildSuppressionSummary({
1634
- // PUBLISHED total includes graph-surfaced rows. Folding graphAdded into
1635
- // the derivation but not into the reported total made the invariant hold
1636
- // internally and break externally by exactly that count: JSON consumers
1637
- // saw total != preRank + byBudget + returned, and the text line could
1638
- // read "showing 8 of 10" having actually considered 12. The number a
1639
- // caller sees must be the number the arithmetic used.
1640
- totalCandidates: totalCandidatesCountCmd + graphAddedCountCmd,
1641
- droppedPreRank: droppedPreRankCountCmd,
1642
- droppedByBudget: droppedByBudgetCountCmd,
1643
- summarySubstitutionsAdded: 0,
1644
- freshTailAdded: 0,
1645
- suppressedByInterference: cmdSuppressedByInterference,
1646
- });
1647
- // v0.33 / J1 — emit pipeline-local audit op when a hint fires (lockstep
1648
- // with api.recall's audit pattern; each pipeline emits for its own hits).
1649
- if (cmdAnchoringHint?.reason === 'memory_dominance') {
1650
- emitCliAudit(hippoRoot, 'recall_anchor_detected_memory_dominance', cmdAnchoringHint.memoryId, {
1651
- memory_id: cmdAnchoringHint.memoryId,
1652
- query_count: cmdAnchoringHint.queryCount ?? null,
1653
- });
1654
- }
1655
- else if (cmdAnchoringHint?.reason === 'query_repeat') {
1656
- emitCliAudit(hippoRoot, 'recall_anchor_detected_query_repeat', cmdAnchoringHint.memoryId, {
1657
- memory_id: cmdAnchoringHint.memoryId,
1658
- });
1659
- }
1660
- // v1.13.x / J2 — CLI per-pipeline availability/recency-bias detector. Each
1661
- // pipeline computes its own hint (this one against the CLI's returned top-K
1662
- // and the full local+global candidate pool). Soft warning only. Gated by
1663
- // HIPPO_AVAILABILITY=off, which short-circuits BEFORE the detect call so
1664
- // disabled tenants pay zero work. Audit emission is pipeline-local, lockstep
1665
- // with the anchoring emitCliAudit calls above. cmdAvailabilityHint is null on
1666
- // the zero-result branch (topK < minReturned), so the splat is a no-op there.
1667
- let cmdAvailabilityHint = null;
1668
- if (process.env.HIPPO_AVAILABILITY !== 'off') {
1669
- cmdAvailabilityHint = detectAvailabilityBias({
1670
- topK: results.map((r) => ({ id: r.entry.id, created: r.entry.created })),
1671
- pool: [...localEntries, ...globalEntries].map((e) => ({ id: e.id, created: e.created })),
1672
- });
1673
- if (cmdAvailabilityHint) {
1674
- emitCliAudit(hippoRoot, 'recall_availability_detected', undefined, {
1675
- recent_fraction: cmdAvailabilityHint.recentFraction,
1676
- older_passed_over: cmdAvailabilityHint.olderCandidatesPassedOver,
1677
- returned_count: cmdAvailabilityHint.returnedCount,
1678
- });
1679
- }
1680
- }
1681
- // v0.32 / J3.2 — auto-injection of reference-class baserate when the
1682
- // CLI query carries a forward-prediction phrase AND a class matches.
1683
- // cmdRecall runs its own pipeline (doesn't go through api.recall for
1684
- // the memory list), so it computes the hint here. The hint VALUE is
1685
- // pipeline-invariant — same (hippoRoot, tenantId, query) inputs would
1686
- // produce the same hint in api.recall — but the audit emission is
1687
- // pipeline-local (one audit row per actual call, actor='cli' here).
1688
- // computePlanningFallacyOutput short-circuits BEFORE the regex gate
1689
- // when HIPPO_AUTODEBIAS=off so the no-match path is effectively free.
1690
- // v1.13.4: switched to the richer Output type so the watching variant
1691
- // (regex fired, no class matched OR tiebreak) can also surface.
1692
- const cmdPlanningFallacyOutput = computePlanningFallacyOutput(hippoRoot, tenantId, query, { actor: 'cli' });
1693
- const cmdPlanningFallacyHint = cmdPlanningFallacyOutput.hint ?? null;
1694
- const cmdPlanningFallacyWatching = cmdPlanningFallacyOutput.watching ?? null;
1695
- // A5 audit: emit one 'recall' event per query, capturing the (truncated)
1696
- // query text and the post-filter result count. Tenant resolved by emitCliAudit.
1697
- // Emit before the early-empty return so zero-result recalls are still logged.
1698
- // recall reads from BOTH local and global stores when both are initialized;
1699
- // log against every participating store so the audit trail in either db
1700
- // shows the read access (no false negatives across --global flows).
1701
- const recallMetadata = {
1702
- query: query.slice(0, 200),
1703
- results: results.length,
1704
- };
1705
- emitCliAudit(hippoRoot, 'recall', undefined, recallMetadata);
1706
- if (isInitialized(globalRoot) && globalRoot !== hippoRoot) {
1707
- emitCliAudit(globalRoot, 'recall', undefined, recallMetadata);
1708
- }
1709
1695
  // Continuity assembly (--continuity). Lives BEFORE the zero-result branch
1710
1696
  // so a no-match query with active continuity state still returns a useful
1711
- // resume packet. Same three tenant-scoped store helpers as api.recall;
1712
- // continuityTokens uses the same Math.ceil(len/4) rule as the search-path
1713
- // estimateTokens() in src/search.ts.
1697
+ // resume packet. Same three tenant-scoped store helpers as api.recall.
1714
1698
  const includeContinuity = Boolean(flags['continuity']);
1715
1699
  let activeSnapshot = null;
1716
1700
  let sessionHandoff = null;
1717
1701
  let recentSessionEvents = [];
1718
- let continuityTokens = 0;
1719
1702
  if (includeContinuity) {
1720
1703
  const rawSnapshot = loadActiveTaskSnapshot(hippoRoot, tenantId);
1721
1704
  const sessionId = rawSnapshot?.session_id ?? undefined;
@@ -1735,34 +1718,165 @@ async function cmdRecall(hippoRoot, query, flags) {
1735
1718
  sessionHandoff =
1736
1719
  rawHandoff && passesScopeFilterForRecall(rowScope(rawHandoff), effectiveScope) ? rawHandoff : null;
1737
1720
  recentSessionEvents = rawEvents.filter((e) => passesScopeFilterForRecall(rowScope(e), effectiveScope));
1738
- const tokenize = (s) => s ? estimateTokens(s) : 0;
1739
- continuityTokens =
1740
- tokenize(activeSnapshot?.task) +
1741
- tokenize(activeSnapshot?.summary) +
1742
- tokenize(activeSnapshot?.next_step) +
1743
- tokenize(sessionHandoff?.summary) +
1744
- tokenize(sessionHandoff?.nextAction) +
1745
- (sessionHandoff?.artifacts ?? []).reduce((acc, a) => acc + tokenize(a), 0) +
1746
- (sessionHandoff?.constraints ?? []).reduce((acc, c) => acc + tokenize(c), 0) +
1747
- tokenize(sessionHandoff?.evidence ? formatHandoffEvidenceLine(sessionHandoff.evidence) : null) +
1748
- tokenize(sessionHandoff?.outcome) +
1749
- tokenize(sessionHandoff?.targetRuntime) +
1750
- tokenize(sessionHandoff?.cardId) +
1751
- recentSessionEvents.reduce((acc, e) => acc + tokenize(e.content), 0);
1752
- }
1753
- const hasContinuity = activeSnapshot !== null
1754
- || sessionHandoff !== null
1755
- || recentSessionEvents.length > 0;
1756
- // TE0 token ledger: the memory text this recall hands back (results plus
1757
- // any continuity block), recorded once per query.
1758
- withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
1759
- tenantId,
1760
- sessionId: hostSessionId() ?? null,
1761
- surface: 'recall',
1762
- event: 'inject',
1763
- items: results.length,
1764
- tokens: results.reduce((acc, r) => acc + (r.tokens || 0), 0) + continuityTokens,
1721
+ }
1722
+ // Sections print ahead of the memories, so they are paid first, after the header; one that does not fit is dropped.
1723
+ const sectionBudget = budget - printedTokens(recallHeading(budget, budget, query));
1724
+ let left = sectionBudget;
1725
+ const pays = (tokens) => { if (tokens > left)
1726
+ return false; left -= tokens; return true; };
1727
+ if (activeSnapshot && !pays(printedTokens(snapshotText(activeSnapshot))))
1728
+ activeSnapshot = null;
1729
+ if (sessionHandoff && !pays(printedTokens(handoffText(sessionHandoff))))
1730
+ sessionHandoff = null;
1731
+ if (recentSessionEvents.length > 0 && !pays(printedTokens(sessionTrailText(recentSessionEvents))))
1732
+ recentSessionEvents = [];
1733
+ const continuityTokens = sectionBudget - left;
1734
+ const hasContinuity = activeSnapshot !== null || sessionHandoff !== null || recentSessionEvents.length > 0;
1735
+ // J3.2: the baserate hint depends on the query alone; its audit is pipeline-local (actor 'cli').
1736
+ const cmdPlanningFallacyOutput = computePlanningFallacyOutput(hippoRoot, tenantId, query, { actor: 'cli' });
1737
+ const planText = planningLine(cmdPlanningFallacyOutput);
1738
+ const showPlan = planText !== null && pays(printedTokens(`${planText}\n`));
1739
+ const cmdPlanningFallacyHint = showPlan ? cmdPlanningFallacyOutput.hint ?? null : null;
1740
+ const cmdPlanningFallacyWatching = showPlan ? cmdPlanningFallacyOutput.watching ?? null : null;
1741
+ // The first --min-results are kept whatever they cost (the documented exception); the rest skip and continue.
1742
+ const floor = minResults ?? 1;
1743
+ const fitted = fitBudget(results, left, floor, printCost);
1744
+ // Copies go after every cut, so a merged row the budget drops never hides its sources.
1745
+ const shown = (n) => dropHeldCopies(fitted.slice(0, n), (r) => r.entry);
1746
+ let kept = fitted.length;
1747
+ results = shown(kept);
1748
+ // J1, J2 and C5: each pipeline computes its hints over the list it returns, so they follow the list as it shrinks.
1749
+ // HIPPO_ANCHORING=off and HIPPO_AVAILABILITY=off skip the work entirely.
1750
+ const anchorRing = process.env.HIPPO_ANCHORING !== 'off' && sessionId
1751
+ ? getOrCreateRing(sessionRecallHistoryCli, buildSessionKey(tenantId, sessionId))
1752
+ : null;
1753
+ const queryHash = hashQueryText(query);
1754
+ const availabilityPool = process.env.HIPPO_AVAILABILITY !== 'off'
1755
+ ? [...localEntries, ...globalEntries].map((e) => ({ id: e.id, created: e.created }))
1756
+ : null;
1757
+ const hintsFor = (list, held) => {
1758
+ const anchoring = anchorRing ? detectAnchoring(snapshotRing(anchorRing), queryHash, list[0]?.entry.id ?? null) : null;
1759
+ const availability = availabilityPool
1760
+ ? detectAvailabilityBias({ topK: list.map((r) => ({ id: r.entry.id, created: r.entry.created })), pool: availabilityPool })
1761
+ : null;
1762
+ const summary = api.buildSuppressionSummary({
1763
+ // The published total includes graph-surfaced rows, so total == preRank + byBudget + returned holds for callers.
1764
+ totalCandidates: totalCandidatesCountCmd + graphAddedCountCmd,
1765
+ droppedPreRank: droppedPreRankCountCmd + held,
1766
+ droppedByBudget: Math.max(0, totalCandidatesCountCmd + graphAddedCountCmd - droppedPreRankCountCmd - held - list.length),
1767
+ summarySubstitutionsAdded: 0,
1768
+ freshTailAdded: 0,
1769
+ suppressedByInterference: anchoring?.reason === 'memory_dominance' ? 1 : 0, // a query_repeat is a re-ask, not competition
1770
+ });
1771
+ return { anchoring, availability, summary };
1772
+ };
1773
+ const printContinuity = () => {
1774
+ if (activeSnapshot)
1775
+ printActiveTaskSnapshot(activeSnapshot);
1776
+ if (sessionHandoff)
1777
+ printHandoff(sessionHandoff);
1778
+ if (recentSessionEvents.length > 0)
1779
+ printSessionEvents(recentSessionEvents);
1780
+ };
1781
+ const renderRecall = (list, h) => settleTokens((t) => captureConsole(() => {
1782
+ if (list.length === 0) {
1783
+ // The hint still prints when nothing matched, so the agent sees its track record.
1784
+ if (showPlan) {
1785
+ console.log(planText);
1786
+ console.log();
1787
+ }
1788
+ if (hasContinuity) {
1789
+ printContinuity();
1790
+ console.log(`(no memories matched "${query}")`);
1791
+ }
1792
+ else {
1793
+ console.log('No memories found for:', query);
1794
+ }
1795
+ return;
1796
+ }
1797
+ printContinuity();
1798
+ // Anchoring is the stronger pull, so it prints first; the Cutoff line sits above the list, where a reader sees it.
1799
+ if (h.anchoring) {
1800
+ console.log(`[anchored_on: ${h.anchoring.memoryId}] ${h.anchoring.summary}`);
1801
+ console.log();
1802
+ }
1803
+ if (h.availability) {
1804
+ console.log(`Availability bias (${h.availability.recentCount}/${h.availability.returnedCount} recent): ${h.availability.summary}`);
1805
+ console.log();
1806
+ }
1807
+ if (showPlan) {
1808
+ console.log(planText);
1809
+ console.log();
1810
+ }
1811
+ const cutoff = showWhy ? cutoffLine(list.length, h.summary) : null;
1812
+ if (cutoff) {
1813
+ console.log(cutoff);
1814
+ console.log();
1815
+ }
1816
+ console.log(recallHeading(list.length, t, query));
1817
+ for (const r of list)
1818
+ console.log(entryText(r));
1765
1819
  }));
1820
+ let hints = hintsFor(results, kept - results.length);
1821
+ let recallText = renderRecall(results, hints);
1822
+ // The hints, Cutoff line and header vary with the list, so the lowest-ranked entry goes until the whole block fits.
1823
+ while (kept > floor && estimateTokens(recallText) > budget) {
1824
+ kept--;
1825
+ results = shown(kept);
1826
+ hints = hintsFor(results, kept - results.length);
1827
+ recallText = renderRecall(results, hints);
1828
+ }
1829
+ const { anchoring: cmdAnchoringHint, availability: cmdAvailabilityHint, summary: cmdSuppressionSummary } = hints;
1830
+ if (anchorRing) {
1831
+ // Appended after every detect: anchoredOn feeds the cooldown for the next recall on this session.
1832
+ appendRecall(anchorRing, queryHash, results[0]?.entry.id ?? null, cmdAnchoringHint?.memoryId);
1833
+ }
1834
+ else if (process.env.HIPPO_ANCHORING !== 'off') {
1835
+ // SHA-256/16 per the recall-audit convention; hashQueryText is FNV-1a and brute-forceable on short queries.
1836
+ emitCliAudit(hippoRoot, 'recall_anchor_skipped_no_session', undefined, {
1837
+ query_hash: createHash('sha256').update(query).digest('hex').slice(0, 16),
1838
+ query_length: query.length,
1839
+ });
1840
+ }
1841
+ if (cmdAnchoringHint?.reason === 'memory_dominance') {
1842
+ emitCliAudit(hippoRoot, 'recall_anchor_detected_memory_dominance', cmdAnchoringHint.memoryId, {
1843
+ memory_id: cmdAnchoringHint.memoryId,
1844
+ query_count: cmdAnchoringHint.queryCount ?? null,
1845
+ });
1846
+ }
1847
+ else if (cmdAnchoringHint?.reason === 'query_repeat') {
1848
+ emitCliAudit(hippoRoot, 'recall_anchor_detected_query_repeat', cmdAnchoringHint.memoryId, {
1849
+ memory_id: cmdAnchoringHint.memoryId,
1850
+ });
1851
+ }
1852
+ if (cmdAvailabilityHint) {
1853
+ emitCliAudit(hippoRoot, 'recall_availability_detected', undefined, {
1854
+ recent_fraction: cmdAvailabilityHint.recentFraction,
1855
+ older_passed_over: cmdAvailabilityHint.olderCandidatesPassedOver,
1856
+ returned_count: cmdAvailabilityHint.returnedCount,
1857
+ });
1858
+ }
1859
+ // A5 audit: one 'recall' event per query, before the early-empty return, in every participating store.
1860
+ const recallMetadata = {
1861
+ query: query.slice(0, 200),
1862
+ results: results.length,
1863
+ };
1864
+ emitCliAudit(hippoRoot, 'recall', undefined, recallMetadata);
1865
+ if (isInitialized(globalRoot) && globalRoot !== hippoRoot) {
1866
+ emitCliAudit(globalRoot, 'recall', undefined, recallMetadata);
1867
+ }
1868
+ // TE0 token ledger: books the block this recall prints, on whichever exit it takes.
1869
+ const emit = (text) => {
1870
+ withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
1871
+ tenantId,
1872
+ sessionId: hostSessionId() ?? null,
1873
+ surface: 'recall',
1874
+ event: 'inject',
1875
+ items: results.length,
1876
+ tokens: estimateTokens(text),
1877
+ }));
1878
+ console.log(text);
1879
+ };
1766
1880
  if (results.length === 0) {
1767
1881
  // LC1 F1 structural fix (docs/plans/2026-08-02-lc1-recall-trace-persistence.md):
1768
1882
  // trace the zero-result recall too (result_count 0, no result rows) so
@@ -1807,55 +1921,13 @@ async function cmdRecall(hippoRoot, query, flags) {
1807
1921
  };
1808
1922
  out.continuityTokens = continuityTokens;
1809
1923
  }
1810
- console.log(JSON.stringify(out));
1924
+ emit(JSON.stringify(out));
1811
1925
  return;
1812
1926
  }
1813
- // v0.33 / J1 — render anchoring hint above planning hint (anchoring
1814
- // is the stronger cognitive-pull warning so it gets first position).
1815
- if (cmdAnchoringHint) {
1816
- console.log(`[anchored_on: ${cmdAnchoringHint.memoryId}] ${cmdAnchoringHint.summary}`);
1817
- }
1818
- // v1.13.x / J2 — render availability/recency-bias hint below anchoring and
1819
- // above the planning-fallacy hint. Soft warning; absent (env disabled or no
1820
- // bias detected) is silent. Null on this zero-result branch anyway since
1821
- // topK < minReturned, so this is effectively a no-op here; wired for parity.
1822
- if (cmdAvailabilityHint) {
1823
- console.log(`Availability bias (${cmdAvailabilityHint.recentCount}/${cmdAvailabilityHint.returnedCount} recent): ${cmdAvailabilityHint.summary}`);
1824
- }
1825
- // v0.32 / J3.2 — render hint BEFORE the no-memories message so the
1826
- // calling agent sees its track record even when the query missed
1827
- // every memory. Same single-line shape + JSON.stringify-safe phrase
1828
- // as the populated-results path below.
1829
- if (cmdPlanningFallacyHint) {
1830
- const safePhrase = JSON.stringify(cmdPlanningFallacyHint.detectedPhrase);
1831
- console.log(`Planning fallacy hint (class: ${cmdPlanningFallacyHint.classTag}): ${cmdPlanningFallacyHint.baserateSummary} [detected: ${safePhrase}]`);
1832
- console.log();
1833
- }
1834
- else if (cmdPlanningFallacyWatching) {
1835
- // v1.13.4: render the watching variant when the regex matched but no
1836
- // baserate could be produced. Suggestion text directs the user to
1837
- // tag a prediction class so future queries can produce a baserate.
1838
- const safePhrase = JSON.stringify(cmdPlanningFallacyWatching.detectedPhrase);
1839
- console.log(`Planning fallacy: watching this query (reason: ${cmdPlanningFallacyWatching.reason}). ${cmdPlanningFallacyWatching.suggestion} [detected: ${safePhrase}]`);
1840
- console.log();
1841
- }
1842
- if (hasContinuity) {
1843
- // Print continuity even when no memories matched. The resume packet
1844
- // is the whole point of `--continuity` and must not be dropped here.
1845
- if (activeSnapshot)
1846
- printActiveTaskSnapshot(activeSnapshot);
1847
- if (sessionHandoff)
1848
- printHandoff(sessionHandoff);
1849
- if (recentSessionEvents.length > 0)
1850
- printSessionEvents(recentSessionEvents);
1851
- console.log(`(no memories matched "${query}")`);
1852
- return;
1853
- }
1854
- console.log('No memories found for:', query);
1927
+ emit(recallText);
1855
1928
  return;
1856
1929
  }
1857
1930
  const retrievedIds = results.map((r) => r.entry.id);
1858
- const localIndex = loadIndex(hippoRoot);
1859
1931
  const strengthenedHere = strengthenRetrieved(hippoRoot, retrievedIds);
1860
1932
  if (isInitialized(globalRoot))
1861
1933
  strengthenRetrieved(globalRoot, retrievedIds.filter((id) => !strengthenedHere.has(id)));
@@ -1944,131 +2016,10 @@ async function cmdRecall(hippoRoot, query, flags) {
1944
2016
  };
1945
2017
  jsonOut.continuityTokens = continuityTokens;
1946
2018
  }
1947
- console.log(JSON.stringify(jsonOut));
2019
+ emit(JSON.stringify(jsonOut));
1948
2020
  return;
1949
2021
  }
1950
- const totalTokens = results.reduce((sum, r) => sum + r.tokens, 0);
1951
- if (includeContinuity && hasContinuity) {
1952
- if (activeSnapshot)
1953
- printActiveTaskSnapshot(activeSnapshot);
1954
- if (sessionHandoff)
1955
- printHandoff(sessionHandoff);
1956
- if (recentSessionEvents.length > 0)
1957
- printSessionEvents(recentSessionEvents);
1958
- }
1959
- // v0.33 / J1 — render anchoring hint above planning-fallacy hint
1960
- // (anchoring is the stronger cognitive-pull warning so it gets first
1961
- // position). Hint absent (env disabled, no sessionId, or no R1/R2)
1962
- // is silent.
1963
- if (cmdAnchoringHint) {
1964
- console.log(`[anchored_on: ${cmdAnchoringHint.memoryId}] ${cmdAnchoringHint.summary}`);
1965
- console.log();
1966
- }
1967
- // v1.13.x / J2 — render availability/recency-bias hint below anchoring and
1968
- // above the planning-fallacy hint. Soft warning; absent (env disabled or no
1969
- // bias detected) is silent.
1970
- if (cmdAvailabilityHint) {
1971
- console.log(`Availability bias (${cmdAvailabilityHint.recentCount}/${cmdAvailabilityHint.returnedCount} recent): ${cmdAvailabilityHint.summary}`);
1972
- console.log();
1973
- }
1974
- // v0.32 / J3.2 — render planning-fallacy hint ABOVE the result list so
1975
- // the agent sees its track record before scrolling. Hint absent (env
1976
- // disabled or no forward-claim match) is silent. detectedPhrase is
1977
- // sanitised against control chars and ASCII quotes via JSON.stringify
1978
- // to head off rendering ambiguity when a regex match contains quotes
1979
- // or parens (plan-eng-critic round 2 LOW).
1980
- if (cmdPlanningFallacyHint) {
1981
- const safePhrase = JSON.stringify(cmdPlanningFallacyHint.detectedPhrase);
1982
- console.log(`Planning fallacy hint (class: ${cmdPlanningFallacyHint.classTag}): ${cmdPlanningFallacyHint.baserateSummary} [detected: ${safePhrase}]`);
1983
- console.log();
1984
- }
1985
- else if (cmdPlanningFallacyWatching) {
1986
- // v1.13.4: render the watching variant when the regex matched but no
1987
- // baserate could be produced (no_class_match / tiebreak). Suggestion
1988
- // text directs the user toward an action that would unblock the
1989
- // hint next time (typically: tag a prediction class).
1990
- const safePhrase = JSON.stringify(cmdPlanningFallacyWatching.detectedPhrase);
1991
- console.log(`Planning fallacy: watching this query (reason: ${cmdPlanningFallacyWatching.reason}). ${cmdPlanningFallacyWatching.suggestion} [detected: ${safePhrase}]`);
1992
- console.log();
1993
- }
1994
- // v1.13.3 / C5 follow-up — Cutoff line ABOVE the result list (was a
1995
- // "WYSIATI:" line BELOW the result list in v1.12.13-v1.13.2). Dogfood
1996
- // proof at docs/dogfood/2026-05-27-track-j-warnings.md: a fresh sub-agent
1997
- // reading the v1.13.2 bottom-placed line ignored it entirely and
1998
- // summarised the visible memories as if the dropped pool didn't exist
1999
- // (the exact WYSIATI failure mode C5 is supposed to flag). Top placement
2000
- // + plain English ("Cutoff:" not "WYSIATI:") closes the read gap.
2001
- if (showWhy) {
2002
- const s = cmdSuppressionSummary;
2003
- const clauses = [];
2004
- // "dropped to fit limit" pointed at the wrong control: the residual covers
2005
- // search-ranking and token-budget drops too, and fires even when --limit
2006
- // was never passed. Measured: `recall --budget 20 --why` printed "39
2007
- // dropped to fit limit" with no --limit flag in the command at all.
2008
- if (s.droppedByBudget > 0)
2009
- clauses.push(`${s.droppedByBudget} not shown (rank, budget or limit)`);
2010
- if (s.droppedPreRank > 0)
2011
- clauses.push(`${s.droppedPreRank} filtered pre-rank`);
2012
- if (s.summarySubstitutionsAdded > 0)
2013
- clauses.push(`${s.summarySubstitutionsAdded} summary substitutions added`);
2014
- if (s.freshTailAdded > 0)
2015
- clauses.push(`${s.freshTailAdded} fresh-tail added`);
2016
- if (s.suppressedByInterference > 0)
2017
- clauses.push(`${s.suppressedByInterference} suppressed by interference`);
2018
- if (clauses.length > 0) {
2019
- console.log(`Cutoff: showing ${results.length} of ${s.totalCandidates} candidates; ${clauses.join('; ')}.`);
2020
- console.log();
2021
- }
2022
- }
2023
- console.log(`Found ${results.length} memories (${totalTokens} tokens) for: "${query}"\n`);
2024
- for (const r of results) {
2025
- const e = r.entry;
2026
- const label = confidenceLabel(e);
2027
- const confLabel = label.warn ? `[${label.text}] \u26A0\uFE0F` : `[${label.text}]`;
2028
- const strengthBar = '\u2588'.repeat(Math.round(e.strength * 10)) + '\u2591'.repeat(10 - Math.round(e.strength * 10));
2029
- const isGlobal = isInitialized(globalRoot) && !localIndex.entries[e.id];
2030
- const globalMark = isGlobal ? ' [global]' : '';
2031
- const supersededMark = e.superseded_by ? ' [superseded]' : '';
2032
- const graphMark = r.graphVia ? ` [graph: ${r.graphVia.hops}hop ${r.graphVia.relType}]` : '';
2033
- const sourceMark = isGlobal ? ' [global]' : ' [local]';
2034
- console.log(`--- ${e.id} [${e.layer}] ${confLabel}${globalMark}${supersededMark}${graphMark} score=${fmt(r.score, 3)} strength=${fmt(e.strength)}`);
2035
- console.log(` [${strengthBar}] tags: ${e.tags.join(', ') || 'none'} | retrieved: ${e.retrieval_count}x`);
2036
- if (showWhy) {
2037
- const explanation = explainMatch(query, r);
2038
- console.log(` source:${sourceMark} | layer: [${e.layer}] | confidence: [${label.text}]`);
2039
- console.log(` reason: ${explanation.reason}`);
2040
- if (explanation.envelope) {
2041
- const env = explanation.envelope;
2042
- console.log(` kind: ${env.kind}`);
2043
- if (env.scope)
2044
- console.log(` scope: ${env.scope}`);
2045
- if (env.owner)
2046
- console.log(` owner: ${env.owner}`);
2047
- if (env.artifact_ref)
2048
- console.log(` artifact_ref: ${env.artifact_ref}`);
2049
- if (env.session_id)
2050
- console.log(` session_id: ${env.session_id}`);
2051
- console.log(` confidence: ${env.confidence}`);
2052
- }
2053
- // A7 recall-trace: render the ordered lifecycle re-ranking chain, e.g.
2054
- // "ranking: base 0.420 -> interference x0.3 -> 0.126 -> goal-boost x1.5 -> 0.189".
2055
- if (r.rerankTrace && r.rerankTrace.length > 0) {
2056
- const parts = [`base ${fmt(r.rerankTrace[0].scoreBefore, 3)}`];
2057
- for (const step of r.rerankTrace) {
2058
- const mult = step.multiplier !== undefined ? ` x${fmt(step.multiplier, 2)}` : '';
2059
- parts.push(`${step.stage}${mult}`);
2060
- parts.push(fmt(step.scoreAfter, 3));
2061
- }
2062
- console.log(` ranking: ${parts.join(' -> ')}`);
2063
- }
2064
- }
2065
- console.log();
2066
- console.log(e.content);
2067
- console.log();
2068
- }
2069
- // v1.12.13 / C5 -> v1.13.3 follow-up: the WYSIATI bottom block was
2070
- // moved above the result list (see comment near the Cutoff render
2071
- // above). Function ends here.
2022
+ emit(recallText);
2072
2023
  }
2073
2024
  async function cmdExplain(hippoRoot, query, flags) {
2074
2025
  requireInit(hippoRoot);
@@ -2155,11 +2106,17 @@ async function cmdExplain(hippoRoot, query, flags) {
2155
2106
  : config.search.localBump;
2156
2107
  // explainExplicitScope hoisted above the candidate loads (v1.25.0).
2157
2108
  const explainActiveScope = explainExplicitScope || detectScope();
2109
+ // Priced as recall prints each result, so explain returns what recall's engines would.
2110
+ const explainIndex = loadIndex(hippoRoot);
2111
+ const explainGlobalOn = isInitialized(globalRoot);
2112
+ const cost = (r) => printedTokens(recallEntryText(r, query, false, explainGlobalOn && !explainIndex.entries[r.entry.id]));
2113
+ const entryBudget = Math.max(0, budget - printedTokens(recallHeading(budget, budget, query)));
2158
2114
  let results;
2159
2115
  let modeUsed;
2160
2116
  if (usePhysics && !hasGlobal) {
2161
2117
  results = await physicsSearch(query, explainLocalEntries, {
2162
- budget,
2118
+ budget: entryBudget,
2119
+ cost,
2163
2120
  hippoRoot,
2164
2121
  physicsConfig: config.physics,
2165
2122
  explain: true,
@@ -2169,7 +2126,7 @@ async function cmdExplain(hippoRoot, query, flags) {
2169
2126
  }
2170
2127
  else if (hasGlobal) {
2171
2128
  results = await searchBothHybrid(query, hippoRoot, globalRoot, {
2172
- budget, explain: true, mmr: mmrEnabled, mmrLambda, localBump, scope: explainActiveScope,
2129
+ budget: entryBudget, cost, explain: true, mmr: mmrEnabled, mmrLambda, localBump, scope: explainActiveScope,
2173
2130
  includeSuperseded: explainIncludeSuperseded, asOf: explainAsOf, tenantId,
2174
2131
  recallScope: explainExplicitScope
2175
2132
  ? { requested: explainExplicitScope, additive: true }
@@ -2179,7 +2136,7 @@ async function cmdExplain(hippoRoot, query, flags) {
2179
2136
  }
2180
2137
  else {
2181
2138
  results = await hybridSearch(query, explainLocalEntries, {
2182
- budget, hippoRoot, explain: true, mmr: mmrEnabled, mmrLambda, scope: explainActiveScope,
2139
+ budget: entryBudget, cost, hippoRoot, explain: true, mmr: mmrEnabled, mmrLambda, scope: explainActiveScope,
2183
2140
  includeSuperseded: explainIncludeSuperseded, asOf: explainAsOf,
2184
2141
  });
2185
2142
  modeUsed = 'hybrid';
@@ -2187,6 +2144,7 @@ async function cmdExplain(hippoRoot, query, flags) {
2187
2144
  if (limit < results.length) {
2188
2145
  results = results.slice(0, limit);
2189
2146
  }
2147
+ results = dropHeldCopies(results, (r) => r.entry);
2190
2148
  const candidates = explainLocalEntries.length + explainGlobalEntries.length;
2191
2149
  if (asJson) {
2192
2150
  const output = results.map((r, rank) => ({
@@ -2313,7 +2271,9 @@ async function cmdEval(hippoRoot, corpusPath, flags) {
2313
2271
  try {
2314
2272
  baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
2315
2273
  }
2316
- catch { }
2274
+ catch {
2275
+ console.error(`Warning: eval baseline ${baselinePath} is unreadable; running without it.`);
2276
+ }
2317
2277
  }
2318
2278
  const result = await runFeatureEval(version);
2319
2279
  if (asJson) {
@@ -2645,28 +2605,46 @@ async function cmdRefine(hippoRoot, flags) {
2645
2605
  }
2646
2606
  }
2647
2607
  }
2648
- /**
2649
- * Scan for Claude Code MEMORY.md files and import new entries into hippo.
2650
- * Looks in ~/.claude/projects/<project>/memory/ for .md files with YAML frontmatter.
2651
- */
2608
+ /** Claude Code's auto memory folder names for a project: its checkout, which subfolders share, or the folder itself outside a repository. */
2609
+ function claudeMemoryFolderNames(projectRoot) {
2610
+ const roots = [projectRoot, realpathOrResolve(projectRoot)];
2611
+ const git = spawnSync('git', ['rev-parse', '--path-format=absolute', '--show-toplevel', '--absolute-git-dir', '--git-common-dir'], { cwd: projectRoot, encoding: 'utf8', timeout: 10000, windowsHide: true });
2612
+ if (git.status === 0) {
2613
+ const [top, gitDir, common] = git.stdout.trim().split(/\r?\n/);
2614
+ roots.push(claudeCheckoutRoot(top, gitDir, common));
2615
+ }
2616
+ return new Set(roots.map((root) => (process.platform === 'win32' ? claudeFolderName(root).toLowerCase() : claudeFolderName(root))));
2617
+ }
2618
+ /** Claude Code's rule: a linked worktree shares its main checkout's folder, or the git folder's when that sits outside a checkout (a bare repository, or --separate-git-dir); any other checkout, a submodule included, keeps its own. */
2619
+ function claudeCheckoutRoot(top, gitDir, common) {
2620
+ if (gitDir === common)
2621
+ return top;
2622
+ if (path.basename(common) === '.git')
2623
+ return path.dirname(common);
2624
+ return fs.existsSync(path.join(common, '.git')) ? top : common;
2625
+ }
2626
+ /** Claude Code's folder name for a path: non-alphanumerics made '-', and a name over 200 characters cut to 200 plus a base-36 hash of the whole path. */
2627
+ function claudeFolderName(root) {
2628
+ const full = path.resolve(root);
2629
+ const name = full.replace(/[^a-zA-Z0-9]/g, '-');
2630
+ if (name.length <= 200)
2631
+ return name;
2632
+ let hash = 0;
2633
+ for (let i = 0; i < full.length; i++)
2634
+ hash = ((hash << 5) - hash + full.charCodeAt(i)) | 0;
2635
+ return `${name.slice(0, 200)}-${Math.abs(hash).toString(36)}`;
2636
+ }
2637
+ /** Import new entries from the Claude Code auto memory of the store's project, the frontmatter .md files in ~/.claude/projects/<project>/memory/. */
2652
2638
  export function learnFromMemoryMd(hippoRoot, homeDir = os.homedir()) {
2653
- const home = homeDir;
2654
- const memoryDirs = [];
2655
- // Claude Code project memories
2656
- const claudeProjectsDir = path.join(home, '.claude', 'projects');
2657
- if (fs.existsSync(claudeProjectsDir)) {
2658
- try {
2659
- for (const project of fs.readdirSync(claudeProjectsDir)) {
2660
- const memDir = path.join(claudeProjectsDir, project, 'memory');
2661
- if (fs.existsSync(memDir))
2662
- memoryDirs.push(memDir);
2663
- }
2664
- }
2665
- catch { /* permission denied */ }
2666
- }
2639
+ // Only this project's folder, because every entry written here counts as this project's memory.
2640
+ // SHORTCUT: an autoMemoryDirectory or CLAUDE_CODE_PROJECT_DIR_NAME setting imports nothing; read Claude's settings if users ask.
2641
+ const claudeProjectsDir = path.join(homeDir, '.claude', 'projects');
2642
+ const memoryDirs = [...claudeMemoryFolderNames(path.dirname(hippoRoot))]
2643
+ .map((folder) => path.join(claudeProjectsDir, folder, 'memory'))
2644
+ .filter((memDir) => fs.existsSync(memDir));
2667
2645
  if (memoryDirs.length === 0)
2668
2646
  return 0;
2669
- const existing = loadAllEntries(hippoRoot, resolveTenantId({}));
2647
+ const keys = storedTextKeys(loadAllEntries(hippoRoot, resolveTenantId({})));
2670
2648
  const baseHalfLifeDays = loadConfig(hippoRoot).defaultHalfLifeDays;
2671
2649
  let imported = 0;
2672
2650
  let skippedSecret = 0;
@@ -2698,12 +2676,8 @@ export function learnFromMemoryMd(hippoRoot, homeDir = os.homedir()) {
2698
2676
  skippedSecret++;
2699
2677
  continue;
2700
2678
  }
2701
- // Dedup: check if substantially similar content already exists
2702
- const isDup = existing.some(e => {
2703
- const overlap = textOverlap(content.slice(0, 200), e.content.slice(0, 200));
2704
- return overlap > 0.6;
2705
- });
2706
- if (isDup)
2679
+ // Dedup: skip only when the same text is already stored
2680
+ if (keys.has(duplicateKey(content)))
2707
2681
  continue;
2708
2682
  const entry = createMemory(content, {
2709
2683
  layer: Layer.Episodic,
@@ -2723,7 +2697,7 @@ export function learnFromMemoryMd(hippoRoot, homeDir = os.homedir()) {
2723
2697
  }
2724
2698
  throw err;
2725
2699
  }
2726
- existing.push(entry); // prevent self-dedup within batch
2700
+ keys.add(duplicateKey(content)); // prevent self-dedup within batch
2727
2701
  imported++;
2728
2702
  }
2729
2703
  }
@@ -2740,10 +2714,12 @@ export function learnFromMemoryMd(hippoRoot, homeDir = os.homedir()) {
2740
2714
  function cmdDedup(hippoRoot, flags) {
2741
2715
  requireInit(hippoRoot);
2742
2716
  const dryRun = Boolean(flags['dry-run']);
2743
- const threshold = parseFloat(String(flags['threshold'] ?? '0.7'));
2717
+ if (flags['threshold'] !== undefined) {
2718
+ console.error('hippo dedup: --threshold is ignored; a duplicate is the same text apart from spacing.');
2719
+ }
2744
2720
  const entries = loadAllEntries(hippoRoot);
2745
- console.log(`Scanning ${entries.length} memories for duplicates (>=${(threshold * 100).toFixed(0)}% text overlap)${dryRun ? ' (dry run)' : ''}...\n`);
2746
- const result = deduplicateStore(hippoRoot, { threshold, dryRun });
2721
+ console.log(`Scanning ${entries.length} memories for duplicates (same text apart from spacing)${dryRun ? ' (dry run)' : ''}...\n`);
2722
+ const result = deduplicateStore(hippoRoot, { dryRun });
2747
2723
  if (result.removed === 0) {
2748
2724
  console.log('No duplicates found.');
2749
2725
  return;
@@ -2895,10 +2871,10 @@ export function renderSleepResult(result) {
2895
2871
  }
2896
2872
  async function cmdSleepCore(hippoRoot, flags) {
2897
2873
  requireInit(hippoRoot);
2898
- // Phase 1: Auto-learn from git + MEMORY.md (CLI-only, uses process.cwd() / os.homedir()).
2874
+ // Phase 1: Auto-learn from git + Claude Code auto memory (CLI-only, uses process.cwd() / os.homedir()).
2899
2875
  // Stays in cli.ts; api.sleep covers Phase 2-6 only.
2900
2876
  if (!flags['no-learn'] && flags['dry-run']) {
2901
- console.log('Dry run: skipped learning from git commits and MEMORY.md files.');
2877
+ console.log("Dry run: skipped learning from git commits and this project's Claude Code auto memory.");
2902
2878
  }
2903
2879
  else if (!flags['no-learn']) {
2904
2880
  const config = loadConfig(hippoRoot);
@@ -2916,10 +2892,9 @@ async function cmdSleepCore(hippoRoot, flags) {
2916
2892
  console.error(`Churn-staleness check failed for ${root}: ${result.error}`);
2917
2893
  }
2918
2894
  }
2919
- // Also learn from Claude Code MEMORY.md files
2920
2895
  const memImported = learnFromMemoryMd(hippoRoot);
2921
2896
  if (memImported > 0)
2922
- console.log(`Imported ${memImported} memories from Claude Code MEMORY.md files.`);
2897
+ console.log(`Imported ${memImported} memories from this project's Claude Code auto memory.`);
2923
2898
  }
2924
2899
  // Phase 2-6: Pure-storage pipeline (consolidate + dedup + audit + share + ambient).
2925
2900
  const ctx = {
@@ -2933,12 +2908,8 @@ async function cmdSleepCore(hippoRoot, flags) {
2933
2908
  });
2934
2909
  renderSleepResult(result);
2935
2910
  }
2936
- /**
2937
- * Print the contents of the SessionEnd sleep log to stdout, then clear it.
2938
- * Called from SessionStart hooks so the user sees the previous session's
2939
- * consolidation output (SessionEnd hook output is invisible because the TUI
2940
- * is tearing down when it runs).
2941
- */
2911
+ /** Prints the SessionEnd sleep log, then clears it. Stderr, because Claude Code adds
2912
+ * SessionStart stdout to the model's context and this log is for the user. */
2942
2913
  function cmdLastSleep(flags) {
2943
2914
  const logPath = typeof flags['path'] === 'string'
2944
2915
  ? flags['path']
@@ -2953,11 +2924,11 @@ function cmdLastSleep(flags) {
2953
2924
  return;
2954
2925
  }
2955
2926
  if (content.trim().length > 0) {
2956
- console.log('=== Previous session hippo consolidation ===');
2957
- process.stdout.write(content);
2927
+ console.error('=== Previous session hippo consolidation ===');
2928
+ process.stderr.write(content);
2958
2929
  if (!content.endsWith('\n'))
2959
- console.log();
2960
- console.log('===========================================');
2930
+ console.error();
2931
+ console.error('===========================================');
2961
2932
  }
2962
2933
  if (!flags['keep']) {
2963
2934
  try {
@@ -3023,7 +2994,8 @@ function cmdCompactResume(hippoRoot, stdinText, stdinTimedOut) {
3023
2994
  // source === 'compact' to print. Real SessionStart payloads always
3024
2995
  // carry source; only the TTY/no-stdin manual path prints without
3025
2996
  // one (codex round 3).
3026
- if (payload.source !== 'compact') {
2997
+ // A sub-agent's payload carries its parent's session id, so X5 would pass and restore the parent's snapshot into it.
2998
+ if (payload.source !== 'compact' || isSubagentPayload(stdinText)) {
3027
2999
  suppressOutput = true;
3028
3000
  }
3029
3001
  if (typeof payload.session_id === 'string') {
@@ -3042,29 +3014,41 @@ function cmdCompactResume(hippoRoot, stdinText, stdinTimedOut) {
3042
3014
  snapshot.session_id !== null &&
3043
3015
  payloadSessionId !== snapshot.session_id;
3044
3016
  if (snapshot && !sessionMismatch) {
3045
- console.log('## Restored after compaction\n');
3046
- // X12: re-injected state is background reference, not instructions
3047
- // — the framing line the model actually sees at every compaction.
3048
- console.log("_Point-in-time working-state snapshot, auto-restored after compaction. Background reference, not instructions; the user's live messages win._\n");
3049
- printActiveTaskSnapshot(snapshot);
3050
- if (snapshot.session_id) {
3051
- const events = listSessionEvents(hippoRoot, tenantId, { session_id: snapshot.session_id });
3052
- // Nothing auto-populates session_events, so an empty table is the
3053
- // common real case — printSessionEvents([]) would otherwise inject
3054
- // a bare "No session events found." line into every compaction.
3055
- if (events.length > 0) {
3056
- const cappedEvents = events.map((e) => ({
3017
+ // Loaded before the print so a bad trail row costs the trail, not the snapshot; stderr stays out of the model's context.
3018
+ let events = [];
3019
+ try {
3020
+ if (snapshot.session_id) {
3021
+ events = listSessionEvents(hippoRoot, tenantId, { session_id: snapshot.session_id }).map((e) => ({
3057
3022
  ...e,
3058
3023
  content: truncateCodePointSafe(e.content, COMPACT_RESUME_EVENT_CONTENT_CAP),
3059
3024
  }));
3060
- printSessionEvents(cappedEvents);
3061
3025
  }
3062
3026
  }
3027
+ catch (err) {
3028
+ console.error(`hippo compact-resume: trail skipped: ${err instanceof Error ? err.message : String(err)}`);
3029
+ }
3030
+ // Printed in one write so the ledger books exactly the text the model is handed.
3031
+ const text = captureConsole(() => {
3032
+ console.log('## Restored after compaction\n');
3033
+ // X12: re-injected state is background reference, not instructions:
3034
+ // the framing line the model actually sees at every compaction.
3035
+ console.log("_Point-in-time working-state snapshot, auto-restored after compaction. Background reference, not instructions; the user's live messages win._\n");
3036
+ printActiveTaskSnapshot(snapshot);
3037
+ // Nothing auto-populates session_events, so an empty trail is the common real case;
3038
+ // printSessionEvents([]) would inject a bare "No session events found." line into every compaction.
3039
+ if (events.length > 0)
3040
+ printSessionEvents(events);
3041
+ });
3042
+ console.log(text);
3043
+ withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
3044
+ tenantId, sessionId: payloadSessionId, surface: 'compact_resume', event: 'inject', items: 1, tokens: estimateTokens(text),
3045
+ }));
3063
3046
  }
3064
3047
  }
3065
3048
  }
3066
- catch {
3067
- // Degrade to empty stdout on any store error — never crash SessionStart.
3049
+ catch (err) {
3050
+ // Empty stdout on any store error, never a crashed SessionStart; the reason goes to stderr, which the model never sees.
3051
+ console.error(`hippo compact-resume: skipped: ${err instanceof Error ? err.message : String(err)}`);
3068
3052
  }
3069
3053
  process.exit(0);
3070
3054
  }
@@ -3124,7 +3108,7 @@ async function cmdSessionEnd(hippoRoot, flags) {
3124
3108
  }
3125
3109
  }
3126
3110
  /**
3127
- * Detached worker that runs sleep, then capture. Invoked via the internal
3111
+ * Detached worker that counts re-reads, runs sleep, then capture. Invoked via the internal
3128
3112
  * `__session-end-worker` subcommand (not user-facing). Failures in one stage
3129
3113
  * do not block the other.
3130
3114
  */
@@ -3134,7 +3118,7 @@ function collectHandoffEvidence(cwd, testStatus) {
3134
3118
  let gitRef = null;
3135
3119
  try {
3136
3120
  gitRef = execFileSync('git', ['rev-parse', 'HEAD'], {
3137
- cwd, encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
3121
+ cwd, encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
3138
3122
  }).trim() || null;
3139
3123
  }
3140
3124
  catch {
@@ -3143,7 +3127,7 @@ function collectHandoffEvidence(cwd, testStatus) {
3143
3127
  let dirtyTree = null;
3144
3128
  try {
3145
3129
  const status = execFileSync('git', ['status', '--porcelain'], {
3146
- cwd, encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
3130
+ cwd, encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
3147
3131
  });
3148
3132
  dirtyTree = status.trim().length > 0;
3149
3133
  }
@@ -3153,34 +3137,66 @@ function collectHandoffEvidence(cwd, testStatus) {
3153
3137
  return { gitRef, dirtyTree, testStatus };
3154
3138
  }
3155
3139
  async function cmdSessionEndWorker(hippoRoot, flags) {
3156
- try {
3157
- await cmdSleep(hippoRoot, flags);
3140
+ const transcriptPath = typeof flags['transcript'] === 'string' ? flags['transcript'] : undefined;
3141
+ const closeLogFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : null;
3142
+ const closeSessionId = typeof flags['session-id'] === 'string' ? flags['session-id'] : null;
3143
+ const rereadLog = await bookSessionRereads(hippoRoot, transcriptPath, closeSessionId)
3144
+ .catch((err) => [`re-read count failed: ${err instanceof Error ? err.message : String(err)}`]);
3145
+ // Sleep starts the log file afresh, so the lines go in after it; on exit too, in case sleep exits the process.
3146
+ const flushRereadLog = () => { for (const line of rereadLog.splice(0))
3147
+ appendSessionEndCloseLog(closeLogFile, line); };
3148
+ process.once('exit', flushRereadLog);
3149
+ // Like the other hooks: project store, else global; a folder with neither must not get one made.
3150
+ const store = hookStoreRoot(hippoRoot);
3151
+ if (!isInitialized(store)) {
3152
+ appendSessionEndCloseLog(closeLogFile, 'skip: no hippo store for this folder or globally', { startFresh: true });
3153
+ flushRereadLog();
3154
+ return;
3155
+ }
3156
+ // Sleeping the global store from here would learn this folder's git commits into it; it has its own daily sleep.
3157
+ if (isInitialized(hippoRoot)) {
3158
+ try {
3159
+ await cmdSleep(hippoRoot, flags);
3160
+ }
3161
+ catch {
3162
+ // sleep errors are already tee'd to the log file via cmdSleep's
3163
+ // `[hippo] sleep failed: ...` line. Continue to capture regardless.
3164
+ }
3158
3165
  }
3159
- catch {
3160
- // sleep errors are already tee'd to the log file via cmdSleep's
3161
- // `[hippo] sleep failed: ...` line. Continue to capture regardless.
3166
+ else {
3167
+ appendSessionEndCloseLog(closeLogFile, 'skip sleep: this folder has no store of its own', { startFresh: true });
3162
3168
  }
3169
+ flushRereadLog();
3170
+ const digestLog = (message) => appendSessionEndCloseLog(closeLogFile, message);
3171
+ const scan = transcriptPath ? readSessionScan(transcriptPath, digestLog) : null;
3163
3172
  try {
3164
3173
  const logFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined;
3165
- const transcriptPath = typeof flags['transcript'] === 'string' ? flags['transcript'] : undefined;
3166
3174
  // With no stdin of its own, capture would read this as a manual run and scan every project.
3167
3175
  if (!transcriptPath) {
3168
3176
  appendSessionEndCloseLog(logFile ?? null, 'skip capture: no transcript for this session');
3169
3177
  }
3170
3178
  else {
3171
- cmdCapture(hippoRoot, {
3179
+ cmdCapture(store, {
3172
3180
  source: 'last-session',
3173
3181
  transcriptPath,
3174
3182
  logFile,
3175
3183
  dryRun: false,
3176
3184
  global: false,
3177
3185
  tenantId: resolveTenantId({}),
3186
+ // In the global store, rows would otherwise read as user-global and show up in every project.
3187
+ originProject: store === hippoRoot ? undefined : deriveOriginProject(process.cwd()),
3188
+ sessionTurns: scan?.turns,
3178
3189
  });
3179
3190
  }
3180
3191
  }
3181
3192
  catch {
3182
3193
  // Same treatment — the failure line is already in the log.
3183
3194
  }
3195
+ recordSessionDigest(hippoRoot, scan, {
3196
+ key: closeSessionId || path.basename(transcriptPath ?? '', '.jsonl'),
3197
+ tenantId: resolveTenantId({}),
3198
+ log: digestLog,
3199
+ });
3184
3200
  // DF1 T3: close the ending session's own active task snapshot AFTER
3185
3201
  // sleep+capture complete — neither producer (runPreCompact,
3186
3202
  // `hippo snapshot save`) runs inside session-end, so this can never
@@ -3189,20 +3205,23 @@ async function cmdSessionEndWorker(hippoRoot, flags) {
3189
3205
  // clause). Absent session id -> no-op plus one log line; session-end is
3190
3206
  // not guaranteed to fire at all (crash, kill -9), so the freshness bound
3191
3207
  // in loadFreshActiveTaskSnapshot is the backstop layer, not this close.
3192
- const closeLogFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : null;
3193
- const closeSessionId = typeof flags['session-id'] === 'string' ? flags['session-id'] : null;
3194
3208
  // Handoff write happens BEFORE the snapshot close below, while the
3195
3209
  // snapshot writeSessionEndHandoff reads is still active.
3196
3210
  if (closeSessionId) {
3197
3211
  try {
3198
- const activeForHandoff = loadActiveTaskSnapshot(hippoRoot, resolveTenantId({}));
3199
- if (!activeForHandoff || activeForHandoff.session_id !== closeSessionId) {
3200
- appendSessionEndCloseLog(closeLogFile, 'skip: no active snapshot for session');
3212
+ const tenantId = resolveTenantId({});
3213
+ const ownSnapshot = loadActiveTaskSnapshot(store, tenantId)?.session_id === closeSessionId;
3214
+ // A never-compacted session has no snapshot; read even when it has one, as another session's PreCompact can take the slot before the write.
3215
+ const derived = transcriptPath
3216
+ ? transcriptWorkingState(transcriptPath, (message) => appendSessionEndCloseLog(closeLogFile, message))
3217
+ : null;
3218
+ if (!ownSnapshot && !derived) {
3219
+ appendSessionEndCloseLog(closeLogFile, 'skip: no snapshot or transcript for session');
3201
3220
  }
3202
3221
  else {
3203
3222
  const evidence = collectHandoffEvidence(process.cwd(), 'unknown');
3204
- const handoff = writeSessionEndHandoff(hippoRoot, resolveTenantId({}), closeSessionId, evidence);
3205
- appendSessionEndCloseLog(closeLogFile, handoff ? `wrote handoff for session ${closeSessionId}` : 'skip: handoff newer than snapshot');
3223
+ const handoff = writeSessionEndHandoff(store, tenantId, closeSessionId, evidence, derived);
3224
+ appendSessionEndCloseLog(closeLogFile, handoff ? `wrote handoff for session ${closeSessionId}` : `skip: kept the existing handoff for session ${closeSessionId}`);
3206
3225
  }
3207
3226
  }
3208
3227
  catch (err) {
@@ -3212,7 +3231,7 @@ async function cmdSessionEndWorker(hippoRoot, flags) {
3212
3231
  }
3213
3232
  try {
3214
3233
  if (closeSessionId) {
3215
- const closed = closeTaskSnapshotsForSession(hippoRoot, resolveTenantId({}), closeSessionId);
3234
+ const closed = closeTaskSnapshotsForSession(store, resolveTenantId({}), closeSessionId);
3216
3235
  appendSessionEndCloseLog(closeLogFile, `closed ${closed} active snapshot(s) for session ${closeSessionId}`);
3217
3236
  }
3218
3237
  else {
@@ -3223,6 +3242,38 @@ async function cmdSessionEndWorker(hippoRoot, flags) {
3223
3242
  appendSessionEndCloseLog(closeLogFile, `snapshot close failed: ${err.message}`);
3224
3243
  }
3225
3244
  }
3245
+ /** Books the ending session's re-reads in each store its ledger rows can land in (project and global); returns the log lines. */
3246
+ async function bookSessionRereads(hippoRoot, transcriptPath, sessionId) {
3247
+ if (!transcriptPath || !sessionId)
3248
+ return [];
3249
+ let read;
3250
+ try {
3251
+ read = await readApiCalls(transcriptPath);
3252
+ }
3253
+ catch (err) {
3254
+ return [`skip re-read count: cannot read the transcript: ${err instanceof Error ? err.message : String(err)}`];
3255
+ }
3256
+ const roots = new Set([hippoRoot, getGlobalRoot()].filter((root) => isInitialized(root)).map((root) => path.resolve(root)));
3257
+ const lines = [];
3258
+ let tokens = 0;
3259
+ for (const root of roots) {
3260
+ try {
3261
+ const db = openHippoDb(root);
3262
+ try {
3263
+ tokens += recordRereads(db, resolveTenantId({}), sessionId, read.calls);
3264
+ }
3265
+ finally {
3266
+ closeHippoDb(db);
3267
+ }
3268
+ }
3269
+ catch (err) {
3270
+ lines.push(`re-read count failed: ${err instanceof Error ? err.message : String(err)}`);
3271
+ }
3272
+ }
3273
+ const skipped = read.malformed > 0 ? `, ${read.malformed} unparsable transcript lines skipped` : '';
3274
+ lines.push(`re-read ${tokens} tokens over ${read.calls.length} model calls for session ${sessionId}${skipped}`);
3275
+ return lines;
3276
+ }
3226
3277
  /**
3227
3278
  * Best-effort log line for the DF1 T3 snapshot-close step in
3228
3279
  * `cmdSessionEndWorker`. `cmdSleep`/`cmdCapture` each tee console output to
@@ -3231,14 +3282,15 @@ async function cmdSessionEndWorker(hippoRoot, flags) {
3231
3282
  * the detached worker's `stdio: 'ignore'` — write straight to the file
3232
3283
  * instead, matching capture.ts's `appendPreCompactLog` convention.
3233
3284
  */
3234
- function appendSessionEndCloseLog(logFile, message) {
3285
+ function appendSessionEndCloseLog(logFile, message, opts = {}) {
3235
3286
  if (!logFile)
3236
3287
  return;
3237
3288
  try {
3238
3289
  fs.mkdirSync(path.dirname(logFile), { recursive: true });
3239
3290
  // sanitizeLogMessage: `message` interpolates the payload-controlled
3240
3291
  // session_id — same log-forgery guard appendPreCompactLog applies.
3241
- fs.appendFileSync(logFile, `[hippo] ${new Date().toISOString()} ${sanitizeLogMessage(message)}\n`, 'utf8');
3292
+ const write = opts.startFresh ? fs.writeFileSync : fs.appendFileSync;
3293
+ write(logFile, `[hippo] ${new Date().toISOString()} ${sanitizeLogMessage(message)}\n`, 'utf8');
3242
3294
  }
3243
3295
  catch {
3244
3296
  // Best-effort only — never let a log-write failure surface as an error.
@@ -3338,11 +3390,23 @@ function cmdCodexRun(hippoRoot, args) {
3338
3390
  }
3339
3391
  async function cmdCodexSessionEndWorker(hippoRoot, flags) {
3340
3392
  const logFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined;
3341
- try {
3342
- await cmdSleep(hippoRoot, logFile ? { 'log-file': logFile } : {});
3393
+ // Like the other hooks: project store, else global; a folder with neither must not get one made.
3394
+ const store = hookStoreRoot(hippoRoot);
3395
+ if (!isInitialized(store)) {
3396
+ appendSessionEndCloseLog(logFile ?? null, 'skip: no hippo store for this folder or globally', { startFresh: true });
3397
+ return;
3343
3398
  }
3344
- catch {
3345
- // sleep errors are already written via cmdSleep
3399
+ // Sleeping the global store from here would learn this folder's git commits into it; it has its own daily sleep.
3400
+ if (isInitialized(hippoRoot)) {
3401
+ try {
3402
+ await cmdSleep(hippoRoot, logFile ? { 'log-file': logFile } : {});
3403
+ }
3404
+ catch {
3405
+ // sleep errors are already written via cmdSleep
3406
+ }
3407
+ }
3408
+ else {
3409
+ appendSessionEndCloseLog(logFile ?? null, 'skip sleep: this folder has no store of its own', { startFresh: true });
3346
3410
  }
3347
3411
  try {
3348
3412
  const codexHome = typeof flags['codex-home'] === 'string'
@@ -3364,6 +3428,8 @@ async function cmdCodexSessionEndWorker(hippoRoot, flags) {
3364
3428
  appendSessionEndCloseLog(logFile ?? null, 'skip capture: no Codex transcript for this session');
3365
3429
  return;
3366
3430
  }
3431
+ const digestLog = (message) => appendSessionEndCloseLog(logFile ?? null, message);
3432
+ const scan = readSessionScan(transcriptPath, digestLog);
3367
3433
  const captureOpts = {
3368
3434
  source: 'last-session',
3369
3435
  transcriptPath,
@@ -3371,8 +3437,17 @@ async function cmdCodexSessionEndWorker(hippoRoot, flags) {
3371
3437
  dryRun: false,
3372
3438
  global: false,
3373
3439
  tenantId: resolveTenantId({}),
3440
+ originProject: store === hippoRoot ? undefined : deriveOriginProject(process.cwd()),
3441
+ sessionTurns: scan?.turns,
3374
3442
  };
3375
- cmdCapture(hippoRoot, captureOpts);
3443
+ try {
3444
+ cmdCapture(store, captureOpts);
3445
+ }
3446
+ catch {
3447
+ // capture path logs its own failures
3448
+ }
3449
+ // The Codex wrapper passes no session id, so the rollout file names the session.
3450
+ recordSessionDigest(hippoRoot, scan, { key: path.basename(transcriptPath, '.jsonl'), tenantId: resolveTenantId({}), log: digestLog });
3376
3451
  }
3377
3452
  catch {
3378
3453
  // capture path logs its own failures
@@ -3695,37 +3770,10 @@ function cmdInspect(hippoRoot, id) {
3695
3770
  console.log(entry.content);
3696
3771
  }
3697
3772
  function printActiveTaskSnapshot(snapshot) {
3698
- console.log('## Active Task Snapshot\n');
3699
- console.log(`- Task: ${snapshot.task}`);
3700
- console.log(`- Status: ${snapshot.status}`);
3701
- console.log(`- Updated: ${snapshot.updated_at}`);
3702
- console.log(`- Source: ${snapshot.source}`);
3703
- if (snapshot.session_id) {
3704
- console.log(`- Session: ${snapshot.session_id}`);
3705
- }
3706
- console.log('');
3707
- console.log('### Summary');
3708
- console.log(snapshot.summary);
3709
- console.log('');
3710
- console.log('### Next step');
3711
- console.log(snapshot.next_step);
3712
- console.log('');
3773
+ console.log(snapshotText(snapshot));
3713
3774
  }
3714
3775
  function printSessionEvents(events) {
3715
- if (events.length === 0) {
3716
- console.log('No session events found.');
3717
- return;
3718
- }
3719
- const latest = events[events.length - 1];
3720
- console.log('## Recent Session Trail\n');
3721
- console.log(`- Session: ${latest.session_id}`);
3722
- console.log(`- Task: ${latest.task ?? 'n/a'}`);
3723
- console.log(`- Updated: ${latest.created_at}`);
3724
- console.log('');
3725
- for (const event of events) {
3726
- console.log(`- [${event.created_at}] (${event.event_type}) ${event.content}`);
3727
- }
3728
- console.log('');
3776
+ console.log(events.length === 0 ? 'No session events found.' : sessionTrailText(events));
3729
3777
  }
3730
3778
  function cmdConflicts(hippoRoot, flags) {
3731
3779
  requireInit(hippoRoot);
@@ -3850,6 +3898,12 @@ function cmdReject(hippoRoot, args, flags) {
3850
3898
  console.log(` Reason: ${reason}`);
3851
3899
  if (result.removedIds.length > 0) {
3852
3900
  console.log(` Removed ${result.removedIds.length} matching row(s): ${result.removedIds.join(', ')}`);
3901
+ if (result.successorIds.length > 0) {
3902
+ console.log(` Merged rows that held it keep their other texts in: ${result.successorIds.join(', ')}`);
3903
+ }
3904
+ if (result.dormantSuccessorIds.length > 0) {
3905
+ console.log(` Dormant merged rows that held it keep their other texts in: ${result.dormantSuccessorIds.join(', ')}`);
3906
+ }
3853
3907
  }
3854
3908
  else {
3855
3909
  console.log(' No live rows matched (pre-emptive tombstone).');
@@ -4028,7 +4082,8 @@ function cmdQuarantine(hippoRoot, args, flags) {
4028
4082
  /**
4029
4083
  * `hippo tokens [--days <n>] [--json] [--global]`: the token ledger
4030
4084
  * (ROADMAP TE0). Tokens of memory text handed to agents per surface, blocks
4031
- * the per-prompt hook skipped as unchanged (TE2) and the tokens that saved.
4085
+ * the per-prompt hook skipped as unchanged (TE2) and the tokens that saved,
4086
+ * and the hook blocks' tokens later model calls re-read, counted when each session ends.
4032
4087
  * Counts are estimates (characters / 4), the same estimate every budget uses.
4033
4088
  */
4034
4089
  function cmdTokens(hippoRoot, flags) {
@@ -4049,17 +4104,22 @@ function cmdTokens(hippoRoot, flags) {
4049
4104
  console.log(`No memory text recorded in the last ${windowDays} days.`);
4050
4105
  return;
4051
4106
  }
4052
- console.log(`Memory text handed to agents, last ${windowDays} days (estimated tokens)\n`);
4053
- console.log(` ${'surface'.padEnd(14)}${'sent'.padStart(8)}${'tokens'.padStart(12)}${'skipped'.padStart(10)}${'saved'.padStart(12)}`);
4107
+ console.log(`Memory text handed to agents, last ${windowDays} days (estimated tokens, characters / 4)\n`);
4108
+ console.log(` ${'surface'.padEnd(14)}${'sent'.padStart(8)}${'tokens'.padStart(12)}${'skipped'.padStart(10)}${'saved'.padStart(12)}`
4109
+ + `${'re-read'.padStart(12)}`);
4054
4110
  for (const row of summary.surfaces) {
4055
4111
  console.log(` ${row.surface.padEnd(14)}${String(row.injected).padStart(8)}${String(row.tokens).padStart(12)}`
4056
- + `${String(row.skipped).padStart(10)}${String(row.tokensAvoided).padStart(12)}`);
4112
+ + `${String(row.skipped).padStart(10)}${String(row.tokensAvoided).padStart(12)}${String(row.tokensReread).padStart(12)}`);
4057
4113
  }
4058
4114
  console.log('');
4059
4115
  console.log(` Total sent: ${summary.totalTokens} tokens. Saved by skipping unchanged blocks: ${summary.totalTokensAvoided}.`);
4116
+ console.log(` Re-read by later model calls until compaction: ${summary.totalTokensReread} tokens,`
4117
+ + ` counted for ${summary.rereadSessions} of ${summary.hookSessions} sessions.`);
4060
4118
  if (summary.meanTokensPerSession > 0) {
4061
4119
  console.log(` Mean per session (rows with a session id): ${summary.meanTokensPerSession} tokens.`);
4062
4120
  }
4121
+ console.log(' Re-reads are counted for hook and compact-resume blocks when a session ends; other surfaces, and open or crashed sessions, show sent only.');
4122
+ console.log(" Re-reads usually bill at the provider's cached-input rate, a fraction of the full input price.");
4063
4123
  }
4064
4124
  /** `hippo failures [--days <n>] [--json] [--global]`: failed tool calls by outcome, and repeats across sessions (CD13). */
4065
4125
  function cmdFailures(hippoRoot, flags) {
@@ -4297,47 +4357,7 @@ function cmdSession(hippoRoot, args, flags) {
4297
4357
  process.exit(1);
4298
4358
  }
4299
4359
  function printHandoff(handoff) {
4300
- console.log('## Session Handoff\n');
4301
- console.log(`- Session: ${handoff.sessionId}`);
4302
- console.log(`- Updated: ${handoff.updatedAt}`);
4303
- if (handoff.taskId)
4304
- console.log(`- Task: ${handoff.taskId}`);
4305
- if (handoff.repoRoot)
4306
- console.log(`- Repo: ${handoff.repoRoot}`);
4307
- if (handoff.outcome)
4308
- console.log(`- Outcome: ${handoff.outcome}`);
4309
- if (handoff.targetRuntime)
4310
- console.log(`- Target runtime: ${handoff.targetRuntime}`);
4311
- if (handoff.cardId)
4312
- console.log(`- Card: ${handoff.cardId}`);
4313
- console.log('');
4314
- console.log('### Summary');
4315
- console.log(handoff.summary);
4316
- if (handoff.nextAction) {
4317
- console.log('');
4318
- console.log('### Next action');
4319
- console.log(handoff.nextAction);
4320
- }
4321
- if (handoff.artifacts && handoff.artifacts.length > 0) {
4322
- console.log('');
4323
- console.log('### Artifacts');
4324
- for (const artifact of handoff.artifacts) {
4325
- console.log(`- ${artifact}`);
4326
- }
4327
- }
4328
- if (handoff.constraints && handoff.constraints.length > 0) {
4329
- console.log('');
4330
- console.log('### Constraints');
4331
- for (const constraint of handoff.constraints) {
4332
- console.log(`- ${constraint}`);
4333
- }
4334
- }
4335
- if (handoff.evidence) {
4336
- console.log('');
4337
- console.log('### Evidence');
4338
- console.log(formatHandoffEvidenceLine(handoff.evidence));
4339
- }
4340
- console.log('');
4360
+ console.log(handoffText(handoff));
4341
4361
  }
4342
4362
  function cmdHandoff(hippoRoot, args, flags) {
4343
4363
  requireInit(hippoRoot);
@@ -6127,7 +6147,7 @@ function cmdGraph(hippoRoot, args, flags) {
6127
6147
  : process.platform === 'darwin'
6128
6148
  ? ['open', [out]]
6129
6149
  : ['xdg-open', [out]];
6130
- const child = spawn(cmd, cmdArgs, { detached: true, stdio: 'ignore' });
6150
+ const child = spawn(cmd, cmdArgs, { detached: true, stdio: 'ignore', windowsHide: true });
6131
6151
  // A missing launcher (e.g. xdg-open absent) emits 'error' asynchronously;
6132
6152
  // an unhandled 'error' event would throw, so swallow it — the file is
6133
6153
  // already written and its path printed above.
@@ -6390,6 +6410,13 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6390
6410
  }
6391
6411
  }
6392
6412
  const currentSessionId = payloadSessionId ?? hostSessionId();
6413
+ // A sub-agent's payload and env both carry its parent's session id, so it books no session and never skips a block.
6414
+ const subagent = isSubagentPayload(stdinText);
6415
+ const ledgerSessionId = subagent ? undefined : currentSessionId;
6416
+ if (subagent)
6417
+ payloadSessionId = undefined;
6418
+ const format = String(flags['format'] ?? 'markdown');
6419
+ const framing = String(flags['framing'] ?? 'observe');
6393
6420
  const opts = {
6394
6421
  q: query,
6395
6422
  budget,
@@ -6400,6 +6427,8 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6400
6427
  crossProject,
6401
6428
  currentSessionId,
6402
6429
  prompt: payloadPrompt,
6430
+ // JSON is budgeted as the markdown it stands for, so one budget picks the same memories in every format.
6431
+ cost: contextCost(format === 'additional-context' ? 'additional-context' : 'markdown', framing),
6403
6432
  };
6404
6433
  const result = await api.getContext(ctx, opts);
6405
6434
  // Early exit when there's nothing to render (matches pre-extraction behavior).
@@ -6409,9 +6438,6 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6409
6438
  (result.recentEvents && result.recentEvents.length > 0);
6410
6439
  if (!hasContextData)
6411
6440
  return;
6412
- // Format + framing are CLI rendering concerns; api.getContext doesn't see them.
6413
- const format = String(flags['format'] ?? 'markdown');
6414
- const framing = String(flags['framing'] ?? 'observe');
6415
6441
  // Adapter: ContextResultEntry -> the print-helper input shape. v39:
6416
6442
  // cross-project inclusions (only present under --cross-project or with
6417
6443
  // isolation disabled via crossProject) render in their own demarcated
@@ -6446,7 +6472,7 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6446
6472
  });
6447
6473
  console.log(jsonText);
6448
6474
  withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6449
- tenantId: ctx.tenantId, sessionId: currentSessionId, surface: pinnedOnly ? 'hook' : 'context',
6475
+ tenantId: ctx.tenantId, sessionId: ledgerSessionId, surface: pinnedOnly ? 'hook' : 'context',
6450
6476
  event: 'inject', items: output.length, tokens: estimateTokens(jsonText),
6451
6477
  }));
6452
6478
  }
@@ -6458,11 +6484,7 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6458
6484
  const recallEntries = result.entries.filter((r) => r.promptRecall);
6459
6485
  const staticItems = staticEntries.map((r) => ({ entry: r.entry, score: r.score, tokens: r.tokens, isGlobal: r.isGlobal ?? false }));
6460
6486
  const recallItems = recallEntries.map((r) => ({ entry: r.entry, score: r.score, tokens: r.tokens, isGlobal: r.isGlobal ?? false }));
6461
- // Header total is static-only once a recall section exists; otherwise byte-identical to today.
6462
- const staticHeaderTokens = recallItems.length > 0
6463
- ? staticItems.reduce((sum, r) => sum + r.tokens, 0)
6464
- : result.tokens;
6465
- const staticBlock = captureConsole(() => {
6487
+ const staticBlock = settleTokens((t) => captureConsole(() => {
6466
6488
  if (result.activeSnapshot)
6467
6489
  printActiveTaskSnapshot(result.activeSnapshot);
6468
6490
  if (result.sessionHandoff)
@@ -6470,16 +6492,13 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6470
6492
  if (result.recentEvents && result.recentEvents.length > 0) {
6471
6493
  printSessionEvents(result.recentEvents);
6472
6494
  }
6473
- if (staticItems.length > 0) {
6474
- // TE1: no live strength percentage, so an unchanged set of memories
6475
- // renders byte-identically turn after turn.
6476
- printContextMarkdown(staticItems, staticHeaderTokens, framing, { showStrength: false });
6477
- }
6495
+ // TE1: no live strength percentage, so an unchanged set of memories renders byte-identically turn after turn.
6496
+ if (staticItems.length > 0)
6497
+ printContextMarkdown(staticItems, t, framing, { showStrength: false });
6478
6498
  printCrossProjectSection(staticCrossEntries);
6479
- });
6480
- const recallTokens = recallItems.reduce((sum, r) => sum + r.tokens, 0);
6499
+ }));
6481
6500
  const recallBlock = recallItems.length > 0
6482
- ? captureConsole(() => printContextMarkdown(recallItems, recallTokens, framing, { showStrength: false, heading: 'Prompt-Relevant Memory' }))
6501
+ ? settleTokens((t) => captureConsole(() => printContextMarkdown(recallItems, t, framing, { showStrength: false, heading: 'Prompt-Relevant Memory' })))
6483
6502
  : '';
6484
6503
  if (!staticBlock.trim() && !recallBlock.trim())
6485
6504
  return;
@@ -6524,7 +6543,7 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6524
6543
  if (finalStatic) {
6525
6544
  try {
6526
6545
  recordTokenUse(db, {
6527
- tenantId: ctx.tenantId, sessionId: currentSessionId, surface, event: 'inject',
6546
+ tenantId: ctx.tenantId, sessionId: ledgerSessionId, surface, event: 'inject',
6528
6547
  items: staticItems.length, tokens: estimateTokens(finalStatic), hash: blockHash(finalStatic),
6529
6548
  });
6530
6549
  }
@@ -6533,7 +6552,7 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6533
6552
  if (recallBlock) {
6534
6553
  try {
6535
6554
  recordTokenUse(db, {
6536
- tenantId: ctx.tenantId, sessionId: currentSessionId, surface: 'hook_recall', event: 'inject',
6555
+ tenantId: ctx.tenantId, sessionId: ledgerSessionId, surface: 'hook_recall', event: 'inject',
6537
6556
  items: recallItems.length, tokens: estimateTokens(recallBlock), hash: blockHash(recallBlock),
6538
6557
  });
6539
6558
  }
@@ -6543,8 +6562,8 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6543
6562
  }
6544
6563
  }
6545
6564
  else {
6546
- // markdown (default)
6547
- const text = captureConsole(() => {
6565
+ // markdown (default); the header figure counts the whole block, sections included, as the ledger does.
6566
+ const text = settleTokens((t) => captureConsole(() => {
6548
6567
  if (result.activeSnapshot) {
6549
6568
  printActiveTaskSnapshot(result.activeSnapshot);
6550
6569
  }
@@ -6554,18 +6573,17 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6554
6573
  if (result.recentEvents && result.recentEvents.length > 0) {
6555
6574
  printSessionEvents(result.recentEvents);
6556
6575
  }
6557
- if (renderItems.length > 0) {
6558
- printContextMarkdown(renderItems, result.tokens, framing);
6559
- }
6576
+ if (renderItems.length > 0)
6577
+ printContextMarkdown(renderItems, t, framing);
6560
6578
  printCrossProjectSection(crossEntries);
6561
6579
  if (result.ambientState) {
6562
6580
  console.log(`\n${renderAmbientSummary(result.ambientState)}`);
6563
6581
  }
6564
- });
6582
+ }));
6565
6583
  if (text.length > 0)
6566
6584
  console.log(text);
6567
6585
  withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6568
- tenantId: ctx.tenantId, sessionId: currentSessionId, surface: pinnedOnly ? 'hook' : 'context',
6586
+ tenantId: ctx.tenantId, sessionId: ledgerSessionId, surface: pinnedOnly ? 'hook' : 'context',
6569
6587
  event: 'inject', items: renderItems.length, tokens: estimateTokens(text),
6570
6588
  }));
6571
6589
  }
@@ -6579,7 +6597,8 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6579
6597
  */
6580
6598
  function resetHookInjection(hippoRoot, stdinText, requiredSource) {
6581
6599
  const sessionId = hookPayloadSessionId(stdinText, requiredSource);
6582
- if (sessionId === null)
6600
+ // A sub-agent's compaction leaves its parent's context, and the blocks in it, as they were.
6601
+ if (sessionId === null || isSubagentPayload(stdinText))
6583
6602
  return;
6584
6603
  withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6585
6604
  tenantId: resolveTenantId({}), sessionId, surface: 'hook', event: 'reset', items: 0, tokens: 0,
@@ -6654,50 +6673,17 @@ function withLedgerDb(hippoRoot, fn) {
6654
6673
  function printCrossProjectSection(items) {
6655
6674
  if (items.length === 0)
6656
6675
  return;
6657
- console.log(`\n## Other-project memory (explicitly requested, ${items.length} entries)\n`);
6658
- for (const item of items) {
6659
- const originLabel = item.origin === null || item.origin === '' ? 'unknown-origin' : item.origin;
6660
- const tagStr = item.entry.tags.length > 0 ? ` [${item.entry.tags.join(', ')}]` : '';
6661
- console.log(`- **[${originLabel}]** ${item.entry.content}${tagStr}`);
6662
- }
6676
+ console.log(crossProjectHeading(items.length));
6677
+ for (const item of items)
6678
+ console.log(crossProjectLine(item));
6663
6679
  }
6664
6680
  /** @internal — exported for snapshot tests (tests/cli-context-render-snapshot.test.ts). NOT a stable public API. */
6665
6681
  export function printContextMarkdown(items, totalTokens, framing = 'observe', opts = {}) {
6666
6682
  const now = evalNow();
6667
6683
  const showStrength = opts.showStrength !== false;
6668
- const heading = opts.heading ?? 'Project Memory';
6669
- console.log(`## ${heading} (${items.length} entries, ${totalTokens} tokens)\n`);
6670
- for (const item of items) {
6671
- const e = item.entry;
6672
- const tagStr = e.tags.length > 0 ? ` [${e.tags.join(', ')}]` : '';
6673
- const strengthPct = Math.round(calculateStrength(e) * 100);
6674
- const strengthStr = showStrength ? ` (${strengthPct}%)` : '';
6675
- const globalPrefix = item.isGlobal ? '[global] ' : '';
6676
- const effectiveConf = confidenceFacets(e, now).tier;
6677
- const label = confidenceLabel(e, now);
6678
- const confWarning = label.warn ? ' \u26A0\uFE0F' : '';
6679
- const confTag = `[${label.text}]${confWarning}`;
6680
- if (framing === 'observe') {
6681
- const dateStr = new Date(e.created).toISOString().slice(0, 10);
6682
- if (effectiveConf === 'verified') {
6683
- // Verified: no date prefix, just the rule
6684
- console.log(`- **${confTag} ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
6685
- }
6686
- else if (effectiveConf === 'stale') {
6687
- console.log(`- **${confTag} Previously observed (${dateStr}): ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
6688
- }
6689
- else {
6690
- console.log(`- **${confTag} Previously observed (${dateStr}): ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
6691
- }
6692
- }
6693
- else if (framing === 'suggest') {
6694
- console.log(`- **${confTag} Consider checking: ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
6695
- }
6696
- else {
6697
- // framing === 'assert': no prefix (bare facts)
6698
- console.log(`- **${confTag} ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
6699
- }
6700
- }
6684
+ console.log(contextHeading(opts.heading ?? 'Project Memory', items.length, totalTokens));
6685
+ for (const item of items)
6686
+ console.log(contextLine(item, framing, showStrength, now));
6701
6687
  }
6702
6688
  function autoDetectContext() {
6703
6689
  // Try git diff --name-only for changed files
@@ -6705,6 +6691,7 @@ function autoDetectContext() {
6705
6691
  const diff = execSync('git diff --name-only HEAD 2>&1', {
6706
6692
  encoding: 'utf8',
6707
6693
  timeout: 3000,
6694
+ windowsHide: true,
6708
6695
  }).trim();
6709
6696
  if (diff) {
6710
6697
  // Extract meaningful terms from file paths
@@ -6720,6 +6707,7 @@ function autoDetectContext() {
6720
6707
  const branch = execSync('git branch --show-current 2>&1', {
6721
6708
  encoding: 'utf8',
6722
6709
  timeout: 3000,
6710
+ windowsHide: true,
6723
6711
  }).trim();
6724
6712
  if (branch && branch !== 'main' && branch !== 'master') {
6725
6713
  return branch.replace(/[-_\/]/g, ' ');
@@ -6848,9 +6836,7 @@ async function cmdWatch(command, hippoRoot) {
6848
6836
  try {
6849
6837
  writeEntry(hippoRoot, entry);
6850
6838
  updateStats(hippoRoot, { remembered: 1 });
6851
- if (isEmbeddingConfigured(hippoRoot)) {
6852
- embedMemory(hippoRoot, entry).catch(() => { });
6853
- }
6839
+ void embedMemory(hippoRoot, entry);
6854
6840
  const preview = stderr.trim().slice(0, 80);
6855
6841
  console.error(`\nHippo learned from failure: "${preview}"`);
6856
6842
  }
@@ -6898,7 +6884,7 @@ function learnFromRepo(hippoRoot, repoPath, days, label) {
6898
6884
  // parsed lesson with the gate on the write alone.
6899
6885
  //
6900
6886
  // Round 2 then found the cure was worse. STORAGE is what makes invalidation
6901
- // idempotent here: a stored lesson is recognised by deduplicateLesson on
6887
+ // idempotent here: a stored lesson is recognised by its same-text key on
6902
6888
  // the next scan and short-circuits before invalidating again. A lesson that
6903
6889
  // invalidates but is never stored has no such record, so every rescan
6904
6890
  // re-invalidates, and invalidateMatching halves half_life_days each time.
@@ -6926,9 +6912,9 @@ function learnFromRepo(hippoRoot, repoPath, days, label) {
6926
6912
  let rejected = 0;
6927
6913
  const gitLearnTags = ['error', 'git-learned'];
6928
6914
  const existingForSchema = loadAllEntries(hippoRoot, resolveTenantId({}));
6915
+ const keys = storedTextKeys(existingForSchema);
6929
6916
  for (const lesson of lessons) {
6930
- // The array overload ignores the tenant arg; existingForSchema is already scoped.
6931
- if (deduplicateLesson(existingForSchema, lesson, 0.7, resolveTenantId({}))) {
6917
+ if (keys.has(duplicateKey(lesson))) {
6932
6918
  skipped++;
6933
6919
  continue;
6934
6920
  }
@@ -6966,9 +6952,8 @@ function learnFromRepo(hippoRoot, repoPath, days, label) {
6966
6952
  throw err;
6967
6953
  }
6968
6954
  updateStats(hippoRoot, { remembered: 1 });
6969
- if (isEmbeddingConfigured(hippoRoot)) {
6970
- embedMemory(hippoRoot, entry).catch(() => { });
6971
- }
6955
+ keys.add(duplicateKey(lesson));
6956
+ void embedMemory(hippoRoot, entry);
6972
6957
  added++;
6973
6958
  }
6974
6959
  console.log(`${prefix}${added} new lessons added, ${skipped} duplicates skipped` +
@@ -7251,6 +7236,13 @@ never as a closing step:
7251
7236
  hippo remember "<description of what went wrong>" --error
7252
7237
  \`\`\`
7253
7238
 
7239
+ When you learn something that should outlive this session (a decision and
7240
+ its reason, a user preference, a lesson), record it right then, while you
7241
+ work, never as a closing step. Leave out secrets and personal details:
7242
+ \`\`\`bash
7243
+ hippo remember "<what you learned and why>"
7244
+ \`\`\`
7245
+
7254
7246
  When Hippo's Codex wrapper is installed, session-end capture runs automatically.
7255
7247
  If the wrapper is not installed, capture a brief summary manually:
7256
7248
  \`\`\`bash
@@ -7276,6 +7268,13 @@ never as a closing step:
7276
7268
  hippo remember "<description of what went wrong>" --error
7277
7269
  \`\`\`
7278
7270
 
7271
+ When you learn something that should outlive this session (a decision and
7272
+ its reason, a user preference, a lesson), record it right then, while you
7273
+ work, never as a closing step. Leave out secrets and personal details:
7274
+ \`\`\`bash
7275
+ hippo remember "<what you learned and why>"
7276
+ \`\`\`
7277
+
7279
7278
  When ending a session, capture a brief summary:
7280
7279
  \`\`\`bash
7281
7280
  hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
@@ -7300,6 +7299,13 @@ never as a closing step:
7300
7299
  hippo remember "<description of what went wrong>" --error
7301
7300
  \`\`\`
7302
7301
 
7302
+ When you learn something that should outlive this session (a decision and
7303
+ its reason, a user preference, a lesson), record it right then, while you
7304
+ work, never as a closing step. Leave out secrets and personal details:
7305
+ \`\`\`bash
7306
+ hippo remember "<what you learned and why>"
7307
+ \`\`\`
7308
+
7303
7309
  When ending a session, capture a brief summary:
7304
7310
  \`\`\`bash
7305
7311
  hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
@@ -7318,10 +7324,17 @@ hippo context --auto --budget 1500
7318
7324
  \`\`\`
7319
7325
  Read the output before writing any code.
7320
7326
 
7321
- When you learn a non-obvious lesson or hit an error, record it right then,
7322
- while you work, never as a closing step:
7327
+ On errors or unexpected behaviour, record it right then, while you work,
7328
+ never as a closing step:
7323
7329
  \`\`\`bash
7324
- hippo remember "<lesson>" --error
7330
+ hippo remember "<description of what went wrong>" --error
7331
+ \`\`\`
7332
+
7333
+ When you learn something that should outlive this session (a decision and
7334
+ its reason, a user preference, a lesson), record it right then, while you
7335
+ work, never as a closing step. Leave out secrets and personal details:
7336
+ \`\`\`bash
7337
+ hippo remember "<what you learned and why>"
7325
7338
  \`\`\`
7326
7339
 
7327
7340
  When stuck or repeating yourself, check if this happened before:
@@ -7353,6 +7366,13 @@ never as a closing step:
7353
7366
  hippo remember "<description of what went wrong>" --error
7354
7367
  \`\`\`
7355
7368
 
7369
+ When you learn something that should outlive this session (a decision and
7370
+ its reason, a user preference, a lesson), record it right then, while you
7371
+ work, never as a closing step. Leave out secrets and personal details:
7372
+ \`\`\`bash
7373
+ hippo remember "<what you learned and why>"
7374
+ \`\`\`
7375
+
7356
7376
  When ending a session, capture a brief summary:
7357
7377
  \`\`\`bash
7358
7378
  hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
@@ -7371,14 +7391,19 @@ const SHIPPED_HOOK_HASHES = new Map([
7371
7391
  ['15abcece9712279fb4721f7a8f0ba117457400278977beb5cf5b5d7ba49f7b1a', 'codex'],
7372
7392
  ['0c81a6b2c21473313001f624b80ea870e661aecbfda9bfe8503febc0d5f34533', 'codex'],
7373
7393
  ['88e45358aba4f17912f113221c991dc758275991335d1daa4aa1974a69c46769', 'codex'],
7394
+ ['e61632fe177450a06541c148a9a4f9182530d8df667806927a99792825903298', 'codex'],
7374
7395
  ['a1415ecda9b2f8f317c233738e4a5ac16e6b2cc385a017c0c8ecfbfacbcab6a3', 'cursor'],
7375
7396
  ['a38c428bbdfc14ec50f6f7b9183785170a4eae1ce9cde60257cca6efc7206b3a', 'cursor'],
7397
+ ['0ec9f556abfd55e94f9e6fb47ece0fc5acb841977d144b35a2371e03645d8636', 'cursor'],
7376
7398
  ['40524c3bd5a2eb04036567cc761451961d950995768bccd93a9900b0f75eafea', 'openclaw'],
7377
7399
  ['7b3518e8c0feaa7b8b454cde7743f7598ad14cd9979e1680d0954484e2464aae', 'openclaw'],
7400
+ ['1137dcf04568caf011e41db77bc55324faee88bc29c3a5fcc98ab687cd952a16', 'openclaw'],
7378
7401
  ['4601c67c31f41cd5b1324cfccdb1afc66872b7fb0bc1e7c5789ecabb1f6bd942', 'opencode'],
7379
7402
  ['90d9e21d8d1ecbe99a0fc7b7f2d9f8af7b5315a6b4b0203df4f7a9bdc0699b98', 'opencode'],
7403
+ ['ca4e00284f1397ed2f2fcc53210c27f63b90edf6b37fd66dad5ee58b94ea3eee', 'opencode'],
7380
7404
  ['8b8f5986d7f7ed15f06e68720d8913c3cab23d94366b411935ca2bbaa334553b', 'pi'],
7381
7405
  ['37767b355e18beac726b05b9e2b898dab8c6135fd7b98f3aa52edc734d5dd283', 'pi'],
7406
+ ['6e85a5cccb3cfeaa9a080713754936db730f96376f94cc9a9888a746149c7268', 'pi'],
7382
7407
  ]);
7383
7408
  function cmdHook(args, flags) {
7384
7409
  const subcommand = args[0];
@@ -7482,9 +7507,16 @@ function cmdHook(args, flags) {
7482
7507
  }
7483
7508
  }
7484
7509
  else if (target === 'codex') {
7485
- const result = installCodexWrapper();
7486
- console.log(`Installed Codex session-end integration -> ${result.metadataPath}`);
7487
- console.log(` Wrapped detected Codex launcher at ${result.commandPath}`);
7510
+ installCodexMemoryHooks('');
7511
+ // The wrapper stays the capture path; the hooks above work without it, so a missing launcher is not an error.
7512
+ if (detectRealCodexPath()) {
7513
+ const result = installCodexWrapper();
7514
+ console.log(`Installed Codex session-end integration -> ${result.metadataPath}`);
7515
+ console.log(` Wrapped detected Codex launcher at ${result.commandPath}`);
7516
+ }
7517
+ else {
7518
+ console.log('No codex launcher on PATH, so session-end capture was not set up; re-run once `codex` is on PATH.');
7519
+ }
7488
7520
  }
7489
7521
  return;
7490
7522
  }
@@ -7528,6 +7560,9 @@ function cmdHook(args, flags) {
7528
7560
  }
7529
7561
  }
7530
7562
  else if (target === 'codex') {
7563
+ if (uninstallJsonHooks('codex')) {
7564
+ console.log(`Removed hippo's Codex memory hooks from ${resolveJsonHookPaths('codex').settings}`);
7565
+ }
7531
7566
  if (uninstallCodexWrapper()) {
7532
7567
  console.log('Removed Codex wrapper integration');
7533
7568
  }
@@ -7617,10 +7652,13 @@ function cmdSetup(flags) {
7617
7652
  }
7618
7653
  for (const tool of wrapperTools) {
7619
7654
  if (dryRun) {
7655
+ if (tool.name === 'codex')
7656
+ console.log(`[dry-run] would install Codex memory hooks in ${resolveJsonHookPaths('codex').settings}`);
7620
7657
  console.log(`[dry-run] would wrap the detected ${tool.name} launcher in place`);
7621
7658
  continue;
7622
7659
  }
7623
7660
  if (tool.name === 'codex') {
7661
+ installCodexMemoryHooks(` ${tool.name.padEnd(14)} `);
7624
7662
  const result = ensureCodexWrapperInstalled();
7625
7663
  if (result.status === 'installed') {
7626
7664
  console.log(` ${tool.name.padEnd(14)} wrapped launcher -> ${result.commandPath}`);
@@ -7883,17 +7921,20 @@ function cmdAssemble(hippoRoot, sessionId, flags) {
7883
7921
  ...(Number.isFinite(freshTailCount) && freshTailCount >= 0 ? { freshTailCount } : {}),
7884
7922
  summarizeOlder,
7885
7923
  ...(scope !== undefined ? { scope } : {}),
7924
+ cost: assembleCost(sessionId),
7886
7925
  });
7887
7926
  if (flags['json']) {
7888
7927
  console.log(JSON.stringify(r, null, 2));
7889
7928
  return;
7890
7929
  }
7891
- console.log(`Session ${r.sessionId} — ${r.items.length} items, ${r.tokens} tokens (raw=${r.totalRaw}, summarized=${r.summarized}, evicted=${r.evicted})`);
7892
- for (const it of r.items) {
7893
- const prefix = it.isSummary ? '[summary]' : it.isFreshTail ? '[tail]' : '[older]';
7894
- const head = it.content.slice(0, 120);
7895
- console.log(` ${prefix} ${it.createdAt} ${it.id} — ${head}${it.content.length > 120 ? '…' : ''}`);
7896
- }
7930
+ console.log(settleTokens((t) => captureConsole(() => {
7931
+ console.log(assembleHeading({ ...r, items: r.items.length, tokens: t }));
7932
+ for (const it of r.items) {
7933
+ const prefix = it.isSummary ? '[summary]' : it.isFreshTail ? '[tail]' : '[older]';
7934
+ const head = it.content.slice(0, 120);
7935
+ console.log(` ${prefix} ${it.createdAt} ${it.id} \u2014 ${head}${it.content.length > 120 ? '…' : ''}`);
7936
+ }
7937
+ })));
7897
7938
  }
7898
7939
  function cmdDrillDown(hippoRoot, summaryId, flags) {
7899
7940
  requireInit(hippoRoot);
@@ -7920,6 +7961,7 @@ function cmdDrillDown(hippoRoot, summaryId, flags) {
7920
7961
  ...(Number.isFinite(limit) && limit > 0 ? { limit } : {}),
7921
7962
  ...(Number.isFinite(budget) && budget > 0 ? { budget } : {}),
7922
7963
  ...(depth !== undefined ? { depth } : {}),
7964
+ cost: drillCost,
7923
7965
  });
7924
7966
  if ('failure' in r) {
7925
7967
  // v1.6.4: only `not_drillable` is caller-actionable. `not_found`
@@ -8124,65 +8166,7 @@ function cmdAuthScopeGrant(hippoRoot, keyId, scope, grant, flags) {
8124
8166
  // ---------------------------------------------------------------------------
8125
8167
  // Audit log subcommands (A5 stub auth — `hippo audit list`)
8126
8168
  // ---------------------------------------------------------------------------
8127
- const VALID_AUDIT_OPS = new Set([
8128
- 'remember',
8129
- 'recall',
8130
- 'promote',
8131
- 'supersede',
8132
- 'forget',
8133
- 'archive_raw',
8134
- 'auth_revoke',
8135
- 'auth_create', // v1.12.4: emitted by api.authCreate
8136
- 'outcome', // v1.11.5: pre-existing drift — emitted today but rejected by old Set
8137
- 'consolidate', // v1.11.5: emitted by api.sleep / hippo sleep
8138
- 'audit_prune', // v1.12.9: emitted by pruneAuditLog
8139
- 'summary_marked_dirty', // v0.30 / E1 — lockstep with AuditOp union + server.ts VALID_AUDIT_OPS (v1.11.5 CRIT A institutional rule)
8140
- 'summary_marked_clean', // v0.30 / E3 — buildDag post-link clean op; lockstep
8141
- 'summary_rebuilt', // v0.30 / E3 — sleep-cycle rebuild op; lockstep
8142
- 'predict_create', // v0.31 / E2 prediction first-class object — emitted by savePrediction
8143
- 'predict_close', // v0.31 / E2 — emitted by closePrediction
8144
- 'predict_baserate', // v0.31 / J3 — emitted by computePredictionBaserate
8145
- 'recall_autodebias_hint', // v0.32 / J3.2 — emitted by computePlanningFallacyHint on success
8146
- 'recall_autodebias_hint_no_class_match', // v0.32 / J3.2 — telemetry: forward-claim, no class scored
8147
- 'recall_autodebias_hint_tiebreak', // v0.32 / J3.2 — telemetry: forward-claim, >=2 classes tied
8148
- 'recall_anchor_detected_query_repeat', // v0.33 / J1 — emitted by detector on R1 fire
8149
- 'recall_anchor_detected_memory_dominance', // v0.33 / J1 — emitted by detector on R2 fire
8150
- 'recall_anchor_skipped_no_session', // v0.33 / J1 — telemetry: no sessionId, ring skipped
8151
- 'recall_availability_detected', // v1.13.x / J2 - emitted when availability/recency-bias hint fires
8152
- 'decision_create', // E2 decision first-class object — emitted by saveDecision
8153
- 'decision_supersede', // E2 — emitted by saveDecision when --supersedes resolves to an active decision row
8154
- 'decision_close', // E2 — emitted by closeDecision
8155
- 'incident_open', // E2 incident first-class object — emitted by saveIncident
8156
- 'incident_resolve', // E2 — emitted by resolveIncident (open -> resolved)
8157
- 'incident_close', // E2 — emitted by closeIncident (open|resolved -> closed)
8158
- 'process_create', // E2 process first-class object — emitted by saveProcess
8159
- 'process_supersede', // E2 — emitted by saveProcess on a supersession
8160
- 'process_close', // E2 — emitted by closeProcess
8161
- 'policy_create', // E2 policy first-class object — emitted by savePolicy
8162
- 'policy_supersede', // E2 — emitted by savePolicy on a supersession
8163
- 'policy_close', // E2 — emitted by closePolicy
8164
- 'skill_create', // E2 skill first-class object — emitted by saveSkill
8165
- 'skill_supersede', // E2 — emitted by saveSkill on a supersession
8166
- 'skill_close', // E2 — emitted by closeSkill
8167
- 'project_brief_create', // E2 project_brief first-class object — emitted by saveProjectBrief
8168
- 'project_brief_supersede', // E2 — emitted by saveProjectBrief on a supersession (incl. refresh)
8169
- 'project_brief_close', // E2 — emitted by closeProjectBrief
8170
- 'customer_note_create', // E2 customer_note first-class object — emitted by saveCustomerNote
8171
- 'customer_note_supersede', // E2 — emitted by saveCustomerNote on a supersession
8172
- 'customer_note_close', // E2 — emitted by closeCustomerNote
8173
- 'mv_rescue', // LC2-E3 — emitted by consolidate() per rescue; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
8174
- 'reject_value', // AT1 — emitted by `hippo reject`; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
8175
- 'reject_refusal', // AT1 — emitted when the rejection guard refuses a write; lockstep
8176
- 'unreject_value', // AT1 — emitted by `hippo unreject`; lockstep
8177
- 'conflict_resolve', // AT1 — emitted by resolveConflict on every resolution path; lockstep
8178
- 'half_life_migrate', // Decay default change — emitted by migrateDefaultHalfLife; lockstep with AuditOp union
8179
- 'dormant_restore', // Dormant memories — emitted by api.restoreDormant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
8180
- 'auth_grant', // EI2: emitted by api.authGrant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
8181
- 'auth_ungrant', // EI2: emitted by api.authUngrant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
8182
- 'quarantine', // CD5: emitted by recordQuarantine; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
8183
- 'quarantine_approve', // CD5: emitted by api.quarantineApprove; lockstep
8184
- 'quarantine_reject', // CD5: emitted by api.quarantineReject; lockstep
8185
- ]);
8169
+ const VALID_AUDIT_OPS = new Set(AUDIT_OPS);
8186
8170
  function formatAuditRow(ev) {
8187
8171
  const target = ev.targetId ?? '-';
8188
8172
  const meta = JSON.stringify(ev.metadata ?? {});
@@ -8708,7 +8692,7 @@ function cmdSlack(hippoRoot, args, flags) {
8708
8692
  }
8709
8693
  export function usageText() {
8710
8694
  return `
8711
- Hippo - memory for AI agents that learns what is wrong and stops repeating it
8695
+ Hippo - memory for AI agents that learns what is wrong and ranks it down
8712
8696
 
8713
8697
  Usage: hippo <command> [options]
8714
8698
 
@@ -8718,6 +8702,7 @@ Commands:
8718
8702
  --days <n> Days of git history to seed (default: 365 for --scan, 30 for single)
8719
8703
  --global Init the global store ($HIPPO_HOME or ~/.hippo/)
8720
8704
  --no-hooks Skip auto-detecting and installing agent hooks
8705
+ (HIPPO_SKIP_AUTO_INTEGRATIONS=1 does the same)
8721
8706
  --no-schedule Skip auto-creating the machine-level daily runner
8722
8707
  --no-learn Skip seeding memories from git history
8723
8708
  remember <text> Store a memory
@@ -8733,7 +8718,7 @@ Commands:
8733
8718
  --tag <tag> Tag for the new memory (repeatable; default: the old memory's tags)
8734
8719
  --pin Pin the new memory (default: pinned if the old one was)
8735
8720
  recall <query> Search and retrieve memories (local + global)
8736
- --budget <n> Token budget (default: 4000)
8721
+ --budget <n> Token budget for the whole printed block (default: 4000)
8737
8722
  --min-results <n> Minimum results regardless of budget (default: 1)
8738
8723
  --json Output as JSON
8739
8724
  --why Show match reasons and source annotations
@@ -8814,7 +8799,7 @@ Commands:
8814
8799
  handoff/events (use 'hippo session resume' for
8815
8800
  the explicit handoff-without-snapshot path).
8816
8801
  explain <query> Show full score breakdown for each retrieved memory
8817
- --budget <n> Token budget (default: 4000)
8802
+ --budget <n> Token budget, counted as recall prints (default: 4000)
8818
8803
  --limit <n> Cap the number of results displayed
8819
8804
  --json Output as JSON
8820
8805
  --physics | --classic Force search mode (default: from config)
@@ -8845,20 +8830,23 @@ Commands:
8845
8830
  --json Output full summary as JSON
8846
8831
  context Smart context injection for AI agents
8847
8832
  --auto Auto-detect task from git state
8848
- --budget <n> Token budget (default: 1500)
8833
+ --budget <n> Token budget for the whole printed block (default: 1500)
8849
8834
  --pinned-only Only inject pinned memories (used by UserPromptSubmit hook)
8850
8835
  --include-recent <n> With --pinned-only, also inject the last N writes regardless of pinning
8851
8836
  (the hook payload's "prompt" drives prompt recall instead of --include-recent when pinnedInject.promptRecall is on)
8852
8837
  --format <fmt> Output format: markdown (default), json, or additional-context (Claude Code hook JSON)
8853
8838
  --framing <mode> Framing: observe (default), suggest, assert
8854
8839
  sleep Run consolidation pass (auto-learns + dedup + auto-shares)
8840
+ Runs at Claude Code and OpenCode session end and in the daily job.
8841
+ With ANTHROPIC_API_KEY set it sends memory text to Anthropic for
8842
+ fact extraction; {"extraction":{"enabled":false}} turns that off
8855
8843
  --dry-run Preview without writing
8856
8844
  --no-learn Skip auto git-learn before consolidation
8857
8845
  --no-share Skip auto-sharing to global store
8858
8846
  daily-runner Sweep registered workspaces and run daily learn+sleep
8859
8847
  dedup Remove duplicate memories (keeps stronger copy)
8860
8848
  --dry-run Preview without removing
8861
- --threshold <n> Overlap threshold 0-1 (default: 0.7)
8849
+ --threshold <n> Ignored, kept for old scripts: a duplicate is the same text apart from spacing
8862
8850
  status Show memory health stats
8863
8851
  audit [--fix] Check memory quality (--fix removes junk)
8864
8852
  github GitHub connector subcommands (backfill, dlq)
@@ -8882,10 +8870,10 @@ Commands:
8882
8870
  --stats Count memories per DAG level instead
8883
8871
  drill <summary-id> Walk down a DAG level-2 summary to its children
8884
8872
  --limit N Cap children list (default 50)
8885
- --budget N Cap total child token cost (≈ chars/4)
8873
+ --budget N Token budget for the printed children (≈ chars/4)
8886
8874
  --json Output as JSON
8887
8875
  assemble --session <id> Build a session's chronological context window
8888
- --budget N Token budget (default 4000)
8876
+ --budget N Token budget for the printed window (default 4000)
8889
8877
  --fresh-tail N Recent rows always kept verbatim (default 10)
8890
8878
  --no-summarize-older Disable older-row summary substitution
8891
8879
  --scope <s> Restrict to exact scope (default: deny *:private:*)
@@ -8936,8 +8924,10 @@ Commands:
8936
8924
  --out <file> Where to write it (default: hippo-support-<time>.json here)
8937
8925
  --include-logs Add the last ${TAIL_MAX_LINES} lines of each hippo log, known secret shapes removed
8938
8926
  tokens Tokens of memory text hippo handed agents, per surface
8939
- (hook, context, recall, MCP, HTTP), and what skipping
8940
- unchanged hook blocks saved
8927
+ (hook, compact-resume, context, recall, MCP, HTTP), what
8928
+ skipping unchanged hook blocks saved, and how much of the
8929
+ hook and compact-resume blocks later model calls re-read,
8930
+ counted when a session ends. Estimates (characters / 4)
8941
8931
  --days <n> Window in days (default: 30)
8942
8932
  --json Output as JSON
8943
8933
  --global Operate on the global store
@@ -9057,34 +9047,40 @@ Commands:
9057
9047
  capture Extract memories from conversation text
9058
9048
  --stdin Read from piped input
9059
9049
  --file <path> Read from a file
9060
- --last-session Read from the most recent agent session transcript
9050
+ --last-session Read the transcript a hook names on stdin, else the newest
9051
+ Claude Code one from any project
9061
9052
  --transcript <path> Explicit transcript path (implies --last-session)
9062
9053
  --log-file <path> Tee output to a log file (paired with 'hippo last-sleep')
9063
9054
  --dry-run Preview without writing
9064
9055
  --global Write to global store ($HIPPO_HOME or ~/.hippo/)
9065
- setup One-shot: detect installed AI tools and install all
9066
- available SessionEnd+SessionStart+PreCompact hooks
9056
+ setup One-shot: detect installed AI tools and install their hooks:
9057
+ claude-code gets 7 hooks in ~/.claude/settings.json, opencode
9058
+ a plugin, codex 2 hooks in its hooks.json plus a launcher
9059
+ wrapper; other tools get a hint
9067
9060
  --all Install for every JSON-hook tool, even if not detected
9068
9061
  --dry-run Show what would be installed without writing
9069
9062
  --no-schedule Skip installing or repairing the daily runner
9070
- last-sleep Print the last 'hippo sleep --log-file' output and clear it
9063
+ last-sleep Print the last 'hippo sleep --log-file' output to stderr and clear it
9071
9064
  --path <p> Log path (default: ~/.hippo/logs/last-sleep.log)
9072
9065
  --keep Print without clearing
9073
- session-end SessionEnd hook: run sleep, then capture this session, in a detached worker
9066
+ session-end SessionEnd hook: count this session's re-read tokens, run sleep, then
9067
+ capture from the session's last 20 user and 10 assistant messages,
9068
+ in a detached worker
9074
9069
  --log-file <path> Tee the worker's output to a log file (paired with 'hippo last-sleep')
9075
9070
  pre-compact PreCompact hook: save a working-state snapshot before compaction
9076
9071
  --log-file <p> Diagnostic log path (default: ~/.hippo/logs/pre-compact.log)
9077
- compact-resume SessionStart(compact) hook: re-print the snapshot + session trail
9072
+ compact-resume SessionStart(compact) hook: re-print the snapshot, if under 15 minutes old
9078
9073
  post-compact PostCompact hook: tell the user what pre-compact saved
9079
9074
  --log-file <p> Same log path as pre-compact (default: ~/.hippo/logs/pre-compact.log)
9080
9075
  codex-run [-- ...args] Launch real Codex behind Hippo's session-end wrapper
9081
9076
  hook <sub> [target] Manage framework integrations
9082
9077
  hook list Show available hooks
9083
9078
  hook install <target> Install hook (claude-code|codex|cursor|openclaw|opencode|pi)
9084
- claude-code/opencode install SessionEnd+SessionStart;
9085
- claude-code also installs PreCompact +
9086
- SessionStart(compact) for mid-session continuity;
9087
- codex wraps the detected launcher in place
9079
+ claude-code adds 7 hooks to ~/.claude/settings.json;
9080
+ opencode installs a plugin; codex adds 2 hooks to
9081
+ $CODEX_HOME/hooks.json (trust them once in /hooks) and
9082
+ wraps the detected launcher in place; all but claude-code
9083
+ also patch an existing AGENTS.md
9088
9084
  hook uninstall <target> Remove hook
9089
9085
  predict "<claim>" Record a prediction to score against the actual outcome later
9090
9086
  --class <c> Reference class (required)
@@ -9205,7 +9201,7 @@ Commands:
9205
9201
  wm clear Clear working memory entries
9206
9202
  --scope <scope> Filter by scope
9207
9203
  --session <id> Filter by session
9208
- wm flush Flush working memory (session end)
9204
+ wm flush Same as clear; nothing runs it at session end
9209
9205
  --scope <scope> Filter by scope
9210
9206
  --session <id> Filter by session
9211
9207
  dashboard Open web dashboard for memory health
@@ -9447,10 +9443,7 @@ async function main(command, args, flags, hippoRoot) {
9447
9443
  const rememberKindRaw = typeof flags['kind'] === 'string' ? flags['kind'].toLowerCase() : undefined;
9448
9444
  const rememberKindAllowed = ['distilled', 'superseded'];
9449
9445
  if (rememberKindRaw === undefined || rememberKindAllowed.includes(rememberKindRaw)) {
9450
- const tagsRaw = flags['tag'];
9451
- const tags = Array.isArray(tagsRaw)
9452
- ? tagsRaw.map(String)
9453
- : typeof tagsRaw === 'string' ? [tagsRaw] : undefined;
9446
+ const tags = rememberTags(flags, process.cwd()).all;
9454
9447
  // B2 v1.12.6 — validate --owner on the thin-client path too.
9455
9448
  // Failure on this path exits early so the user gets the same
9456
9449
  // validation experience whether or not a server is up.