hippo-memory 1.55.0 → 1.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +11 -0
  2. package/dist/api.d.ts +19 -9
  3. package/dist/api.js +112 -35
  4. package/dist/card-detail.d.ts +1 -1
  5. package/dist/card-detail.js +1 -1
  6. package/dist/cli/shared.d.ts +137 -0
  7. package/dist/cli/shared.js +830 -0
  8. package/dist/cli/sleep.d.ts +10 -0
  9. package/dist/cli/sleep.js +171 -0
  10. package/dist/cli.d.ts +0 -7
  11. package/dist/cli.js +313 -1806
  12. package/dist/config.d.ts +5 -0
  13. package/dist/config.js +21 -0
  14. package/dist/connectors/github/webhook.d.ts +19 -0
  15. package/dist/connectors/github/webhook.js +313 -0
  16. package/dist/connectors/slack/webhook.d.ts +22 -0
  17. package/dist/connectors/slack/webhook.js +203 -0
  18. package/dist/consolidate.js +3 -2
  19. package/dist/context-auto.d.ts +3 -0
  20. package/dist/context-auto.js +34 -0
  21. package/dist/customer-notes.js +2 -1
  22. package/dist/dashboard.js +2 -1
  23. package/dist/db.js +67 -1
  24. package/dist/decisions.js +2 -1
  25. package/dist/delivery-recorder.d.ts +127 -0
  26. package/dist/delivery-recorder.js +218 -0
  27. package/dist/eval-stats.d.ts +58 -0
  28. package/dist/eval-stats.js +111 -0
  29. package/dist/goals.d.ts +49 -25
  30. package/dist/goals.js +39 -22
  31. package/dist/graph-extract.js +1 -1
  32. package/dist/graph-recall.d.ts +1 -1
  33. package/dist/graph-recall.js +1 -1
  34. package/dist/graph.js +1 -1
  35. package/dist/hooks.d.ts +1 -3
  36. package/dist/hooks.js +2 -4
  37. package/dist/http-util.d.ts +31 -0
  38. package/dist/http-util.js +46 -0
  39. package/dist/incidents.js +2 -1
  40. package/dist/index.d.ts +5 -2
  41. package/dist/index.js +5 -2
  42. package/dist/mcp/server.js +173 -285
  43. package/dist/memory.d.ts +19 -0
  44. package/dist/memory.js +38 -0
  45. package/dist/policies.js +2 -1
  46. package/dist/predictions.js +2 -1
  47. package/dist/processes.js +2 -1
  48. package/dist/project-briefs.js +3 -1
  49. package/dist/prompt-recall.js +1 -1
  50. package/dist/recall-history.d.ts +5 -0
  51. package/dist/recall-history.js +9 -0
  52. package/dist/recall-pipeline.d.ts +101 -0
  53. package/dist/recall-pipeline.js +313 -0
  54. package/dist/recall-scope.d.ts +22 -0
  55. package/dist/recall-scope.js +27 -1
  56. package/dist/recall-trace.d.ts +69 -0
  57. package/dist/recall-trace.js +136 -0
  58. package/dist/search.d.ts +0 -20
  59. package/dist/search.js +2 -49
  60. package/dist/server.js +1901 -2384
  61. package/dist/skills.js +2 -1
  62. package/dist/store-cards.d.ts +53 -0
  63. package/dist/store-cards.js +512 -0
  64. package/dist/store.d.ts +2 -89
  65. package/dist/store.js +6 -562
  66. package/dist/tenant.d.ts +22 -0
  67. package/dist/tenant.js +26 -0
  68. package/dist/token-ledger.d.ts +2 -0
  69. package/dist/token-ledger.js +5 -0
  70. package/dist/tokenize.d.ts +2 -0
  71. package/dist/tokenize.js +8 -0
  72. package/dist/version.d.ts +1 -1
  73. package/dist/version.js +1 -1
  74. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  75. package/extensions/openclaw-plugin/package.json +1 -1
  76. package/openclaw.plugin.json +1 -1
  77. package/package.json +2 -1
package/dist/cli.js CHANGED
@@ -37,10 +37,11 @@ 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, detectRealCodexPath, isCodexPresent, isCodexWrapperInstalled, CODEX_TRUST_LINE, repairCodexWrapperIfInstalled, uninstallCodexWrapper, resolveCodexSessionTranscript, resolveCodexWrapperPaths, installOpencodePlugin, uninstallOpencodePlugin, resolveOpencodePluginPath, } from './hooks.js';
40
+ import { execFileSync, spawn } from 'child_process';
41
+ import { installJsonHooks, uninstallJsonHooks, resolveJsonHookPaths, detectInstalledTools, defaultSleepLogPath, ensureCodexWrapperInstalled, installCodexWrapper, detectRealCodexPath, isCodexPresent, isCodexWrapperInstalled, repairCodexWrapperIfInstalled, uninstallCodexWrapper, resolveCodexSessionTranscript, resolveCodexWrapperPaths, installOpencodePlugin, uninstallOpencodePlugin, resolveOpencodePluginPath, } from './hooks.js';
42
42
  import { createMemory, createSuccessor, calculateStrength, calculateRewardFactor, deriveHalfLife, resolveConfidence, confidenceFacets, confidenceLabel, computeSchemaFit, Layer, } from './memory.js';
43
- 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, memoriesBackingObjects, } from './store.js';
43
+ import { getHippoRoot, isInitialized, initStore, writeEntry, strengthenRetrieved, readEntry, deleteEntry, loadAllEntries, loadSearchEntries, loadIndex, saveIndex, loadStats, updateStats, saveActiveTaskSnapshot, loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, closeTaskSnapshotsForSession, clearActiveTaskSnapshot, appendSessionEvent, listSessionEvents, listMemoryConflicts, resolveConflict, saveSessionHandoff, loadLatestHandoff, loadHandoffById, stampHandoffOutcome, writeSessionEndHandoff, memoriesBackingObjects, } from './store.js';
44
+ import { createCard, loadCard, listCards, loadCardRuns, claimCard, heartbeatCard, blockCard, reviewCard, completeCard, reclaimExpiredCards, addCardComment, } from './store-cards.js';
44
45
  import { rejectValue, unrejectValue, listRejectionsForTenant } from './reject-flow.js';
45
46
  import { RejectedValueError } from './rejection.js';
46
47
  import { isHandoffOutcome, formatHandoffEvidenceLine } from './handoff.js';
@@ -48,12 +49,12 @@ 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, fitBudget, hybridSearch, physicsSearch, explainMatch, textOverlap, tokenize as tokenizeQuery } from './search.js';
52
- import { compareEntryIdentity } from './compare.js';
52
+ import { estimateTokens, fitBudget, explainMatch } from './search.js';
53
53
  import { renderTraceContent, parseSteps } from './trace.js';
54
- import { writeRecallTraceAtRoot } from './recall-trace.js';
54
+ import { writeDeliveryEventAtRoot, writeDeliveryEventOnHandle, writeRecallTraceAtRoot } from './recall-trace.js';
55
+ import { createDeliveryRecorder } from './delivery-recorder.js';
55
56
  import { deduplicateStore } from './dedupe.js';
56
- import { isEmbeddingAvailable, embedAll, embedMemory, loadEmbeddingIndex, resolveEmbeddingModel, embeddingModelRequiresReindex, } from './embeddings.js';
57
+ import { embedAll, embedMemory, loadEmbeddingIndex, resolveEmbeddingModel, embeddingModelRequiresReindex, } from './embeddings.js';
57
58
  import { resolveEmbeddingProvider } from './embedding-provider.js';
58
59
  import { loadPhysicsState, resetAllPhysicsState } from './physics-state.js';
59
60
  import { computeSystemEnergy, vecNorm } from './physics.js';
@@ -63,26 +64,27 @@ 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, isSubagentPayload, lastSentState, readApiCalls, recordRereads, recordTokenUse, shouldSkipUnchanged, } from './token-ledger.js';
67
+ import { blockHash, isSubagentPayload, lastSentState, readApiCalls, recordRereads, recordTokenUse, shouldSkipUnchanged, } from './token-ledger.js';
67
68
  import { FAILURE_LOG_RETENTION_DAYS } from './failure-log.js';
68
- import { pushGoal, getActiveGoals, completeGoal, suspendGoal, resumeGoal, applyGoalStackBoost } from './goals.js';
69
+ import { pushGoal, getActiveGoals, completeGoal, suspendGoal, resumeGoal, writeGoalRecallLog } from './goals.js';
69
70
  import { rowToGoal } from './goals.js';
70
- import { captureError, extractLessons, partitionLessons, runWatched, fetchGitLog, isGitRepo, } from './autolearn.js';
71
- import { dropHeldCopies, duplicateKey, storedTextKeys } from './same-text.js';
71
+ import { captureError, runWatched, isGitRepo, } from './autolearn.js';
72
+ import { dropHeldCopies } from './same-text.js';
72
73
  import { currentMachine, importAtCompaction, importAtSessionEnd, importForStore, importProjectMemories, importUserMemories, } from './agent-memories/sync.js';
73
74
  import { detailLines, emptyReport, mergeReports, summaryLine } from './agent-memories/report.js';
74
- import { extractInvalidationTarget, invalidateMatching, detectChurnStale } from './invalidation.js';
75
- import { deriveOriginProject, isGlobalStoreRoot, resolveProjectIdentity } from './project-identity.js';
75
+ import { invalidateMatching } from './invalidation.js';
76
+ import { deriveOriginProject, isGlobalStoreRoot } from './project-identity.js';
76
77
  import { extractPathTags } from './path-context.js';
78
+ import { autoDetectContext } from './context-auto.js';
77
79
  import { detectScope } from './scope.js';
78
- import { getGlobalRoot, initGlobal, shareMemory, listPeers, autoShare, transferScore, searchBothHybrid, syncGlobalToLocal, } from './shared.js';
79
- import { DAILY_TASK_NAME, buildDailyRunnerCommand, buildSchtasksCreateArgs, buildWindowsTaskRun, listRegisteredWorkspaces, registerWorkspace, runDailyMaintenance, } from './scheduler.js';
80
+ import { getGlobalRoot, initGlobal, shareMemory, listPeers, autoShare, transferScore, syncGlobalToLocal, } from './shared.js';
81
+ import { listRegisteredWorkspaces, registerWorkspace, runDailyMaintenance, } from './scheduler.js';
80
82
  import { importChatGPT, importClaude, importCursor, importGenericFile, importMarkdown, importVault, } from './importers.js';
81
- import { cmdCapture, cmdPreCompact, cmdPostCompact, resolveLastSessionTranscript, truncateCodePointSafe, sanitizeLogMessage, transcriptWorkingState } from './capture.js';
83
+ import { cmdCapture, cmdPreCompact, cmdPostCompact, resolveLastSessionTranscript, truncateCodePointSafe, transcriptWorkingState } from './capture.js';
82
84
  import { COMPACTION_DB_WAIT_MS, replayCompactionsAt } from './compaction-record.js';
83
85
  import { readStdinBounded } from './stdin.js';
84
- import { auditMemories, appendAuditEvent, auditQueryFields, AUDIT_OPS, } from './audit.js';
85
- import { listApiKeys, revokeApiKey } from './auth.js';
86
+ import { auditMemories, auditQueryFields, AUDIT_OPS, } from './audit.js';
87
+ import { listApiKeys } from './auth.js';
86
88
  import { buildProvenanceCoverage } from './provenance-coverage.js';
87
89
  import { buildCorrectionLatency } from './correction-latency.js';
88
90
  import * as api from './api.js';
@@ -97,8 +99,7 @@ import * as briefsModule from './project-briefs.js';
97
99
  import * as customerNotesModule from './customer-notes.js';
98
100
  import { extractGraph } from './graph-extract.js';
99
101
  import { buildGraphModel, renderGraphHtml, renderGraphCanvas, DEFAULT_VIEW_LIMIT } from './graph-view.js';
100
- import { createHash } from 'node:crypto';
101
- import { detectAnchoring, hashQueryText, buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, } from './recall-history.js';
102
+ import { detectAnchoring, hashQueryText, biasHintEnabled, buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, } from './recall-history.js';
102
103
  import { detectAvailabilityBias } from './availability.js';
103
104
  // v0.33 / J1 — Module-level per-(tenant, session) recall-history ring map.
104
105
  // Each CLI process maintains its OWN Map; no IPC / no cross-process sharing
@@ -123,16 +124,14 @@ export function __resetSessionRecallHistoryCli() {
123
124
  sessionRecallHistoryCli.clear();
124
125
  }
125
126
  import * as client from './client.js';
126
- import { detectServer, removePidfileIfOwned } from './server-detect.js';
127
127
  import { resolveTenantId } from './tenant.js';
128
128
  import { runEval, bootstrapCorpus, compareSummaries } from './eval.js';
129
129
  import { runFeatureEval, formatResult, resultToBaseline, detectRegressions } from './eval-suite.js';
130
130
  import { refineStore } from './refine-llm.js';
131
131
  import { wmPush, wmRead, wmClear, wmFlush } from './working-memory.js';
132
- import { multihopSearch } from './multihop.js';
133
- import { graphExpandRecall, MAX_HOPS, DEFAULT_MAX_NEIGHBORS } from './graph-recall.js';
134
- import { DEFAULT_GRAPH_STREAM_WEIGHT } from './graph-stream.js';
132
+ import { MAX_HOPS, DEFAULT_MAX_NEIGHBORS } from './graph-recall.js';
135
133
  import { getReranker } from './rerankers/index.js';
134
+ import { rankRecall } from './recall-pipeline.js';
136
135
  import { JEV_DEFAULT_TOP_K } from './rerankers/jev.js';
137
136
  import { computeSalience } from './salience.js';
138
137
  import { renderAmbientSummary } from './ambient.js';
@@ -144,145 +143,10 @@ import { backfillChannel } from './connectors/slack/backfill.js';
144
143
  import { slackHistoryFetcher } from './connectors/slack/web-client.js';
145
144
  import { addWorkspace as addSlackWorkspace, listWorkspaces as listSlackWorkspaces, removeWorkspace as removeSlackWorkspace, } from './connectors/slack/workspaces.js';
146
145
  import { cmdGithub, printGithubBackfillUsage } from './connectors/github/cli-impl.js';
146
+ import { parseLimitFlag, parseCountFlag, parseBudgetFlag, emitCliAudit, requireInit, runChurnStaleForRepo, runViaServerIfAvailable, fmt, recallEntryText, recallHeading, printAgentImport, hippoBlock, installCodexMemoryHooks, setupDailySchedule, parseAsOfFlag, engineFlags, collectHandoffEvidence, logSessionEndImport, appendSessionEndCloseLog, printActiveTaskSnapshot, printSessionEvents, printHandoff, cardStringFlag, hostSessionId, resetHookInjection, captureConsole, hookStoreRoot, withLedgerDb, learnFromRepo, HOOK_MARKERS, HOOKS, resolveAuthRoot, } from './cli/shared.js';
147
147
  // ---------------------------------------------------------------------------
148
148
  // Helpers
149
149
  // ---------------------------------------------------------------------------
150
- function parseLimitFlag(value) {
151
- if (!value)
152
- return Infinity;
153
- const parsed = parseInt(String(value), 10);
154
- return Number.isFinite(parsed) && parsed >= 1 ? parsed : Infinity;
155
- }
156
- function parseCountFlag(value) {
157
- if (!value || value === true || Array.isArray(value))
158
- return 0;
159
- const parsed = parseInt(String(value), 10);
160
- return Number.isFinite(parsed) && parsed >= 1 ? parsed : 0;
161
- }
162
- function parseBudgetFlag(value, fallback) {
163
- if (value === undefined)
164
- return fallback;
165
- // A value-less flag and a junk value are different typos; the --hops guard already splits them.
166
- if (typeof value !== 'string') {
167
- console.error('--budget requires an integer value (e.g. --budget 1500).');
168
- process.exit(1);
169
- }
170
- // Number(), like the --hops guard: parseInt('12abc') is 12, silently accepting what this message rejects.
171
- const parsed = Number(value);
172
- if (!Number.isInteger(parsed) || parsed < 0) {
173
- console.error(`Invalid --budget: "${value}". Must be a non-negative integer.`);
174
- process.exit(1);
175
- }
176
- return parsed;
177
- }
178
- /**
179
- * Emit an audit event against `hippoRoot`'s db. Opens its own short-lived
180
- * connection so callers don't have to thread a db handle. Swallows all errors
181
- * — audit must never crash a CLI command.
182
- */
183
- function emitCliAudit(hippoRoot, op, targetId, metadata) {
184
- try {
185
- const db = openHippoDb(hippoRoot);
186
- try {
187
- appendAuditEvent(db, {
188
- tenantId: resolveTenantId({}),
189
- actor: 'cli',
190
- op,
191
- targetId,
192
- metadata,
193
- });
194
- }
195
- finally {
196
- closeHippoDb(db);
197
- }
198
- }
199
- catch {
200
- // Audit is best-effort; surface failures only via missing rows.
201
- }
202
- }
203
- function requireInit(hippoRoot) {
204
- if (!isInitialized(hippoRoot)) {
205
- console.error(`No hippo store at ${hippoRoot} (searched ${process.cwd()} and its parents up to your home directory). Run \`hippo init\` first.`);
206
- process.exit(1);
207
- }
208
- }
209
- /** FE2: run detectChurnStale against every store this repo's memories can live in. */
210
- function runChurnStaleForRepo(hippoRoot, dryRun) {
211
- const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: process.cwd(), encoding: 'utf8', windowsHide: true }).trim();
212
- const projectName = resolveProjectIdentity(process.cwd()).name;
213
- const globalRoot = getGlobalRoot();
214
- const roots = globalRoot !== hippoRoot && isInitialized(globalRoot) ? [hippoRoot, globalRoot] : [hippoRoot];
215
- const tenantId = resolveTenantId({});
216
- return roots.map((root) => {
217
- // One store failing must not abort sleep's later phases or skip the other store.
218
- try {
219
- return { root, result: detectChurnStale(root, repoRoot, { tenantId, projectName, dryRun }) };
220
- }
221
- catch (err) {
222
- const message = err instanceof Error ? err.message : String(err);
223
- return { root, result: { checked: 0, marked: 0, alreadyMarked: 0, skippedPinned: [], dryRun, preview: [], error: message } };
224
- }
225
- });
226
- }
227
- /**
228
- * H2: when HIPPO_REQUIRE_SERVER is set, the CLI must not silently fall back to
229
- * direct DB mode — a missing server then masks a real misconfiguration (the
230
- * configured HIPPO_API_KEY is also silently discarded on fallback). Throws a
231
- * clear error then. It guards only the routed writes (remember, forget, archive,
232
- * promote); every other command opens the store directly, knob or not.
233
- */
234
- function failIfServerRequired(reason) {
235
- if (process.env['HIPPO_REQUIRE_SERVER']) {
236
- throw new Error(`hippo: HIPPO_REQUIRE_SERVER is set but ${reason}. ` +
237
- `Start \`hippo serve\`, or unset HIPPO_REQUIRE_SERVER to allow direct-mode fallback.`);
238
- }
239
- }
240
- /**
241
- * Run an HTTP-routed command if a `hippo serve` instance is detected for
242
- * `hippoRoot`. Returns:
243
- * - true if the HTTP path ran (success OR a structured server error that
244
- * was already surfaced to stdout/stderr by `httpFn`),
245
- * - false if no server was detected, or if the detected pidfile turned out
246
- * to be stale (connection refused). On stale, the pidfile is removed
247
- * if it still names that dead server (a newer one may have replaced
248
- * it) and the caller should fall back to the direct path.
249
- *
250
- * Per the A1 plan footgun #1: stale pidfiles must self-heal, not crash.
251
- * H2: when HIPPO_REQUIRE_SERVER is set, both fallback paths throw instead of
252
- * returning false, so a missing server fails loudly rather than silently
253
- * degrading to direct mode.
254
- */
255
- async function runViaServerIfAvailable(hippoRoot, httpFn) {
256
- const info = await detectServer(hippoRoot);
257
- if (!info) {
258
- failIfServerRequired('no running server was detected for this hippoRoot');
259
- return false;
260
- }
261
- const apiKey = process.env['HIPPO_API_KEY'];
262
- try {
263
- await httpFn(info, apiKey);
264
- return true;
265
- }
266
- catch (err) {
267
- const failure = client.classifyTransportFailure(err);
268
- if (failure === 'never-sent') {
269
- failIfServerRequired('the server pidfile was stale (connection refused)');
270
- console.error('hippo: stale server pidfile detected, falling back to direct mode');
271
- // Clear the pidfile only if it still names the dead server we just
272
- // probed — a newer server may have rewritten it (removePidfileIfOwned).
273
- removePidfileIfOwned(hippoRoot, { pid: info.pid, startedAt: info.started_at });
274
- return false;
275
- }
276
- if (failure === 'delivery-unknown') {
277
- // Every caller of this helper is a non-idempotent write, so replaying on
278
- // the direct path would store a row the server may already have committed.
279
- // Leave the pidfile alone: the next command's connect-phase failure heals it.
280
- console.error(`hippo: the connection to ${info.url} dropped mid-request, so the write may already have been applied. Not retrying locally. Check with \`hippo recall\` before running this again.`);
281
- process.exit(1);
282
- }
283
- throw err;
284
- }
285
- }
286
150
  // Every switch the CLI reads. A value on one reads as on under Boolean() (`--fix=false` would fix)
287
151
  // and as off under === true (`--pin=true` would not pin), so parseArgs and main() refuse one.
288
152
  // tests/cli-parse-flag-equals.test.ts fails when a switch read is missing from this set.
@@ -409,51 +273,6 @@ export function parseArgs(argv) {
409
273
  }
410
274
  return { command, args, flags };
411
275
  }
412
- function fmt(n, digits = 2) {
413
- return n.toFixed(digits);
414
- }
415
- // What `hippo recall` prints for one result; the budget prices this same text.
416
- function recallEntryText(r, query, showWhy, isGlobal) {
417
- const e = r.entry;
418
- const label = confidenceLabel(e);
419
- const confLabel = label.warn ? `[${label.text}] ⚠️` : `[${label.text}]`;
420
- const bars = Math.round(e.strength * 10);
421
- const graphMark = r.graphVia ? ` [graph: ${r.graphVia.hops}hop ${r.graphVia.relType}]` : '';
422
- const lines = [
423
- `--- ${e.id} [${e.layer}] ${confLabel}${isGlobal ? ' [global]' : ''}${e.superseded_by ? ' [superseded]' : ''}${graphMark} score=${fmt(r.score, 3)} strength=${fmt(e.strength)}`,
424
- ` [${'█'.repeat(bars)}${'░'.repeat(10 - bars)}] tags: ${e.tags.join(', ') || 'none'} | retrieved: ${e.retrieval_count}x`,
425
- ];
426
- if (showWhy) {
427
- const explanation = explainMatch(query, r);
428
- lines.push(` source:${isGlobal ? ' [global]' : ' [local]'} | layer: [${e.layer}] | confidence: [${label.text}]`, ` reason: ${explanation.reason}`);
429
- const env = explanation.envelope;
430
- if (env) {
431
- lines.push(` kind: ${env.kind}`);
432
- if (env.scope)
433
- lines.push(` scope: ${env.scope}`);
434
- if (env.owner)
435
- lines.push(` owner: ${env.owner}`);
436
- if (env.artifact_ref)
437
- lines.push(` artifact_ref: ${env.artifact_ref}`);
438
- if (env.session_id)
439
- lines.push(` session_id: ${env.session_id}`);
440
- lines.push(` confidence: ${env.confidence}`);
441
- }
442
- // A7 recall-trace, e.g. "ranking: base 0.420 -> interference x0.30 -> 0.126 -> goal-boost x1.50 -> 0.189".
443
- if (r.rerankTrace && r.rerankTrace.length > 0) {
444
- const parts = [`base ${fmt(r.rerankTrace[0].scoreBefore, 3)}`];
445
- for (const step of r.rerankTrace) {
446
- parts.push(`${step.stage}${step.multiplier !== undefined ? ` x${fmt(step.multiplier, 2)}` : ''}`, fmt(step.scoreAfter, 3));
447
- }
448
- lines.push(` ranking: ${parts.join(' -> ')}`);
449
- }
450
- }
451
- lines.push('', e.content, '');
452
- return lines.join('\n');
453
- }
454
- function recallHeading(entries, tokens, query) {
455
- return `Found ${entries} memories (${tokens} tokens) for: "${query}"\n`;
456
- }
457
276
  // JSON.stringify keeps quotes or parens in the matched phrase from blurring the line.
458
277
  function planningLine(p) {
459
278
  if (p.hint)
@@ -632,14 +451,6 @@ function cmdInit(hippoRoot, flags) {
632
451
  if (!flags['no-learn'])
633
452
  printAgentImport(importForStore(hippoRoot, { machine: currentMachine() }));
634
453
  }
635
- /** One line when an agent memory import moved anything; its warnings go to stderr. */
636
- function printAgentImport(report, indent = ' ') {
637
- const line = summaryLine(report);
638
- if (line !== null)
639
- console.log(`${indent}${line}`);
640
- for (const warning of report.warnings)
641
- console.error(`hippo: agent memories: ${warning}`);
642
- }
643
454
  /** Every write init makes into agent config (instruction blocks, hooks, plugins) is an automatic integration, so one switch skips them all. */
644
455
  function initInstallsIntegrations(flags) {
645
456
  if (flags['no-hooks'])
@@ -697,19 +508,6 @@ function patchInstructionFiles(dir, agents) {
697
508
  console.log(` Auto-installed ${hook} hook in ${hookDef.file}`);
698
509
  }
699
510
  }
700
- /** The first hippo block in `text` and the agent whose current or shipped text it is; `owner` is undefined for an edited block. */
701
- function hippoBlock(text) {
702
- const at = text.indexOf(HOOK_MARKERS.start);
703
- const start = at + HOOK_MARKERS.start.length;
704
- const end = text.indexOf(HOOK_MARKERS.end, start);
705
- if (at < 0 || end < 0)
706
- return null;
707
- // git autocrlf checks these files out with CRLF: match as LF, write back in the file's own ending.
708
- const raw = text.slice(start, end);
709
- const inner = raw.replace(/\r\n/g, '\n').trim();
710
- const owner = Object.keys(HOOKS).find((k) => HOOKS[k].content === inner) ?? SHIPPED_HOOK_HASHES.get(createHash('sha256').update(inner).digest('hex'));
711
- return { start, end, eol: raw.includes('\r\n') ? '\r\n' : '\n', inner, owner };
712
- }
713
511
  /** Swap an unedited block from an earlier hippo for the current one; an edited block stays, with a hint. */
714
512
  function refreshShippedBlock(filePath, text, hook) {
715
513
  const block = hippoBlock(text);
@@ -724,22 +522,6 @@ function refreshShippedBlock(filePath, text, hook) {
724
522
  fs.writeFileSync(filePath, `${text.slice(0, start)}${eol}${HOOKS[owner].content.replace(/\n/g, eol)}${eol}${text.slice(end)}`, 'utf8');
725
523
  console.log(` Refreshed the ${owner} hippo block in ${name}`);
726
524
  }
727
- /** Adds hippo's two Codex hooks and says what changed; each install ends on the trust reminder, since Codex skips an untrusted hook. */
728
- function installCodexMemoryHooks(indent) {
729
- const result = installJsonHooks('codex');
730
- if (result.invalidJson) {
731
- console.log(`${indent}WARNING: ${result.settingsPath} is not a hooks file hippo can merge into; fix it, then run \`hippo hook install codex\`.`);
732
- return;
733
- }
734
- const added = [
735
- result.installedUserPromptSubmit ? 'UserPromptSubmit' : '',
736
- result.installedCompactResume ? 'SessionStart(compact)' : '',
737
- ].filter(Boolean);
738
- console.log(added.length > 0
739
- ? `${indent}Installed hippo's Codex memory hooks (${added.join(', ')}) in ${result.settingsPath}`
740
- : `${indent}hippo's Codex memory hooks already in ${result.settingsPath}`);
741
- console.log(`${indent}${CODEX_TRUST_LINE}`);
742
- }
743
525
  /** Claude Code settings hooks, Codex's hooks.json and the OpenCode plugin, under the home directory; idempotent, so re-running init adds newer hooks. */
744
526
  function installUserLevelHooks(agents, codexHint) {
745
527
  for (const hook of agents) {
@@ -803,66 +585,6 @@ function installUserLevelHooks(agents, codexHint) {
803
585
  }
804
586
  }
805
587
  }
806
- /**
807
- * Set up a machine-level daily runner that sweeps all registered Hippo
808
- * workspaces.
809
- * Linux/macOS: writes to user crontab.
810
- * Windows: creates a scheduled task.
811
- * Skips if already installed.
812
- */
813
- function setupDailySchedule(globalRoot) {
814
- const runnerDir = path.resolve(globalRoot);
815
- // Reject paths with characters that could break shell/crontab quoting
816
- // (backslash is normal on Windows, only dangerous in Unix shell/crontab)
817
- const unsafeChars = process.platform === 'win32' ? /["`$%\n\r]/ : /["`$\n\r\\]/;
818
- if (unsafeChars.test(runnerDir)) {
819
- console.log(` Skipping schedule: runner path contains unsafe characters.`);
820
- return;
821
- }
822
- const isWindows = process.platform === 'win32';
823
- const taskName = DAILY_TASK_NAME;
824
- const cmd = buildDailyRunnerCommand(runnerDir);
825
- if (isWindows) {
826
- // Check if task already exists
827
- try {
828
- const existing = execSync(`schtasks /query /tn "${taskName}" 2>nul`, { encoding: 'utf-8', windowsHide: true });
829
- if (existing.includes(taskName)) {
830
- return; // already scheduled
831
- }
832
- }
833
- catch {
834
- // Task doesn't exist, create it
835
- }
836
- try {
837
- execFileSync('schtasks', buildSchtasksCreateArgs(taskName, cmd), { stdio: 'pipe', windowsHide: true });
838
- console.log(` Scheduled machine-level daily runner (6:15am) via Task Scheduler: ${taskName}`);
839
- }
840
- catch {
841
- // No admin rights or schtasks unavailable, fall back to printing instructions
842
- console.log(` To schedule the machine-level daily runner, run:`);
843
- console.log(` schtasks /create /tn "${taskName}" /tr "${buildWindowsTaskRun(cmd).replace(/"/g, '\\"')}" /sc daily /st 06:15`);
844
- }
845
- }
846
- else {
847
- // Unix: check crontab for existing entry
848
- const marker = `# hippo:${taskName}`;
849
- try {
850
- const existing = execSync('crontab -l 2>/dev/null', { encoding: 'utf-8', windowsHide: true });
851
- if (existing.includes(marker)) {
852
- return; // already scheduled
853
- }
854
- const cronLine = `15 6 * * * ${cmd} ${marker}`;
855
- const newCrontab = existing.trimEnd() + '\n' + cronLine + '\n';
856
- execSync('crontab -', { input: newCrontab, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
857
- console.log(` Scheduled machine-level daily runner (6:15am) via crontab`);
858
- }
859
- catch {
860
- const cronLine = `15 6 * * * ${cmd}`;
861
- console.log(` To schedule the machine-level daily runner, add to crontab (crontab -e):`);
862
- console.log(` ${cronLine}`);
863
- }
864
- }
865
- }
866
588
  // Shared by the direct write and the routed request so both store the same tags.
867
589
  function rememberTags(flags, cwd) {
868
590
  const requested = Array.isArray(flags['tag']) ? [...flags['tag']] : [];
@@ -1049,669 +771,177 @@ function cmdSupersede(hippoRoot, oldId, newContent, flags) {
1049
771
  emitCliAudit(hippoRoot, 'supersede', oldId, { newId: newEntry.id });
1050
772
  console.log(`Superseded ${oldId} → ${newEntry.id}`);
1051
773
  }
774
+ function failWith(message) {
775
+ return () => {
776
+ console.error(message);
777
+ process.exit(1);
778
+ };
779
+ }
780
+ /** `--graph-stream` implies rrf fusion as well as the graph stream; the CLI fuses the local store only. */
781
+ function parseGraphStreamFlags(flags) {
782
+ let hops;
783
+ if (flags['graph-hops'] !== undefined) {
784
+ if (typeof flags['graph-hops'] === 'boolean')
785
+ failWith(`--graph-hops requires an integer value 1..${MAX_HOPS} (e.g. --graph-hops 2).`)();
786
+ const h = Number(flags['graph-hops']);
787
+ if (!Number.isInteger(h) || h < 1 || h > MAX_HOPS) {
788
+ failWith(`Invalid --graph-hops: "${String(flags['graph-hops'])}". Must be an integer 1..${MAX_HOPS}.`)();
789
+ }
790
+ hops = h;
791
+ }
792
+ let seeds;
793
+ if (flags['graph-seeds'] !== undefined) {
794
+ if (typeof flags['graph-seeds'] === 'boolean')
795
+ failWith('--graph-seeds requires a positive integer value (e.g. --graph-seeds 10).')();
796
+ const s = Number(flags['graph-seeds']);
797
+ if (!Number.isInteger(s) || s < 1)
798
+ failWith(`Invalid --graph-seeds: "${String(flags['graph-seeds'])}". Must be a positive integer.`)();
799
+ seeds = s;
800
+ }
801
+ return { hops, seeds };
802
+ }
803
+ function parseHopsFlags(flags) {
804
+ if (flags['hops'] === undefined)
805
+ return {};
806
+ // A value-less `--hops` parses as true, and Number(true) === 1 would silently run a 1-hop expansion.
807
+ if (typeof flags['hops'] === 'boolean')
808
+ return { fail: failWith(`--hops requires an integer value 0..${MAX_HOPS} (e.g. --hops 1).`) };
809
+ const hops = Number(flags['hops']);
810
+ if (!Number.isInteger(hops) || hops < 0 || hops > MAX_HOPS) {
811
+ return { fail: failWith(`Invalid --hops: "${String(flags['hops'])}". Must be an integer 0..${MAX_HOPS}.`) };
812
+ }
813
+ const raw = flags['max-neighbors'];
814
+ if (raw === undefined)
815
+ return { value: { hops, maxNeighbors: DEFAULT_MAX_NEIGHBORS } };
816
+ if (typeof raw === 'boolean')
817
+ return { fail: failWith(`--max-neighbors requires an integer value 1..200.`) };
818
+ const maxNeighbors = Number(raw);
819
+ if (!Number.isInteger(maxNeighbors) || maxNeighbors < 1 || maxNeighbors > 200) {
820
+ return { fail: failWith(`Invalid --max-neighbors: "${String(raw)}". Must be an integer 1..200.`) };
821
+ }
822
+ return { value: { hops, maxNeighbors } };
823
+ }
824
+ function parseRerankerFlag(flags) {
825
+ const name = flags['reranker'] !== undefined ? String(flags['reranker']).trim() : '';
826
+ let fn;
827
+ try {
828
+ fn = getReranker(name);
829
+ }
830
+ catch (err) {
831
+ // An unknown name throws to the top-level handler, as it did when the lookup sat mid-pipeline.
832
+ return { fail: () => { throw err; } };
833
+ }
834
+ if (!fn)
835
+ return {};
836
+ const topK = flags['reranker-top-k'] !== undefined
837
+ ? parseInt(String(flags['reranker-top-k']), 10)
838
+ : name === 'jev' ? JEV_DEFAULT_TOP_K : 50;
839
+ return { value: { fn, topK } };
840
+ }
841
+ function parseSalienceFlag(flags) {
842
+ const raw = flags['salience-threshold'];
843
+ if (raw === undefined)
844
+ return {};
845
+ const threshold = Number(raw);
846
+ if (!Number.isFinite(threshold) || threshold <= 0) {
847
+ return { fail: failWith(`Invalid --salience-threshold: "${String(raw)}". Must be a positive number.`) };
848
+ }
849
+ return { value: threshold };
850
+ }
851
+ function parseChoiceFlag(flags, name, valid) {
852
+ const value = flags[name] !== undefined ? String(flags[name]).trim() : '';
853
+ if (!value)
854
+ return {};
855
+ if (!valid.includes(value))
856
+ return { fail: failWith(`Invalid --${name}: "${value}". Must be one of: ${valid.join(', ')}.`) };
857
+ return { value };
858
+ }
859
+ /** Parses every flag a ranking stage reads; the first invalid one in pipeline order becomes `error`. */
860
+ function parseRecallLateFlags(flags) {
861
+ const graphHops = parseHopsFlags(flags);
862
+ const reranker = parseRerankerFlag(flags);
863
+ const salience = parseSalienceFlag(flags);
864
+ const outcome = parseChoiceFlag(flags, 'outcome', ['success', 'failure', 'partial']);
865
+ const layer = parseChoiceFlag(flags, 'layer', Object.values(Layer));
866
+ const staged = [
867
+ ['expand', graphHops.fail], ['rerank', reranker.fail], ['salience', salience.fail], ['outcome', outcome.fail], ['layer', layer.fail],
868
+ ];
869
+ let error;
870
+ for (const [stage, fail] of staged) {
871
+ if (fail) {
872
+ error = { stage, fail };
873
+ break;
874
+ }
875
+ }
876
+ return {
877
+ graphHops: graphHops.value,
878
+ reranker: reranker.value,
879
+ salienceThreshold: salience.value,
880
+ outcome: outcome.value,
881
+ layer: layer.value,
882
+ error,
883
+ };
884
+ }
1052
885
  async function cmdRecall(hippoRoot, query, flags) {
1053
886
  requireInit(hippoRoot);
1054
887
  const budget = parseBudgetFlag(flags['budget'], 4000);
1055
888
  const limit = parseLimitFlag(flags['limit']);
1056
889
  const asJson = Boolean(flags['json']);
1057
890
  const showWhy = Boolean(flags['why']);
1058
- const forcePhysics = Boolean(flags['physics']);
1059
- const forceClassic = Boolean(flags['classic']);
1060
891
  const includeSuperseded = Boolean(flags['include-superseded']);
1061
- const asOf = typeof flags['as-of'] === 'string' ? flags['as-of'] : undefined;
1062
- if (asOf !== undefined && Number.isNaN(new Date(asOf).getTime())) {
1063
- console.error(`Error: --as-of value "${asOf}" is not a valid ISO date (e.g. 2026-04-22 or 2026-04-22T12:00:00Z).`);
1064
- process.exit(1);
1065
- }
892
+ const asOf = parseAsOfFlag(flags);
1066
893
  const globalRoot = getGlobalRoot();
1067
894
  const primaryIsGlobal = isGlobalStoreRoot(hippoRoot);
1068
- // A5 stub auth: resolve the active tenant once and thread it through every
1069
- // recall-time SELECT against `memories`. Cross-tenant rows must never surface.
895
+ // Cross-tenant rows must never surface, so the tenant is resolved once and threaded through every load.
1070
896
  const tenantId = resolveTenantId({});
1071
- // v1.25.0 (v39 follow-up #1): the direct CLI recall path applies the recall
1072
- // scope rule. SQL half via loadRecallSearchEntries in 'additive' mode —
1073
- // default-deny (`unknown:legacy` quarantine) always, and an explicit
1074
- // `--scope X` UNLOCKS envelope scope X on top of the default-admitted set
1075
- // (the CLI flag is historically a tag-boost hint over scope-NULL rows, so
1076
- // api.recall's narrowing exact-match would empty every tag-scoped recall —
1077
- // see passesCliRecallScopeFilter in recall-scope.ts). The regex-only
1078
- // `<source>:private:*` half is the JS post-filter below. Hoisted here
1079
- // (it previously lived with the boost flags): recallExplicitScope is the
1080
- // FILTER input; recallActiveScope (which falls back to detectScope()) stays
1081
- // boost-only — auto-detection must never become a filter input or
1082
- // detected-project recalls would change shape.
897
+ // The explicit --scope is the filter input; the detected scope only boosts, so auto-detection never filters.
1083
898
  const recallExplicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
1084
- const requestedScopeForFilter = recallExplicitScope || undefined;
1085
- const loadSuperseded = includeSuperseded || Boolean(asOf);
1086
- let localEntries = loadRecallSearchEntries(hippoRoot, query, undefined, tenantId, requestedScopeForFilter, 'additive', loadSuperseded);
1087
- let globalEntries = globalRoot !== hippoRoot && isInitialized(globalRoot)
1088
- ? loadRecallSearchEntries(globalRoot, query, undefined, tenantId, requestedScopeForFilter, 'additive', loadSuperseded)
1089
- : [];
1090
- // v1.12.13 / C5 — WYSIATI counters. Track filter activity per the plan v3
1091
- // Task 3 mapping table. dropped_pre_rank is the SUM of all non-budget
1092
- // filter drops (pre-rank AND post-rank); its meaning is unchanged by C5.
1093
- //
1094
- // C5 (2026-08-24): search-engine internal drops (scored-to-zero rows that
1095
- // hybridSearch/physicsSearch returns fewer of than they were given) now
1096
- // count toward droppedByBudget, not "not counted at all" as the old v1
1097
- // convention had it. That old convention is exactly why the `Cutoff:` line
1098
- // never printed: cmdRecall measured droppedByBudget from `results.length -
1099
- // limit` (cli.ts ~1547) AFTER the search call, but `results` had already
1100
- // been ranked and truncated by the search engine to a handful of rows, so
1101
- // `limit < results.length` was almost always false and the counter stayed
1102
- // 0 while hundreds of candidates silently vanished (see the plan's measured
1103
- // table: 397 of 400 candidates gone, every counter reading 0). droppedByBudget
1104
- // is now derived as "everything not attributed to a named pre-rank filter",
1105
- // computed after the final `--limit` slice — see the definition near
1106
- // line ~1545 for the exact formula and the double-count argument.
1107
- // totalCandidates = post-SQL-predicate count (api.recall parity: measured
1108
- // after loadRecallSearchEntries, before the JS scope filter). NOTE the
1109
- // v1.12.13 accounting convention: SQL-excluded rows (quarantine + the
1110
- // v1.25.0 pre-window ':private:' exclusion) are pre-candidate and are NOT
1111
- // counted as drops; the JS half below normally drops 0 and exists as
1112
- // defense-in-depth (LIKE/regex divergence, exact-mode mismatch).
1113
- const totalCandidatesCountCmd = localEntries.length + globalEntries.length;
1114
- let droppedPreRankCountCmd = 0;
1115
- // Graph expansion adds candidates AFTER totalCandidatesCountCmd is taken,
1116
- // so they are folded back in before the budget residual is derived.
1117
- let graphAddedCountCmd = 0;
1118
- // v1.25.0: JS half of the recall scope rule (private-scope regex deny with
1119
- // explicit-request unlock), via the canonical helper — do not inline a
1120
- // fourth copy of this predicate.
1121
- const passesRecallScope = (e) => api.passesCliRecallScopeFilter(e.scope ?? null, requestedScopeForFilter);
1122
- const beforeScopeFilterCmd = localEntries.length + globalEntries.length;
1123
- localEntries = localEntries.filter(passesRecallScope);
1124
- globalEntries = globalEntries.filter(passesRecallScope);
1125
- droppedPreRankCountCmd += beforeScopeFilterCmd - (localEntries.length + globalEntries.length);
1126
- // Bi-temporal filtering for physics path (hybridSearch handles it internally)
1127
- if (asOf) {
1128
- const filterAsOf = (entries) => {
1129
- const asOfDate = new Date(asOf);
1130
- const successorValidFrom = new Map();
1131
- for (const e of entries) {
1132
- if (e.superseded_by) {
1133
- const successor = entries.find(s => s.id === e.superseded_by);
1134
- if (successor)
1135
- successorValidFrom.set(e.id, successor.valid_from);
1136
- }
1137
- }
1138
- return entries.filter(e => {
1139
- if (new Date(e.valid_from) > asOfDate)
1140
- return false;
1141
- if (!e.superseded_by)
1142
- return true;
1143
- const succVf = successorValidFrom.get(e.id);
1144
- return succVf ? new Date(succVf) > asOfDate : true;
1145
- });
1146
- };
1147
- const beforeAsOf = localEntries.length + globalEntries.length;
1148
- localEntries = filterAsOf(localEntries);
1149
- globalEntries = filterAsOf(globalEntries);
1150
- droppedPreRankCountCmd += beforeAsOf - (localEntries.length + globalEntries.length);
1151
- }
1152
- else if (!includeSuperseded) {
1153
- const beforeSupersededDrop = localEntries.length + globalEntries.length;
1154
- localEntries = localEntries.filter(e => !e.superseded_by);
1155
- globalEntries = globalEntries.filter(e => !e.superseded_by);
1156
- droppedPreRankCountCmd += beforeSupersededDrop - (localEntries.length + globalEntries.length);
1157
- }
1158
- const hasGlobal = globalEntries.length > 0;
1159
- // Determine search mode: --physics forces physics, --classic forces BM25+cosine,
1160
- // default uses physics if config.physics.enabled is not false
1161
899
  const config = loadConfig(hippoRoot);
1162
- const usePhysics = forcePhysics
1163
- || (!forceClassic && config.physics.enabled !== false);
1164
- const noMmr = Boolean(flags['no-mmr']);
1165
- const mmrLambda = flags['mmr-lambda'] !== undefined
1166
- ? parseFloat(String(flags['mmr-lambda']))
1167
- : config.mmr.lambda;
1168
- const mmrEnabled = !noMmr && config.mmr.enabled;
1169
- const localBump = flags['equal-sources']
1170
- ? 1.0
1171
- : flags['local-bump'] !== undefined
1172
- ? parseFloat(String(flags['local-bump']))
1173
- : config.search.localBump;
1174
900
  const minResults = flags['min-results'] !== undefined
1175
901
  ? parseInt(String(flags['min-results']), 10)
1176
902
  : undefined;
1177
- // recallExplicitScope hoisted above the candidate loads (v1.25.0) — see the
1178
- // scope-filter block near the top of cmdRecall.
1179
903
  const recallActiveScope = recallExplicitScope || detectScope();
1180
- const useMultihop = flags['multihop'] === true || config.multihop.enabled;
1181
- // L1 — graph-retrieval stream. `--graph-stream` forces rrf fusion + the graph stream
1182
- // (see src/graph-stream.ts). NOTE: it implies `scoring:'rrf'` (production default is
1183
- // 'blend'), so the flag bundles two behaviours by design. CLI surface is local-store
1184
- // only for now; the library API supports a globalRoot for callers who need it.
1185
- const useGraphStream = flags['graph-stream'] === true;
1186
- let graphStreamHops;
1187
- if (useGraphStream && flags['graph-hops'] !== undefined) {
1188
- if (typeof flags['graph-hops'] === 'boolean') {
1189
- console.error(`--graph-hops requires an integer value 1..${MAX_HOPS} (e.g. --graph-hops 2).`);
1190
- process.exit(1);
1191
- }
1192
- const h = Number(flags['graph-hops']);
1193
- if (!Number.isInteger(h) || h < 1 || h > MAX_HOPS) {
1194
- console.error(`Invalid --graph-hops: "${String(flags['graph-hops'])}". Must be an integer 1..${MAX_HOPS}.`);
1195
- process.exit(1);
1196
- }
1197
- graphStreamHops = h;
1198
- }
1199
- let graphStreamSeeds;
1200
- if (useGraphStream && flags['graph-seeds'] !== undefined) {
1201
- if (typeof flags['graph-seeds'] === 'boolean') {
1202
- console.error('--graph-seeds requires a positive integer value (e.g. --graph-seeds 10).');
1203
- process.exit(1);
1204
- }
1205
- const s = Number(flags['graph-seeds']);
1206
- if (!Number.isInteger(s) || s < 1) {
1207
- console.error(`Invalid --graph-seeds: "${String(flags['graph-seeds'])}". Must be a positive integer.`);
1208
- process.exit(1);
1209
- }
1210
- graphStreamSeeds = s;
1211
- }
904
+ const graphStream = flags['graph-stream'] === true ? parseGraphStreamFlags(flags) : undefined;
905
+ const late = parseRecallLateFlags(flags);
906
+ const goalTag = flags['goal'] !== undefined ? String(flags['goal']).trim() : '';
907
+ const sessionId = (flags['session-id'] !== undefined
908
+ ? String(flags['session-id'])
909
+ : process.env.HIPPO_SESSION_ID ?? '').trim();
1212
910
  // Engines spend the budget on the text each result prints as, less the header, so selection and print agree.
1213
911
  const localIndex = loadIndex(hippoRoot);
1214
912
  const globalOn = isInitialized(globalRoot);
1215
913
  const entryText = (r) => recallEntryText(r, query, showWhy, primaryIsGlobal || (globalOn && !localIndex.entries[r.entry.id]));
1216
914
  const printCost = (r) => printedTokens(entryText(r));
1217
915
  const entryBudget = Math.max(0, budget - printedTokens(recallHeading(budget, budget, query)));
1218
- let results;
1219
- if (useGraphStream) {
1220
- if (!isEmbeddingAvailable()) {
1221
- // The graph stream fuses inside the rrf path, which only runs with embeddings; without
1222
- // them hybridSearch falls back to BM25-only and the stream is inert. Say so plainly
1223
- // rather than silently no-op.
1224
- console.error('[note] --graph-stream needs embeddings (rrf fusion); none available, so the graph stream is inert. Run `hippo embed` first.');
1225
- }
1226
- if (hasGlobal) {
1227
- console.error('[note] --graph-stream searches the local store only; global graph fusion is a follow-up.');
1228
- }
1229
- // The stream anchors on the top-`seedCount` lexical hits and re-ranks the rank>seedCount
1230
- // tail; on a pool with <= seedCount candidates EVERY candidate is a seed and the stream
1231
- // is inert (it degrades to the 2-list fusion). Tune the anchor count with --graph-seeds.
1232
- results = await hybridSearch(query, localEntries, {
1233
- budget: entryBudget, cost: printCost, hippoRoot, mmr: mmrEnabled, mmrLambda, minResults, scope: recallActiveScope,
1234
- includeSuperseded, asOf,
1235
- scoring: 'rrf',
1236
- graphStream: { weight: DEFAULT_GRAPH_STREAM_WEIGHT, tenantId, hops: graphStreamHops, seedCount: graphStreamSeeds },
1237
- });
1238
- }
1239
- else if (useMultihop) {
1240
- // Unlike searchBothHybrid below, multihop ranks one pooled list, so a shared memory's two copies both compete.
1241
- const allEntries = api.oneCopyPerMemory(localEntries, globalEntries, evalNow()).flat();
1242
- results = multihopSearch(query, allEntries, {
1243
- budget: entryBudget,
1244
- cost: printCost,
1245
- hippoRoot,
1246
- minResults,
1247
- includeSuperseded,
1248
- asOf,
1249
- });
1250
- }
1251
- else if (usePhysics && !hasGlobal) {
1252
- results = await physicsSearch(query, localEntries, {
1253
- budget: entryBudget,
1254
- cost: printCost,
1255
- hippoRoot,
1256
- physicsConfig: config.physics,
1257
- minResults,
1258
- scope: recallActiveScope,
1259
- includeSuperseded,
1260
- asOf,
1261
- });
1262
- }
1263
- else if (hasGlobal) {
1264
- // Use searchBothHybrid for merged results with embedding support.
1265
- // recallScope (v1.25.0): searchBothHybrid re-loads candidates internally,
1266
- // so the scope rule must be plumbed in — the filtered localEntries /
1267
- // globalEntries above are NOT what this path ranks.
1268
- results = await searchBothHybrid(query, hippoRoot, globalRoot, {
1269
- budget: entryBudget, cost: printCost, mmr: mmrEnabled, mmrLambda, localBump, minResults, scope: recallActiveScope, tenantId,
1270
- includeSuperseded, asOf,
1271
- recallScope: recallExplicitScope
1272
- ? { requested: recallExplicitScope, additive: true }
1273
- : {},
1274
- });
1275
- }
1276
- else {
1277
- results = await hybridSearch(query, localEntries, {
1278
- budget: entryBudget, cost: printCost, hippoRoot, mmr: mmrEnabled, mmrLambda, minResults, scope: recallActiveScope,
1279
- includeSuperseded, asOf,
1280
- });
1281
- }
1282
- // E3.2 multi-hop graph recall. After the base branch produces `results`, optionally
1283
- // augment with memories reached by walking the entities/relations graph `--hops N` out
1284
- // from the lexical seeds. Runs BEFORE the opt-in re-rankers below so graph-reached
1285
- // results are first-class candidates in any downstream re-ranking / --why. Default OFF
1286
- // (absent or 0 = no-op). Reached memories are loaded directly by id (NOT via the
1287
- // lexical candidate set, which would exclude the orthogonal neighbours graph recall
1288
- // exists to surface); the engine re-applies the same superseded/asOf hard filters.
1289
- if (flags['hops'] !== undefined) {
1290
- // Reject a value-less `--hops` (parseArgs stores boolean true): Number(true) === 1
1291
- // would otherwise silently run a 1-hop expansion when the user fat-fingered the value.
1292
- if (typeof flags['hops'] === 'boolean') {
1293
- console.error(`--hops requires an integer value 0..${MAX_HOPS} (e.g. --hops 1).`);
1294
- process.exit(1);
1295
- }
1296
- const hops = Number(flags['hops']);
1297
- if (!Number.isInteger(hops) || hops < 0 || hops > MAX_HOPS) {
1298
- console.error(`Invalid --hops: "${String(flags['hops'])}". Must be an integer 0..${MAX_HOPS}.`);
1299
- process.exit(1);
1300
- }
1301
- let maxNeighbors = DEFAULT_MAX_NEIGHBORS;
1302
- if (flags['max-neighbors'] !== undefined) {
1303
- if (typeof flags['max-neighbors'] === 'boolean') {
1304
- console.error(`--max-neighbors requires an integer value 1..200.`);
1305
- process.exit(1);
1306
- }
1307
- maxNeighbors = Number(flags['max-neighbors']);
1308
- if (!Number.isInteger(maxNeighbors) || maxNeighbors < 1 || maxNeighbors > 200) {
1309
- console.error(`Invalid --max-neighbors: "${String(flags['max-neighbors'])}". Must be an integer 1..200.`);
1310
- process.exit(1);
1311
- }
1312
- }
1313
- if (hops > 0) {
1314
- // graphExpandRecall can SURFACE rows that were never in the lexical
1315
- // candidate pool (a graph neighbour reached by entity edge, not by
1316
- // query match), and totalCandidatesCountCmd was snapshotted before the
1317
- // search. Without this the derived budget count goes negative, clamps
1318
- // to 0, and the accounting silently breaks: 1 candidate, 2 returned,
1319
- // 0 drops. Found independently by two reviewers. Count the additions
1320
- // so the invariant holds on graph-expanded recalls too.
1321
- // GROSS, not net. graphExpandRecall both adds neighbours AND evicts weak
1322
- // base rows in one call (graph-recall.ts:285), so a net delta of 0 hides
1323
- // 3 added + 3 evicted: the additions escape the candidate total and the
1324
- // evictions escape the drop count, and the Cutoff line goes silent again.
1325
- // Compare ID sets so both directions are counted.
1326
- const beforeGraphIds = new Set(results.map((r) => r.entry.id));
1327
- results = graphExpandRecall(results, {
1328
- hops,
1329
- maxNeighbors,
1330
- hippoRoot,
1331
- globalRoot: isInitialized(globalRoot) && globalRoot !== hippoRoot ? globalRoot : undefined,
1332
- tenantId,
1333
- includeSuperseded,
1334
- asOf,
1335
- budget: entryBudget,
1336
- cost: printCost,
1337
- minResults: minResults ?? 1,
1338
- recallScope: recallExplicitScope
1339
- ? { requested: recallExplicitScope, additive: true }
1340
- : {},
1341
- });
1342
- // Rows the graph surfaced that the lexical pool never held.
1343
- for (const r of results) {
1344
- if (!beforeGraphIds.has(r.entry.id))
1345
- graphAddedCountCmd++;
1346
- }
1347
- }
1348
- }
1349
- // ACC EVC-adaptive recall (RESEARCH.md §PFC.ACC). When the initial top-K is
1350
- // dominated by lexically similar but distinct memories (high pairwise token
1351
- // overlap = same topic, different facts = conflict), allocate extra retrieval
1352
- // effort: take a wider candidate pool, drop low-relevance distractors, and
1353
- // re-rank by recency to surface the most up-to-date item from the cluster.
1354
- // Default off; opt-in via --evc-adaptive.
1355
- if (flags['evc-adaptive'] && results.length >= 2) {
1356
- const sliceSize = Math.min(3, results.length);
1357
- const slice = results.slice(0, sliceSize);
1358
- let pairs = 0;
1359
- let overlapSum = 0;
1360
- for (let i = 0; i < slice.length; i++) {
1361
- for (let j = i + 1; j < slice.length; j++) {
1362
- overlapSum += textOverlap(slice[i].entry.content, slice[j].entry.content);
1363
- pairs++;
1364
- }
1365
- }
1366
- const avgOverlap = pairs > 0 ? overlapSum / pairs : 0;
1367
- if (avgOverlap >= 0.4) {
1368
- const poolSize = Math.min(results.length, Math.max(sliceSize * 3, 9));
1369
- const pool = results.slice(0, poolSize);
1370
- const tail = results.slice(poolSize);
1371
- const maxScore = pool.reduce((m, r) => Math.max(m, r.score), 0);
1372
- const scoreFloor = maxScore * 0.5;
1373
- // On-topic test: score floor OR query coverage. The score floor alone
1374
- // proxied topicality via ranking score, but the disambiguating update
1375
- // this mechanic exists to surface is BY NATURE phrased differently
1376
- // (weaker lexical/embedding overlap), so under the #t2 embed-text
1377
- // format (docs/plans/2026-07-09-recall-determinism.md T1, which
1378
- // de-compressed similarity gaps) it fell below any sane floor
1379
- // (measured 0.33x max on the acc-evc micro fixture). Query coverage —
1380
- // the fraction of query tokens present in the candidate — is the
1381
- // mechanic's own definition of "same topic, different fact" applied
1382
- // to the query, and is score-scale-independent. Principled EVC
1383
- // calibration remains roadmapped as B1 depth.
1384
- const queryTokens = new Set(tokenizeQuery(query));
1385
- const onTopic = [];
1386
- const offTopic = [];
1387
- for (const r of pool) {
1388
- let hits = 0;
1389
- if (queryTokens.size > 0) {
1390
- const candTokens = new Set(tokenizeQuery(r.entry.content));
1391
- for (const t of queryTokens)
1392
- if (candTokens.has(t))
1393
- hits++;
1394
- }
1395
- const queryCoverage = queryTokens.size > 0 ? hits / queryTokens.size : 0;
1396
- (r.score >= scoreFloor || queryCoverage >= 0.6 ? onTopic : offTopic).push(r);
1397
- }
1398
- // Recency is the true primary key (unchanged) — that's the whole
1399
- // point of --evc-adaptive. compareEntryIdentity is only a TAIL for
1400
- // entries created at the exact same timestamp, which previously fell
1401
- // to array/scan order (T2, deterministic tie keys).
1402
- onTopic.sort((a, b) => {
1403
- const ta = new Date(a.entry.created).getTime();
1404
- const tb = new Date(b.entry.created).getTime();
1405
- return tb !== ta ? tb - ta : compareEntryIdentity(a.entry, b.entry);
1406
- });
1407
- results = [...onTopic, ...offTopic, ...tail];
1408
- }
1409
- }
1410
- // vlPFC interference filter (RESEARCH.md §PFC.vlPFC). Suppress task-irrelevant
1411
- // memories using *recorded* supersession + conflict structure only. Default
1412
- // off; opt-in via --filter-conflicts. Two effects, both surgical:
1413
- // 1. Drop entries with `superseded_by` set. (No-op under default recall,
1414
- // which already filters them; matters when `--include-superseded` was
1415
- // passed. The flag re-asserts the gate.)
1416
- // 2. Apply a 0.3x score multiplier to entries whose `conflicts_with` list
1417
- // references another entry that ALSO appears in the result set. The
1418
- // multiplier is conservative — we never delete on conflict, only
1419
- // down-rank, so the user can still surface the loser via --include-*.
1420
- // We never infer conflicts from lexical overlap. The v1 salience gate did
1421
- // that and destroyed LoCoMo (0.28 → 0.02). Recorded structure only.
1422
- if (flags['filter-conflicts']) {
1423
- const beforeFilterConflicts = results.length;
1424
- results = results.filter((r) => !r.entry.superseded_by);
1425
- droppedPreRankCountCmd += beforeFilterConflicts - results.length;
1426
- const presentIds = new Set(results.map((r) => r.entry.id));
1427
- results = results.map((r) => {
1428
- const peers = r.entry.conflicts_with || [];
1429
- const hasPeerInResults = peers.some((peerId) => presentIds.has(peerId));
1430
- if (!hasPeerInResults)
1431
- return r;
1432
- const next = { ...r, score: r.score * 0.3 };
1433
- // A7 recall-trace: interference (vlPFC) down-rank. Only when --why.
1434
- if (showWhy) {
1435
- next.rerankTrace = [
1436
- ...(r.rerankTrace ?? []),
1437
- { stage: 'interference', multiplier: 0.3, scoreBefore: r.score, scoreAfter: next.score },
1438
- ];
1439
- }
1440
- return next;
1441
- });
1442
- // T2 note: PLAIN stable score sort on purpose (here and in the rerank
1443
- // blocks below) -- these are RE-SORTS of an already deterministically-
1444
- // ordered ranking, so sort stability inherits the upstream content-tail
1445
- // determinism, and ties preserve the prior rank rather than reordering
1446
- // by content (a no-signal boost must not shuffle its input).
1447
- results.sort((a, b) => b.score - a.score);
1448
- }
1449
- // vmPFC continuous value attribution (RESEARCH.md §PFC.vmPFC). Continuous
1450
- // value scoring per memory based on cumulative outcome attribution. Memories
1451
- // with positive cumulative outcomes are boosted; those with negative outcomes
1452
- // are demoted. The multiplier is a tanh-shaped function clamped to [0.7, 1.3]
1453
- // — wider than the always-on outcomeBoost (which clamps [0.85, 1.15]) so this
1454
- // flag has additional decisive effect when value attribution should drive
1455
- // ranking. Default off; opt-in via --value-aware. Reuses outcome_positive /
1456
- // outcome_negative columns; no schema change.
1457
- if (flags['value-aware'] && results.length >= 1) {
1458
- results = results.map((r) => {
1459
- const pos = r.entry.outcome_positive ?? 0;
1460
- const neg = r.entry.outcome_negative ?? 0;
1461
- if (pos === 0 && neg === 0)
1462
- return r;
1463
- const raw = 1 + 0.3 * Math.tanh(pos - neg);
1464
- const valueMult = Math.max(0.7, Math.min(1.3, raw));
1465
- const next = { ...r, score: r.score * valueMult };
1466
- // A7 recall-trace: vmPFC continuous value attribution. Only when --why.
1467
- if (showWhy) {
1468
- next.rerankTrace = [
1469
- ...(r.rerankTrace ?? []),
1470
- { stage: 'value', multiplier: valueMult, scoreBefore: r.score, scoreAfter: next.score },
1471
- ];
1472
- }
1473
- return next;
1474
- });
1475
- results.sort((a, b) => b.score - a.score); // T2: plain stable re-sort, see --filter-conflicts note
1476
- }
1477
- // OFC option-value re-ranker MVP (RESEARCH.md §PFC.OFC). Combine relevance,
1478
- // strength, and integration cost into a single utility score and re-sort.
1479
- // OFC neurons encode a "common currency" across heterogeneous attributes
1480
- // (Rangel et al., 2008); this is the simplest demonstration of that mechanism.
1481
- // Default off; opt-in via --rerank-utility.
1482
- //
1483
- // utility = score * (0.5 + 0.5 * strength) * (1 - cost_factor)
1484
- // cost_factor = min(0.3, tokens / 10000)
1485
- //
1486
- // The full OFC spec (option_valuation table in RESEARCH.md) decomposes value
1487
- // into reward / cost / risk / confidence components. The MVP collapses these
1488
- // to: score (relevance proxy), strength (persistence proxy), tokens (cost).
1489
- // CAVEAT: cost penalty is monotone with token count; LoCoMo's harder QAs
1490
- // often live in long evidence-rich memories. Default off — needs LoCoMo
1491
- // eval before enabling broadly.
1492
- if (flags['rerank-utility']) {
1493
- results = results
1494
- .map((r) => {
1495
- const strength = typeof r.entry.strength === 'number' ? r.entry.strength : 1.0;
1496
- const costFactor = Math.min(0.3, (r.tokens || 0) / 10000);
1497
- const utilityMult = (0.5 + 0.5 * strength) * (1 - costFactor);
1498
- const utility = r.score * utilityMult;
1499
- const next = { ...r, score: utility };
1500
- // A7 recall-trace: OFC option-value re-rank. Only when --why.
1501
- if (showWhy) {
1502
- next.rerankTrace = [
1503
- ...(r.rerankTrace ?? []),
1504
- { stage: 'utility', multiplier: utilityMult, scoreBefore: r.score, scoreAfter: utility },
1505
- ];
1506
- }
1507
- return next;
1508
- })
1509
- .sort((a, b) => b.score - a.score); // T2: plain stable re-sort, see --filter-conflicts note
1510
- }
1511
- // F6 reranker pass (docs/plans/2026-05-10-f6-reranker-hardening.md). When
1512
- // --reranker <name> is set, look up the reranker fn from the registry
1513
- // (src/rerankers/index.ts) and apply it to the top-K candidates. The
1514
- // reranker reorders (and may rescale) results; the post-budget set is
1515
- // returned. Default off; opt-in via --reranker <cross-encoder|jev|llm>. The
1516
- // structurally similar --rerank-utility block above is the OFC MVP and is
1517
- // independent — both can run in the same recall, with --rerank-utility
1518
- // applied first. Available rerankers: cross-encoder, jev, llm (see
1519
- // src/rerankers/index.ts). The Track 1 `features` reranker was removed in
1520
- // v1.9.1 per the F10 HARD RETRACTION; it is no longer a valid value.
1521
- const rerankerName = flags['reranker'] !== undefined ? String(flags['reranker']).trim() : '';
1522
- if (rerankerName) {
1523
- const rerankerFn = getReranker(rerankerName);
1524
- if (rerankerFn) {
1525
- const topK = flags['reranker-top-k'] !== undefined
1526
- ? parseInt(String(flags['reranker-top-k']), 10)
1527
- : rerankerName === 'jev' ? JEV_DEFAULT_TOP_K : 50;
1528
- const head = results.slice(0, topK);
1529
- const tail = results.slice(topK);
1530
- const rerankInput = head.map((r, i) => ({ ...r, preRerankRank: i + 1 }));
1531
- const reranked = await rerankerFn(query, rerankInput, { topK });
1532
- // Copy rerankScore into score so downstream blocks (--goal, goal-stack,
1533
- // salience) that sort by `r.score` honor the reranker's order rather
1534
- // than unwinding it. Original score is preserved on rerankScore's
1535
- // input, but downstream sorters key on `score`.
1536
- const withPostRank = reranked.map((r, i) => {
1537
- const next = {
1538
- ...r,
1539
- score: r.rerankScore,
1540
- postRerankRank: i + 1,
1541
- };
1542
- // A7 recall-trace: F6 reranker pass (reorder + rescale; not a scalar
1543
- // multiply, so no multiplier field). Only when --why.
1544
- if (showWhy) {
1545
- next.rerankTrace = [
1546
- ...(r.rerankTrace ?? []),
1547
- { stage: 'reranker', scoreBefore: r.score, scoreAfter: r.rerankScore },
1548
- ];
1549
- }
1550
- return next;
1551
- });
1552
- results = [...withPostRank, ...tail];
1553
- }
1554
- }
1555
- // dlPFC goal-conditioned recall MVP (RESEARCH.md §PFC.dlPFC). When --goal
1556
- // <tag> is set, memories whose `tags` array contains the goal tag receive
1557
- // a 1.5x score boost and results are re-sorted. The full dlPFC spec
1558
- // (goal_stack + retrieval_policy tables) maintains a hierarchical task
1559
- // stack with weighted retrieval policies; this MVP collapses that to a
1560
- // single-tag boost — the smallest demonstrable goal-conditioning signal.
1561
- // Default off; opt-in via --goal <tag>. No schema change.
1562
- const goalTag = flags['goal'] !== undefined ? String(flags['goal']).trim() : '';
1563
- if (goalTag) {
1564
- results = results
1565
- .map((r) => {
1566
- if (!r.entry.tags?.includes(goalTag))
1567
- return r;
1568
- const boosted = { ...r, score: r.score * 1.5 };
1569
- // A7 recall-trace: the explicit `--goal <tag>` boost is a public CLI
1570
- // re-ranker (score *= 1.5) and is mutually exclusive with the session
1571
- // goal-stack boost below, so it must record its OWN step or `--why
1572
- // --goal` produces no ranking line (codex review). Stage `goal`
1573
- // (explicit flag) is distinct from `goal-boost` (session stack).
1574
- if (showWhy) {
1575
- boosted.rerankTrace = [
1576
- ...(r.rerankTrace ?? []),
1577
- { stage: 'goal', multiplier: 1.5, scoreBefore: r.score, scoreAfter: r.score * 1.5, note: `--goal ${goalTag}` },
1578
- ];
1579
- }
1580
- return boosted;
1581
- })
1582
- .sort((a, b) => b.score - a.score); // T2: plain stable re-sort, see --filter-conflicts note
1583
- }
1584
- // dlPFC depth (B3, v0.38; lifted v1.7.4 into applyGoalStackBoost). When
1585
- // HIPPO_SESSION_ID is set (env or --session-id flag) and the
1586
- // (tenant, session) has active goals, the helper boosts memories whose tags
1587
- // overlap any active goal's name and logs (memory, goal) pairs into
1588
- // goal_recall_log. Runs AFTER the explicit `--goal <tag>` block so an
1589
- // explicit flag always wins (gated on `goalTag === ''`).
1590
- const sessionId = (flags['session-id'] !== undefined
1591
- ? String(flags['session-id'])
1592
- : process.env.HIPPO_SESSION_ID ?? '').trim();
1593
- if (sessionId && goalTag === '') {
916
+ const rank = await rankRecall({ hippoRoot, globalRoot: globalRoot !== hippoRoot && globalOn ? globalRoot : undefined, tenantId, note: (line) => console.error(line) }, {
917
+ query, budget: entryBudget, cost: printCost, limit, why: showWhy, includeSuperseded, asOf,
918
+ explicitScope: recallExplicitScope, activeScope: recallActiveScope,
919
+ search: { ...engineFlags(flags, config), multihop: flags['multihop'] === true || config.multihop.enabled, graphStream, minResults, explain: false },
920
+ graphHops: late.graphHops,
921
+ evcAdaptive: Boolean(flags['evc-adaptive']),
922
+ filterConflicts: Boolean(flags['filter-conflicts']),
923
+ valueAware: Boolean(flags['value-aware']),
924
+ rerankUtility: Boolean(flags['rerank-utility']),
925
+ reranker: late.reranker,
926
+ goalTag,
927
+ sessionId,
928
+ salienceThreshold: late.salienceThreshold,
929
+ outcome: late.outcome,
930
+ layer: late.layer,
931
+ haltBefore: late.error?.stage,
932
+ });
933
+ if (rank.goalRecallLog.length > 0) {
1594
934
  const dbForGoals = openHippoDb(hippoRoot);
1595
- // A7 recall-trace: goal-boost is the shared helper, not an inline map. It
1596
- // writes its steps into this SEPARATE accumulator (keyed by entry id), NOT
1597
- // onto the row (the helper strips internal markers on re-spread). Only
1598
- // allocated under --why.
1599
- const goalBoostTrace = showWhy ? new Map() : undefined;
1600
935
  try {
1601
- results = applyGoalStackBoost(dbForGoals, results, {
1602
- sessionId,
1603
- tenantId,
1604
- limit,
1605
- ...(goalBoostTrace ? { trace: goalBoostTrace } : {}),
1606
- });
936
+ writeGoalRecallLog(dbForGoals, rank.goalRecallLog);
1607
937
  }
1608
938
  finally {
1609
939
  closeHippoDb(dbForGoals);
1610
940
  }
1611
- // Merge the accumulated goal-boost steps onto the matching SearchResult.
1612
- if (goalBoostTrace && goalBoostTrace.size > 0) {
1613
- results = results.map((r) => {
1614
- const step = goalBoostTrace.get(r.entry.id);
1615
- if (!step)
1616
- return r;
1617
- return { ...r, rerankTrace: [...(r.rerankTrace ?? []), step] };
1618
- });
1619
- }
1620
- }
1621
- // Pineal salience MVP (RESEARCH.md §"AI Pineal Gland — Intuition and Awareness
1622
- // Module"). When --salience-threshold T is set (T > 0), memories whose
1623
- // retrieval_count is below T are downweighted: score *= max(0.5, count / T).
1624
- // At or above T, no change. This makes salience emerge from USE — high-recall
1625
- // memories earn full ranking weight, low-recall memories are softly demoted.
1626
- //
1627
- // CRITICAL HISTORY: The v1 salience gate (60% lexical-overlap gate at memory
1628
- // CREATION time) destroyed LoCoMo recall (0.28 -> 0.02) by dropping same-
1629
- // session relevant turns at intake. See MEMORY.md "Hippo salience gate
1630
- // destroys benchmark recall". This v2 is the inverse:
1631
- // - retrieval-side only (no creation-time gating)
1632
- // - retrieval_count signal only (no lexical overlap, no novelty heuristic)
1633
- // - default OFF, opt-in via the flag (no behaviour change without it)
1634
- // - 0.5 floor so non-salient entries stay reachable, never dropped
1635
- // Reuses the existing retrieval_count column; no schema change.
1636
- const salienceThresholdRaw = flags['salience-threshold'];
1637
- if (salienceThresholdRaw !== undefined) {
1638
- const T = Number(salienceThresholdRaw);
1639
- if (!Number.isFinite(T) || T <= 0) {
1640
- console.error(`Invalid --salience-threshold: "${salienceThresholdRaw}". Must be a positive number.`);
1641
- process.exit(1);
1642
- }
1643
- results = results
1644
- .map((r) => {
1645
- const count = r.entry.retrieval_count ?? 0;
1646
- if (count >= T)
1647
- return r;
1648
- const mult = Math.max(0.5, count / T);
1649
- const next = { ...r, score: r.score * mult };
1650
- // A7 recall-trace: pineal salience (retrieval_count) down-weight. The
1651
- // stage names the ACTUAL transform (retrieval_count, not goal-stack).
1652
- // Only when --why.
1653
- if (showWhy) {
1654
- next.rerankTrace = [
1655
- ...(r.rerankTrace ?? []),
1656
- { stage: 'retrieval-count-downweight', multiplier: mult, scoreBefore: r.score, scoreAfter: next.score },
1657
- ];
1658
- }
1659
- return next;
1660
- })
1661
- .sort((a, b) => b.score - a.score); // T2: plain stable re-sort, see --filter-conflicts note
1662
- }
1663
- // --outcome filter: drop trace entries whose trace_outcome !== target.
1664
- // Non-trace entries pass through unaffected (traces are the only layer with
1665
- // a meaningful outcome; filtering non-traces by outcome would be incoherent).
1666
- const outcomeFilter = flags['outcome'] !== undefined ? String(flags['outcome']).trim() : '';
1667
- if (outcomeFilter) {
1668
- const validOutcomes = ['success', 'failure', 'partial'];
1669
- if (!validOutcomes.includes(outcomeFilter)) {
1670
- console.error(`Invalid --outcome: "${outcomeFilter}". Must be one of: ${validOutcomes.join(', ')}.`);
1671
- process.exit(1);
1672
- }
1673
- const beforeOutcomeFilter = results.length;
1674
- results = results.filter((r) => {
1675
- if (r.entry.layer !== Layer.Trace)
1676
- return true;
1677
- return r.entry.trace_outcome === outcomeFilter;
1678
- });
1679
- droppedPreRankCountCmd += beforeOutcomeFilter - results.length;
1680
- }
1681
- // --layer filter: strict, drops entries whose layer does not match.
1682
- const layerFilter = flags['layer'] !== undefined ? String(flags['layer']).trim() : '';
1683
- if (layerFilter) {
1684
- const validLayers = Object.values(Layer);
1685
- if (!validLayers.includes(layerFilter)) {
1686
- console.error(`Invalid --layer: "${layerFilter}". Must be one of: ${validLayers.join(', ')}.`);
1687
- process.exit(1);
1688
- }
1689
- const beforeLayerFilter = results.length;
1690
- results = results.filter((r) => r.entry.layer === layerFilter);
1691
- droppedPreRankCountCmd += beforeLayerFilter - results.length;
1692
- }
1693
- // v1.12.13 / C5 — WYSIATI dropped_by_budget counter. Apply the final
1694
- // `--limit` slice first, then derive the count ARITHMETICALLY as
1695
- // "everything lost that droppedPreRank did not already claim":
1696
- //
1697
- // droppedByBudget = totalCandidates - droppedPreRank - returned
1698
- //
1699
- // This is the invariant the plan requires (totalCandidates == droppedPreRank
1700
- // + droppedByBudget + returned) restated as an assignment, so it holds by
1701
- // construction rather than by two counters happening to agree. It also
1702
- // cannot double-count the post-search droppedPreRank sites (--filter-
1703
- // conflicts, --outcome, --layer, ~1270/1528/1541): those are subtracted
1704
- // once here, not re-counted, because this line does not re-walk any filter
1705
- // — it only compares the two totals already tracked above. Everything left
1706
- // over — search-engine internal rank-step drops AND the `--limit` slice
1707
- // itself — lands in droppedByBudget, per the C5 accounting change in the
1708
- // comment near line ~976. Clamped at 0 as a defensive floor: if a future
1709
- // pipeline change ever returns MORE rows than totalCandidates minus
1710
- // droppedPreRank (should not happen), report "nothing dropped" rather than
1711
- // a negative count.
1712
- if (limit < results.length) {
1713
- results = results.slice(0, limit);
1714
941
  }
942
+ late.error?.fail();
943
+ const { localEntries, globalEntries, totalCandidates: totalCandidatesCountCmd, droppedPreRank: droppedPreRankCountCmd, graphAdded: graphAddedCountCmd, } = rank;
944
+ let results = rank.results;
1715
945
  // Continuity assembly (--continuity). Lives BEFORE the zero-result branch
1716
946
  // so a no-match query with active continuity state still returns a useful
1717
947
  // resume packet. Same three tenant-scoped store helpers as api.recall.
@@ -1767,11 +997,11 @@ async function cmdRecall(hippoRoot, query, flags) {
1767
997
  results = shown(kept);
1768
998
  // J1, J2 and C5: each pipeline computes its hints over the list it returns, so they follow the list as it shrinks.
1769
999
  // HIPPO_ANCHORING=off and HIPPO_AVAILABILITY=off skip the work entirely.
1770
- const anchorRing = process.env.HIPPO_ANCHORING !== 'off' && sessionId
1000
+ const anchorRing = biasHintEnabled('anchoring') && sessionId
1771
1001
  ? getOrCreateRing(sessionRecallHistoryCli, buildSessionKey(tenantId, sessionId))
1772
1002
  : null;
1773
1003
  const queryHash = hashQueryText(query);
1774
- const availabilityPool = process.env.HIPPO_AVAILABILITY !== 'off'
1004
+ const availabilityPool = biasHintEnabled('availability')
1775
1005
  ? [...localEntries, ...globalEntries].map((e) => ({ id: e.id, created: e.created }))
1776
1006
  : null;
1777
1007
  const hintsFor = (list, held) => {
@@ -1851,7 +1081,7 @@ async function cmdRecall(hippoRoot, query, flags) {
1851
1081
  // Appended after every detect: anchoredOn feeds the cooldown for the next recall on this session.
1852
1082
  appendRecall(anchorRing, queryHash, results[0]?.entry.id ?? null, cmdAnchoringHint?.memoryId);
1853
1083
  }
1854
- else if (process.env.HIPPO_ANCHORING !== 'off') {
1084
+ else if (biasHintEnabled('anchoring')) {
1855
1085
  // SHA-256/16 per the recall-audit convention; hashQueryText is FNV-1a and brute-forceable on short queries.
1856
1086
  emitCliAudit(hippoRoot, 'recall_anchor_skipped_no_session', undefined, auditQueryFields(query));
1857
1087
  }
@@ -2038,131 +1268,49 @@ async function cmdRecall(hippoRoot, query, flags) {
2038
1268
  }
2039
1269
  emit(recallText);
2040
1270
  }
1271
+ /** The SQL predicate drops denied rows before the window, so an unscoped probe counts what the policy hides. */
1272
+ function noteScopeHidden(hippoRoot, globalRoot, query, tenantId, requested) {
1273
+ const probe = [
1274
+ ...loadSearchEntries(hippoRoot, query, undefined, tenantId),
1275
+ ...(globalRoot ? loadSearchEntries(globalRoot, query, undefined, tenantId) : []),
1276
+ ];
1277
+ // Window-capped, so the count is a floor on large stores; fine for a "why is my row missing" hint.
1278
+ const hidden = probe.filter((e) => !api.passesCliRecallScopeFilter(e.scope ?? null, requested)).length;
1279
+ if (hidden > 0) {
1280
+ console.error(`[note] ${hidden} candidate${hidden === 1 ? '' : 's'} hidden by recall scope policy (pass an explicit --scope to inspect).`);
1281
+ }
1282
+ }
2041
1283
  async function cmdExplain(hippoRoot, query, flags) {
2042
1284
  requireInit(hippoRoot);
2043
1285
  const budget = parseBudgetFlag(flags['budget'], 4000);
2044
1286
  const limit = parseLimitFlag(flags['limit']);
2045
1287
  const asJson = Boolean(flags['json']);
2046
- const forcePhysics = Boolean(flags['physics']);
2047
- const forceClassic = Boolean(flags['classic']);
2048
- const explainIncludeSuperseded = Boolean(flags['include-superseded']);
2049
- const explainAsOf = typeof flags['as-of'] === 'string' ? flags['as-of'] : undefined;
2050
- if (explainAsOf !== undefined && Number.isNaN(new Date(explainAsOf).getTime())) {
2051
- console.error(`Error: --as-of value "${explainAsOf}" is not a valid ISO date (e.g. 2026-04-22 or 2026-04-22T12:00:00Z).`);
2052
- process.exit(1);
2053
- }
1288
+ const includeSuperseded = Boolean(flags['include-superseded']);
1289
+ const asOf = parseAsOfFlag(flags);
2054
1290
  const globalRoot = getGlobalRoot();
2055
- // A5: scope explain results to the active tenant.
2056
1291
  const tenantId = resolveTenantId({});
2057
- // v1.25.0 (v39 follow-up #1): cmdExplain shares cmdRecall's leak class —
2058
- // same unscoped loads, same fix, same semantics (explain explains what
2059
- // recall would see). Flag hoisted above the loads; the note below keeps the
2060
- // command honest for an operator debugging a hidden row.
2061
- const explainExplicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
2062
- const explainRequestedScope = explainExplicitScope || undefined;
2063
- const explainLoadSuperseded = explainIncludeSuperseded || Boolean(explainAsOf);
2064
- let explainLocalEntries = loadRecallSearchEntries(hippoRoot, query, undefined, tenantId, explainRequestedScope, 'additive', explainLoadSuperseded);
2065
- let explainGlobalEntries = isInitialized(globalRoot) ? loadRecallSearchEntries(globalRoot, query, undefined, tenantId, explainRequestedScope, 'additive', explainLoadSuperseded) : [];
2066
- const passesExplainScope = (e) => api.passesCliRecallScopeFilter(e.scope ?? null, explainRequestedScope);
2067
- explainLocalEntries = explainLocalEntries.filter(passesExplainScope);
2068
- explainGlobalEntries = explainGlobalEntries.filter(passesExplainScope);
2069
- // Honesty note (grill finding #2): the SQL predicate excludes denied rows
2070
- // BEFORE the candidate window (codex P2 fix), so the pipeline never sees
2071
- // them. For the diagnostic note only, probe the unscoped window and count
2072
- // what the scope policy hides — window-capped, so the count is a floor on
2073
- // large stores, which is fine for a "why is my row missing" hint.
2074
- const explainUnscopedProbe = [
2075
- ...loadSearchEntries(hippoRoot, query, undefined, tenantId),
2076
- ...(isInitialized(globalRoot) ? loadSearchEntries(globalRoot, query, undefined, tenantId) : []),
2077
- ];
2078
- const explainScopeDropped = explainUnscopedProbe.filter((e) => !passesExplainScope(e)).length;
2079
- if (explainScopeDropped > 0) {
2080
- console.error(`[note] ${explainScopeDropped} candidate${explainScopeDropped === 1 ? '' : 's'} hidden by recall scope policy (pass an explicit --scope to inspect).`);
2081
- }
2082
- // Bi-temporal filtering
2083
- if (explainAsOf) {
2084
- const filterAsOfExplain = (entries) => {
2085
- const asOfDate = new Date(explainAsOf);
2086
- const successorValidFrom = new Map();
2087
- for (const e of entries) {
2088
- if (e.superseded_by) {
2089
- const successor = entries.find(s => s.id === e.superseded_by);
2090
- if (successor)
2091
- successorValidFrom.set(e.id, successor.valid_from);
2092
- }
2093
- }
2094
- return entries.filter(e => {
2095
- if (new Date(e.valid_from) > asOfDate)
2096
- return false;
2097
- if (!e.superseded_by)
2098
- return true;
2099
- const succVf = successorValidFrom.get(e.id);
2100
- return succVf ? new Date(succVf) > asOfDate : true;
2101
- });
2102
- };
2103
- explainLocalEntries = filterAsOfExplain(explainLocalEntries);
2104
- explainGlobalEntries = filterAsOfExplain(explainGlobalEntries);
2105
- }
2106
- else if (!explainIncludeSuperseded) {
2107
- explainLocalEntries = explainLocalEntries.filter(e => !e.superseded_by);
2108
- explainGlobalEntries = explainGlobalEntries.filter(e => !e.superseded_by);
2109
- }
2110
- const hasGlobal = explainGlobalEntries.length > 0;
1292
+ // Explain shows what recall would see, so it applies the same scope rule.
1293
+ const explicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
1294
+ // Unlike recall, explain reads the global store whenever it exists, even when it is the local root.
1295
+ const explainGlobalOn = isInitialized(globalRoot);
1296
+ noteScopeHidden(hippoRoot, explainGlobalOn ? globalRoot : undefined, query, tenantId, explicitScope || undefined);
2111
1297
  const config = loadConfig(hippoRoot);
2112
- const usePhysics = forcePhysics
2113
- || (!forceClassic && config.physics.enabled !== false);
2114
- const noMmr = Boolean(flags['no-mmr']);
2115
- const mmrLambda = flags['mmr-lambda'] !== undefined
2116
- ? parseFloat(String(flags['mmr-lambda']))
2117
- : config.mmr.lambda;
2118
- const mmrEnabled = !noMmr && config.mmr.enabled;
2119
- const localBump = flags['equal-sources']
2120
- ? 1.0
2121
- : flags['local-bump'] !== undefined
2122
- ? parseFloat(String(flags['local-bump']))
2123
- : config.search.localBump;
2124
- // explainExplicitScope hoisted above the candidate loads (v1.25.0).
2125
- const explainActiveScope = explainExplicitScope || detectScope();
1298
+ const engine = engineFlags(flags, config);
2126
1299
  // Priced as recall prints each result, so explain returns what recall's engines would.
2127
1300
  const explainIndex = loadIndex(hippoRoot);
2128
- const explainGlobalOn = isInitialized(globalRoot);
2129
1301
  const cost = (r) => printedTokens(recallEntryText(r, query, false, explainGlobalOn && !explainIndex.entries[r.entry.id]));
2130
1302
  const entryBudget = Math.max(0, budget - printedTokens(recallHeading(budget, budget, query)));
2131
- let results;
2132
- let modeUsed;
2133
- if (usePhysics && !hasGlobal) {
2134
- results = await physicsSearch(query, explainLocalEntries, {
2135
- budget: entryBudget,
2136
- cost,
2137
- hippoRoot,
2138
- physicsConfig: config.physics,
2139
- explain: true,
2140
- scope: explainActiveScope,
2141
- });
2142
- modeUsed = 'physics';
2143
- }
2144
- else if (hasGlobal) {
2145
- results = await searchBothHybrid(query, hippoRoot, globalRoot, {
2146
- budget: entryBudget, cost, explain: true, mmr: mmrEnabled, mmrLambda, localBump, scope: explainActiveScope,
2147
- includeSuperseded: explainIncludeSuperseded, asOf: explainAsOf, tenantId,
2148
- recallScope: explainExplicitScope
2149
- ? { requested: explainExplicitScope, additive: true }
2150
- : {},
2151
- });
2152
- modeUsed = 'searchBothHybrid';
2153
- }
2154
- else {
2155
- results = await hybridSearch(query, explainLocalEntries, {
2156
- budget: entryBudget, cost, hippoRoot, explain: true, mmr: mmrEnabled, mmrLambda, scope: explainActiveScope,
2157
- includeSuperseded: explainIncludeSuperseded, asOf: explainAsOf,
2158
- });
2159
- modeUsed = 'hybrid';
2160
- }
2161
- if (limit < results.length) {
2162
- results = results.slice(0, limit);
2163
- }
2164
- results = dropHeldCopies(results, (r) => r.entry);
2165
- const candidates = explainLocalEntries.length + explainGlobalEntries.length;
1303
+ const rank = await rankRecall({ hippoRoot, globalRoot: explainGlobalOn ? globalRoot : undefined, tenantId }, {
1304
+ query, budget: entryBudget, cost, limit, includeSuperseded, asOf,
1305
+ explicitScope, activeScope: explicitScope || detectScope(),
1306
+ search: { ...engine, multihop: false, explain: true },
1307
+ });
1308
+ const hasGlobal = rank.globalEntries.length > 0;
1309
+ const modeUsed = engine.usePhysics && !hasGlobal
1310
+ ? 'physics'
1311
+ : hasGlobal ? 'searchBothHybrid' : 'hybrid';
1312
+ const results = dropHeldCopies(rank.results, (r) => r.entry);
1313
+ const candidates = rank.localEntries.length + rank.globalEntries.length;
2166
1314
  if (asJson) {
2167
1315
  const output = results.map((r, rank) => ({
2168
1316
  rank: rank + 1,
@@ -2665,164 +1813,6 @@ function cmdDedup(hippoRoot, flags) {
2665
1813
  console.log(` ... and ${result.pairs.length - 15} more (run with --dry-run to see all)`);
2666
1814
  }
2667
1815
  }
2668
- async function cmdSleep(hippoRoot, flags) {
2669
- // Tee stdout/stderr to a log file when --log-file is set. The SessionEnd
2670
- // hook uses this so the output is captured somewhere the SessionStart hook
2671
- // can re-display it next time the agent UI starts.
2672
- const logFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : null;
2673
- let restoreStdout = null;
2674
- if (logFile) {
2675
- try {
2676
- fs.mkdirSync(path.dirname(logFile), { recursive: true });
2677
- fs.writeFileSync(logFile, `[hippo] ${new Date().toISOString()} consolidating memory...\n`, 'utf8');
2678
- const origStdoutWrite = process.stdout.write.bind(process.stdout);
2679
- const origStderrWrite = process.stderr.write.bind(process.stderr);
2680
- const tee = (chunk) => {
2681
- try {
2682
- const buf = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
2683
- fs.appendFileSync(logFile, buf, 'utf8');
2684
- }
2685
- catch {
2686
- // log failures are non-fatal — still write to the real stream
2687
- }
2688
- };
2689
- process.stdout.write = ((chunk, enc, cb) => {
2690
- tee(chunk);
2691
- return origStdoutWrite(chunk, enc, cb);
2692
- });
2693
- process.stderr.write = ((chunk, enc, cb) => {
2694
- tee(chunk);
2695
- return origStderrWrite(chunk, enc, cb);
2696
- });
2697
- restoreStdout = () => {
2698
- process.stdout.write = origStdoutWrite;
2699
- process.stderr.write = origStderrWrite;
2700
- };
2701
- }
2702
- catch (err) {
2703
- console.error(`[hippo] warning: could not open log file ${logFile}: ${err.message}`);
2704
- }
2705
- }
2706
- try {
2707
- await cmdSleepCore(hippoRoot, flags);
2708
- if (logFile)
2709
- console.log('[hippo] sleep complete');
2710
- }
2711
- catch (err) {
2712
- if (logFile)
2713
- console.log(`[hippo] sleep failed: ${err.message}`);
2714
- throw err;
2715
- }
2716
- finally {
2717
- if (restoreStdout)
2718
- restoreStdout();
2719
- }
2720
- }
2721
- /**
2722
- * Render an api.sleep result as console output, byte-identical to the
2723
- * pre-extraction inline implementation in cmdSleepCore.
2724
- */
2725
- /** @internal — exported for snapshot tests (tests/cli-context-render-snapshot.test.ts). NOT a stable public API. */
2726
- export function renderSleepResult(result) {
2727
- console.log(`Running consolidation${result.dryRun ? ' (dry run)' : ''}...`);
2728
- console.log(`\nResults:`);
2729
- console.log(` Active memories: ${result.active}`);
2730
- console.log(` Removed (decayed): ${result.removed}`);
2731
- // Only when dormant.enabled moved something, so every other render stays
2732
- // byte-identical (tests/cli-context-render-snapshot.test.ts).
2733
- if (result.dormant !== undefined && result.dormant > 0) {
2734
- console.log(` Kept dormant: ${result.dormant} (hippo dormant to list)`);
2735
- }
2736
- if (result.dormantExpired !== undefined && result.dormantExpired > 0) {
2737
- console.log(` Expired dormant: ${result.dormantExpired} (past dormant.retentionDays)`);
2738
- }
2739
- console.log(` Merged episodic: ${result.mergedEpisodic}`);
2740
- console.log(` New semantic: ${result.newSemantic}`);
2741
- if (result.details && result.details.length > 0) {
2742
- console.log('\nDetails:');
2743
- for (const d of result.details) {
2744
- console.log(d);
2745
- }
2746
- }
2747
- if (result.dryRun)
2748
- console.log('\n(dry run - nothing written)');
2749
- if (result.deduped && result.deduped.removed > 0) {
2750
- const { removed, semDups, epiDups, crossDups } = result.deduped;
2751
- const parts = [];
2752
- if (semDups > 0)
2753
- parts.push(`${semDups} redundant semantic patterns`);
2754
- if (epiDups > 0)
2755
- parts.push(`${epiDups} duplicate episodic lessons`);
2756
- if (crossDups > 0)
2757
- parts.push(`${crossDups} cross-layer duplicates`);
2758
- console.log(`\n${result.dryRun ? 'Would dedupe' : 'Deduped'} ${removed} duplicates (${parts.join(', ')}). ${result.dryRun ? 'Would keep' : 'Kept'} stronger copies.`);
2759
- }
2760
- if (result.audit) {
2761
- if (result.audit.errorsRemoved > 0) {
2762
- console.log(`\nAudit: ${result.dryRun ? 'would remove' : 'removed'} ${result.audit.errorsRemoved} junk memories (too short/empty).`);
2763
- }
2764
- if (result.audit.warningCount > 0) {
2765
- console.log(`Audit: ${result.audit.warningCount} low-quality memories detected (run \`hippo audit\` for details).`);
2766
- }
2767
- }
2768
- if (result.shared !== undefined && result.shared > 0) {
2769
- console.log(`\nAuto-shared ${result.shared} high-value memories to global store.`);
2770
- }
2771
- if (result.secretSkipped !== undefined && result.secretSkipped > 0) {
2772
- // v1.25.0 (v39 follow-up #2): the secret veto is no longer silent.
2773
- console.log(`\nAuto-share: withheld ${result.secretSkipped} secret-flagged ${result.secretSkipped === 1 ? 'memory' : 'memories'} (secret veto).`);
2774
- }
2775
- if (result.ambient) {
2776
- console.log(`\n${renderAmbientSummary(result.ambient)}`);
2777
- }
2778
- if (result.graph && result.graph.tenants > 0) {
2779
- const { tenants, entities, relations } = result.graph;
2780
- console.log(`\nGraph: rebuilt ${tenants} tenant${tenants === 1 ? '' : 's'} (${entities} entities, ${relations} relations).`);
2781
- }
2782
- }
2783
- async function cmdSleepCore(hippoRoot, flags) {
2784
- requireInit(hippoRoot);
2785
- // Phase 1: Auto-learn from git and every coding agent's own memories (CLI-only, uses process.cwd() / os.homedir()).
2786
- // Stays in cli.ts; api.sleep covers Phase 2-6 only.
2787
- if (!flags['no-learn'] && flags['dry-run']) {
2788
- console.log("Dry run: skipped learning from git commits and coding agents' own memories (`hippo import --agents --dry-run` previews those).");
2789
- }
2790
- else if (!flags['no-learn']) {
2791
- const config = loadConfig(hippoRoot);
2792
- if (config.autoLearnOnSleep && isGitRepo(process.cwd())) {
2793
- const { added } = learnFromRepo(hippoRoot, process.cwd(), 1);
2794
- if (added > 0)
2795
- console.log(`Auto-learned ${added} lessons from today's git commits.`);
2796
- }
2797
- // FE2: opt-in code-churn staleness, off by default (config.churnStaleness.enabled).
2798
- if (config.churnStaleness.enabled && isGitRepo(process.cwd())) {
2799
- for (const { root, result } of runChurnStaleForRepo(hippoRoot, false)) {
2800
- if (result.marked > 0)
2801
- console.log(`Tagged ${result.marked} memories churn-stale in ${root}.`);
2802
- if (result.error)
2803
- console.error(`Churn-staleness check failed for ${root}: ${result.error}`);
2804
- }
2805
- }
2806
- printAgentImport(importForStore(hippoRoot, { machine: currentMachine() }), '');
2807
- }
2808
- // Finishes compactions a killed or busy post-compact hook left; never throws, and a dry run writes nothing.
2809
- if (!flags['dry-run']) {
2810
- const finished = replayCompactionsAt(hippoRoot, (message) => console.error(`compaction replay: ${message}`));
2811
- if (finished > 0)
2812
- console.log(`Finished saving ${finished} compaction${finished === 1 ? '' : 's'} left over from earlier sessions.`);
2813
- }
2814
- // Phase 2-6: Pure-storage pipeline (consolidate + dedup + audit + share + ambient).
2815
- const ctx = {
2816
- hippoRoot,
2817
- tenantId: resolveTenantId({}),
2818
- actor: api.adminActor('cli'),
2819
- };
2820
- const result = await api.sleep(ctx, {
2821
- dryRun: Boolean(flags['dry-run']),
2822
- noShare: Boolean(flags['no-share']),
2823
- });
2824
- renderSleepResult(result);
2825
- }
2826
1816
  /** Prints the SessionEnd sleep log, then clears it. Stderr, because Claude Code adds
2827
1817
  * SessionStart stdout to the model's context and this log is for the user. */
2828
1818
  function cmdLastSleep(flags) {
@@ -3023,49 +2013,6 @@ async function cmdSessionEnd(hippoRoot, flags) {
3023
2013
  return;
3024
2014
  }
3025
2015
  }
3026
- /**
3027
- * Detached worker that counts re-reads, runs sleep, then capture. Invoked via the internal
3028
- * `__session-end-worker` subcommand (not user-facing). Failures in one stage
3029
- * do not block the other.
3030
- */
3031
- // Best-effort git state; a missing git, non-repo cwd, or the timeout all
3032
- // yield null fields rather than throw (autolearn.ts execFileSync shape).
3033
- function collectHandoffEvidence(cwd, testStatus) {
3034
- let gitRef = null;
3035
- try {
3036
- gitRef = execFileSync('git', ['rev-parse', 'HEAD'], {
3037
- cwd, encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
3038
- }).trim() || null;
3039
- }
3040
- catch {
3041
- gitRef = null;
3042
- }
3043
- let dirtyTree = null;
3044
- try {
3045
- const status = execFileSync('git', ['status', '--porcelain'], {
3046
- cwd, encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
3047
- });
3048
- dirtyTree = status.trim().length > 0;
3049
- }
3050
- catch {
3051
- dirtyTree = null;
3052
- }
3053
- return { gitRef, dirtyTree, testStatus };
3054
- }
3055
- /** A folder without its own store never sleeps at session end, so its project's agent notes go to the global store here. */
3056
- function logSessionEndImport(logFile, transcriptPath) {
3057
- try {
3058
- const report = importAtSessionEnd(process.cwd(), transcriptPath, { machine: currentMachine() });
3059
- const line = summaryLine(report);
3060
- if (line !== null)
3061
- appendSessionEndCloseLog(logFile, line);
3062
- for (const warning of report.warnings)
3063
- appendSessionEndCloseLog(logFile, `agent memories: ${warning}`);
3064
- }
3065
- catch (err) {
3066
- appendSessionEndCloseLog(logFile, `agent memory import failed: ${err instanceof Error ? err.message : String(err)}`);
3067
- }
3068
- }
3069
2016
  async function cmdSessionEndWorker(hippoRoot, flags) {
3070
2017
  const transcriptPath = typeof flags['transcript'] === 'string' ? flags['transcript'] : undefined;
3071
2018
  const closeLogFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : null;
@@ -3086,7 +2033,7 @@ async function cmdSessionEndWorker(hippoRoot, flags) {
3086
2033
  // Sleeping the global store from here would learn this folder's git commits into it; it has its own daily sleep.
3087
2034
  if (isInitialized(hippoRoot)) {
3088
2035
  try {
3089
- await cmdSleep(hippoRoot, flags);
2036
+ await (await import('./cli/sleep.js')).cmdSleep(hippoRoot, flags);
3090
2037
  }
3091
2038
  catch {
3092
2039
  // sleep errors are already tee'd to the log file via cmdSleep's
@@ -3205,28 +2152,6 @@ async function bookSessionRereads(hippoRoot, transcriptPath, sessionId) {
3205
2152
  lines.push(`re-read ${tokens} tokens over ${read.calls.length} model calls for session ${sessionId}${skipped}`);
3206
2153
  return lines;
3207
2154
  }
3208
- /**
3209
- * Best-effort log line for the DF1 T3 snapshot-close step in
3210
- * `cmdSessionEndWorker`. `cmdSleep`/`cmdCapture` each tee console output to
3211
- * `logFile` only for their own duration (the tee is restored before this
3212
- * runs), so a plain `console.log` here would be silently discarded under
3213
- * the detached worker's `stdio: 'ignore'` — write straight to the file
3214
- * instead, matching capture.ts's `appendPreCompactLog` convention.
3215
- */
3216
- function appendSessionEndCloseLog(logFile, message, opts = {}) {
3217
- if (!logFile)
3218
- return;
3219
- try {
3220
- fs.mkdirSync(path.dirname(logFile), { recursive: true });
3221
- // sanitizeLogMessage: `message` interpolates the payload-controlled
3222
- // session_id — same log-forgery guard appendPreCompactLog applies.
3223
- const write = opts.startFresh ? fs.writeFileSync : fs.appendFileSync;
3224
- write(logFile, `[hippo] ${new Date().toISOString()} ${sanitizeLogMessage(message)}\n`, 'utf8');
3225
- }
3226
- catch {
3227
- // Best-effort only — never let a log-write failure surface as an error.
3228
- }
3229
- }
3230
2155
  function loadCodexWrapperMetadata() {
3231
2156
  const { metadataPath } = resolveCodexWrapperPaths();
3232
2157
  if (!fs.existsSync(metadataPath)) {
@@ -3256,7 +2181,8 @@ function spawnRealCodex(realCodexPath, forwardArgs, cwd) {
3256
2181
  function cmdCodexRun(hippoRoot, args) {
3257
2182
  const metadata = loadCodexWrapperMetadata();
3258
2183
  const startedAtMs = Date.now();
3259
- const historyPath = metadata.historyPath;
2184
+ // Codex reads CODEX_HOME at each launch, so resolve it now, not from the install-time metadata.
2185
+ const { historyPath } = resolveCodexWrapperPaths();
3260
2186
  const startOffsetBytes = fs.existsSync(historyPath) ? fs.statSync(historyPath).size : 0;
3261
2187
  try {
3262
2188
  cmdLastSleep({ path: metadata.logFile });
@@ -3331,7 +2257,7 @@ async function cmdCodexSessionEndWorker(hippoRoot, flags) {
3331
2257
  // Sleeping the global store from here would learn this folder's git commits into it; it has its own daily sleep.
3332
2258
  if (isInitialized(hippoRoot)) {
3333
2259
  try {
3334
- await cmdSleep(hippoRoot, logFile ? { 'log-file': logFile } : {});
2260
+ await (await import('./cli/sleep.js')).cmdSleep(hippoRoot, logFile ? { 'log-file': logFile } : {});
3335
2261
  }
3336
2262
  catch {
3337
2263
  // sleep errors are already written via cmdSleep
@@ -3344,7 +2270,7 @@ async function cmdCodexSessionEndWorker(hippoRoot, flags) {
3344
2270
  try {
3345
2271
  const codexHome = typeof flags['codex-home'] === 'string'
3346
2272
  ? flags['codex-home']
3347
- : path.join(os.homedir(), '.codex');
2273
+ : resolveCodexWrapperPaths().codexHome;
3348
2274
  const historyPath = typeof flags['history-path'] === 'string'
3349
2275
  ? flags['history-path']
3350
2276
  : path.join(codexHome, 'history.jsonl');
@@ -3702,12 +2628,6 @@ function cmdInspect(hippoRoot, id) {
3702
2628
  console.log('-'.repeat(40));
3703
2629
  console.log(entry.content);
3704
2630
  }
3705
- function printActiveTaskSnapshot(snapshot) {
3706
- console.log(snapshotText(snapshot));
3707
- }
3708
- function printSessionEvents(events) {
3709
- console.log(events.length === 0 ? 'No session events found.' : sessionTrailText(events));
3710
- }
3711
2631
  function cmdConflicts(hippoRoot, flags) {
3712
2632
  requireInit(hippoRoot);
3713
2633
  const conflicts = listMemoryConflicts(hippoRoot, String(flags['status'] ?? 'open'));
@@ -4289,9 +3209,6 @@ function cmdSession(hippoRoot, args, flags) {
4289
3209
  console.error('Usage: hippo session <log|show|latest|resume|complete>');
4290
3210
  process.exit(1);
4291
3211
  }
4292
- function printHandoff(handoff) {
4293
- console.log(handoffText(handoff));
4294
- }
4295
3212
  function cmdHandoff(hippoRoot, args, flags) {
4296
3213
  requireInit(hippoRoot);
4297
3214
  const subcommand = args[0] ?? 'latest';
@@ -4457,18 +3374,6 @@ function printCard(detail) {
4457
3374
  }
4458
3375
  console.log('');
4459
3376
  }
4460
- // parseArgs turns a value-less flag into `true`; refuse rather than silently
4461
- // stringifying it (String(true) === 'true'), mirroring cmdHandoff's guard.
4462
- function cardStringFlag(flags, key) {
4463
- const v = flags[key];
4464
- if (v === undefined)
4465
- return undefined;
4466
- if (v === true || v === false || Array.isArray(v)) {
4467
- console.error(`--${key} requires a value`);
4468
- process.exit(1);
4469
- }
4470
- return v.trim();
4471
- }
4472
3377
  // A too-large --run would silently round to a different id (mirrors parsePositiveIncidentId).
4473
3378
  function cardRunFlag(flags) {
4474
3379
  const raw = cardStringFlag(flags, 'run');
@@ -6286,11 +5191,47 @@ function cmdCurrent(hippoRoot, args, flags) {
6286
5191
  console.error('Usage: hippo current <show>');
6287
5192
  process.exit(1);
6288
5193
  }
6289
- // Claude Code exports its own session var, not ours; without the fallback agent-run recalls trace with no session.
6290
- function hostSessionId() {
6291
- return process.env.HIPPO_SESSION_ID?.trim() || process.env.CLAUDE_CODE_SESSION_ID?.trim() || undefined;
6292
- }
6293
5194
  async function cmdContext(hippoRoot, args, flags, stdinText) {
5195
+ const rec = startDeliveryRecorder(hippoRoot, flags, stdinText);
5196
+ // No try/finally: a render throw keeps its own exit code and writes no event.
5197
+ await renderContext(hippoRoot, args, flags, stdinText, rec);
5198
+ flushDeliveryRecorder(rec);
5199
+ }
5200
+ /** A delivery recorder for a pinned-only call when its ledger store enables one, else null; never throws. */
5201
+ function startDeliveryRecorder(hippoRoot, flags, stdinText) {
5202
+ if (flags['pinned-only'] !== true)
5203
+ return null;
5204
+ try {
5205
+ // The same store withLedgerDb writes the token ledger to, so its config governs both.
5206
+ const root = isInitialized(hippoRoot) ? hippoRoot : isInitialized(getGlobalRoot()) ? getGlobalRoot() : null;
5207
+ if (root === null || !loadConfig(root).deliveryLedger.enabled)
5208
+ return null;
5209
+ return createDeliveryRecorder({
5210
+ root,
5211
+ storeHash: blockHash(path.resolve(root)),
5212
+ writeStore: isGlobalStoreRoot(root) ? 'global' : 'local',
5213
+ tenantId: resolveTenantId({}),
5214
+ stdinText,
5215
+ envSessionId: hostSessionId(),
5216
+ });
5217
+ }
5218
+ catch (error) {
5219
+ console.error(`[hippo] delivery ledger skipped: ${error instanceof Error ? error.message : String(error)}`);
5220
+ return null;
5221
+ }
5222
+ }
5223
+ /** With `db`, writes on the token ledger's handle (same store); without it, opens its own. A second flush is a no-op. */
5224
+ function flushDeliveryRecorder(rec, db) {
5225
+ if (rec === null)
5226
+ return;
5227
+ try {
5228
+ rec.flush((input) => (db ? writeDeliveryEventOnHandle(db, input) : writeDeliveryEventAtRoot(rec.root, input)));
5229
+ }
5230
+ catch (error) {
5231
+ console.error(`[hippo] delivery ledger write failed: ${error instanceof Error ? error.message : String(error)}`);
5232
+ }
5233
+ }
5234
+ async function renderContext(hippoRoot, args, flags, stdinText, rec) {
6294
5235
  // --pinned-only fires on every UserPromptSubmit — including in directories
6295
5236
  // that don't have a local .hippo. Skip requireInit for that path and fall
6296
5237
  // back to global-only inside api.getContext. The non-pinned path still
@@ -6300,8 +5241,10 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6300
5241
  requireInit(hippoRoot);
6301
5242
  }
6302
5243
  const budget = parseBudgetFlag(flags['budget'], 1500);
6303
- if (budget <= 0)
5244
+ if (budget <= 0) {
5245
+ rec?.disabled();
6304
5246
  return;
5247
+ }
6305
5248
  // Resolve query: explicit args, --auto (git diff via CLI-side helper), or
6306
5249
  // fall through to api.getContext's '*' fallback. api.getContext is host-
6307
5250
  // agnostic so the auto-detect (which shells out to git) stays CLI-side.
@@ -6362,6 +5305,7 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6362
5305
  prompt: payloadPrompt,
6363
5306
  // JSON is budgeted as the markdown it stands for, so one budget picks the same memories in every format.
6364
5307
  cost: contextCost(format === 'additional-context' ? 'additional-context' : 'markdown', framing),
5308
+ deliveryObserver: rec ?? undefined,
6365
5309
  };
6366
5310
  const result = await api.getContext(ctx, opts);
6367
5311
  // Early exit when there's nothing to render (matches pre-extraction behavior).
@@ -6369,8 +5313,10 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6369
5313
  result.activeSnapshot ||
6370
5314
  result.sessionHandoff ||
6371
5315
  (result.recentEvents && result.recentEvents.length > 0);
6372
- if (!hasContextData)
5316
+ if (!hasContextData) {
5317
+ rec?.delivered({ state: 'empty' });
6373
5318
  return;
5319
+ }
6374
5320
  // Adapter: ContextResultEntry -> the print-helper input shape. v39:
6375
5321
  // cross-project inclusions (only present under --cross-project or with
6376
5322
  // isolation disabled via crossProject) render in their own demarcated
@@ -6404,10 +5350,14 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6404
5350
  tokens: result.tokens,
6405
5351
  });
6406
5352
  console.log(jsonText);
6407
- withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6408
- tenantId: ctx.tenantId, sessionId: ledgerSessionId, surface: pinnedOnly ? 'hook' : 'context',
6409
- event: 'inject', items: output.length, tokens: estimateTokens(jsonText),
6410
- }));
5353
+ rec?.delivered({ state: 'sent', emittedText: `${jsonText}\n` });
5354
+ withLedgerDb(hippoRoot, (db) => {
5355
+ recordTokenUse(db, {
5356
+ tenantId: ctx.tenantId, sessionId: ledgerSessionId, surface: pinnedOnly ? 'hook' : 'context',
5357
+ event: 'inject', items: output.length, tokens: estimateTokens(jsonText),
5358
+ });
5359
+ flushDeliveryRecorder(rec, db);
5360
+ });
6411
5361
  }
6412
5362
  else if (format === 'additional-context') {
6413
5363
  // Z1: split into a static block (snapshot/handoff/events/pins/recent-N,
@@ -6433,8 +5383,10 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6433
5383
  const recallBlock = recallItems.length > 0
6434
5384
  ? settleTokens((t) => captureConsole(() => printContextMarkdown(recallItems, t, framing, { showStrength: false, heading: 'Prompt-Relevant Memory' })))
6435
5385
  : '';
6436
- if (!staticBlock.trim() && !recallBlock.trim())
5386
+ if (!staticBlock.trim() && !recallBlock.trim()) {
5387
+ rec?.delivered({ state: 'empty' });
6437
5388
  return;
5389
+ }
6438
5390
  const surface = pinnedOnly ? 'hook' : 'context';
6439
5391
  let sendStatic = staticBlock.trim().length > 0;
6440
5392
  // TE2: the per-prompt hook skips a static block identical to the one this
@@ -6449,10 +5401,16 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6449
5401
  const staticHash = blockHash(staticBlock);
6450
5402
  const last = withLedgerDb(hippoRoot, (db) => lastSentState(db, ctx.tenantId, payloadSessionId, surface));
6451
5403
  if (shouldSkipUnchanged(last ?? null, staticHash, refreshTurns)) {
6452
- withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6453
- tenantId: ctx.tenantId, sessionId: payloadSessionId, surface, event: 'skip',
6454
- items: staticItems.length, tokens: estimateTokens(staticBlock), hash: staticHash,
6455
- }));
5404
+ withLedgerDb(hippoRoot, (db) => {
5405
+ recordTokenUse(db, {
5406
+ tenantId: ctx.tenantId, sessionId: payloadSessionId, surface, event: 'skip',
5407
+ items: staticItems.length, tokens: estimateTokens(staticBlock), hash: staticHash,
5408
+ });
5409
+ if (recallBlock.trim())
5410
+ return;
5411
+ rec?.delivered({ state: 'reused', staticHash, staticReused: true });
5412
+ flushDeliveryRecorder(rec, db);
5413
+ });
6456
5414
  sendStatic = false;
6457
5415
  }
6458
5416
  }
@@ -6461,8 +5419,11 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6461
5419
  const additionalContext = finalStatic && recallBlock
6462
5420
  ? `${finalStatic}\n\n${recallBlock}`
6463
5421
  : finalStatic || recallBlock;
6464
- if (!additionalContext.trim())
5422
+ const staticReused = !sendStatic && staticBlock.trim().length > 0;
5423
+ if (!additionalContext.trim()) {
5424
+ rec?.delivered({ state: 'reused', staticHash: blockHash(staticBlock), staticReused });
6465
5425
  return;
5426
+ }
6466
5427
  const payload = {
6467
5428
  hookSpecificOutput: {
6468
5429
  hookEventName: 'UserPromptSubmit',
@@ -6470,6 +5431,13 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6470
5431
  },
6471
5432
  };
6472
5433
  process.stdout.write(JSON.stringify(payload));
5434
+ rec?.delivered({
5435
+ state: staticReused ? 'reused-recall-sent' : 'sent',
5436
+ staticHash: staticBlock.trim() ? blockHash(staticBlock) : null,
5437
+ recallHash: recallBlock ? blockHash(recallBlock) : null,
5438
+ emittedText: additionalContext,
5439
+ staticReused,
5440
+ });
6473
5441
  if (finalStatic || recallBlock) {
6474
5442
  // One connection for both rows; each insert in its own try so one failing doesn't skip the other.
6475
5443
  withLedgerDb(hippoRoot, (db) => {
@@ -6491,6 +5459,7 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6491
5459
  }
6492
5460
  catch { /* best effort; see withLedgerDb doc comment */ }
6493
5461
  }
5462
+ flushDeliveryRecorder(rec, db);
6494
5463
  });
6495
5464
  }
6496
5465
  }
@@ -6515,87 +5484,14 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6515
5484
  }));
6516
5485
  if (text.length > 0)
6517
5486
  console.log(text);
6518
- withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6519
- tenantId: ctx.tenantId, sessionId: ledgerSessionId, surface: pinnedOnly ? 'hook' : 'context',
6520
- event: 'inject', items: renderItems.length, tokens: estimateTokens(text),
6521
- }));
6522
- }
6523
- }
6524
- /**
6525
- * TE2: compaction drops the pinned blocks the per-prompt hook injected
6526
- * earlier, so record a `reset` for the payload's session and the next prompt
6527
- * injects again even if nothing changed. `requiredSource` limits it to hook
6528
- * payloads with that `source` (SessionStart fires for other reasons too).
6529
- * Best-effort and silent: a malformed payload records nothing.
6530
- */
6531
- function resetHookInjection(hippoRoot, stdinText, requiredSource) {
6532
- const sessionId = hookPayloadSessionId(stdinText, requiredSource);
6533
- // A sub-agent's compaction leaves its parent's context, and the blocks in it, as they were.
6534
- if (sessionId === null || isSubagentPayload(stdinText))
6535
- return;
6536
- withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6537
- tenantId: resolveTenantId({}), sessionId, surface: 'hook', event: 'reset', items: 0, tokens: 0,
6538
- }));
6539
- }
6540
- /**
6541
- * Run `fn` with console.log captured; returns the captured lines joined by
6542
- * newlines (what the same calls would have printed, minus the final newline).
6543
- */
6544
- function captureConsole(fn) {
6545
- const lines = [];
6546
- const realLog = console.log;
6547
- console.log = (...parts) => { lines.push(parts.map(String).join(' ')); };
6548
- try {
6549
- fn();
6550
- }
6551
- finally {
6552
- console.log = realLog;
6553
- }
6554
- return lines.join('\n');
6555
- }
6556
- /**
6557
- * The store a Claude Code hook writes to: the project store when there is
6558
- * one, else an existing global store, else the project path (which the hook
6559
- * then skips, since hooks fire in every directory and must not create one).
6560
- * Pre-compact and compact-resume must agree, or a snapshot saved to one store
6561
- * is looked for in the other.
6562
- */
6563
- function hookStoreRoot(hippoRoot) {
6564
- if (isInitialized(hippoRoot))
6565
- return hippoRoot;
6566
- const globalRoot = getGlobalRoot();
6567
- return isInitialized(globalRoot) ? globalRoot : hippoRoot;
6568
- }
6569
- /**
6570
- * Run `fn` against the token ledger's store: the local store when it is
6571
- * initialized, else the global one (the per-prompt hook runs in directories
6572
- * without a local store). Best-effort: returns undefined and never throws,
6573
- * because a ledger failure must not break context or recall.
6574
- */
6575
- function withLedgerDb(hippoRoot, fn) {
6576
- let root = null;
6577
- try {
6578
- if (isInitialized(hippoRoot))
6579
- root = hippoRoot;
6580
- else if (isInitialized(getGlobalRoot()))
6581
- root = getGlobalRoot();
6582
- }
6583
- catch {
6584
- return undefined;
6585
- }
6586
- if (root === null)
6587
- return undefined;
6588
- let db;
6589
- try {
6590
- db = openHippoDb(root);
6591
- return fn(db);
6592
- }
6593
- catch {
6594
- return undefined;
6595
- }
6596
- finally {
6597
- if (db)
6598
- closeHippoDb(db);
5487
+ rec?.delivered(text.length > 0 ? { state: 'sent', emittedText: `${text}\n` } : { state: 'empty' });
5488
+ withLedgerDb(hippoRoot, (db) => {
5489
+ recordTokenUse(db, {
5490
+ tenantId: ctx.tenantId, sessionId: ledgerSessionId, surface: pinnedOnly ? 'hook' : 'context',
5491
+ event: 'inject', items: renderItems.length, tokens: estimateTokens(text),
5492
+ });
5493
+ flushDeliveryRecorder(rec, db);
5494
+ });
6599
5495
  }
6600
5496
  }
6601
5497
  /**
@@ -6618,39 +5514,6 @@ export function printContextMarkdown(items, totalTokens, framing = 'observe', op
6618
5514
  for (const item of items)
6619
5515
  console.log(contextLine(item, framing, showStrength, now));
6620
5516
  }
6621
- function autoDetectContext() {
6622
- // Try git diff --name-only for changed files
6623
- try {
6624
- const diff = execSync('git diff --name-only HEAD 2>&1', {
6625
- encoding: 'utf8',
6626
- timeout: 3000,
6627
- windowsHide: true,
6628
- }).trim();
6629
- if (diff) {
6630
- // Extract meaningful terms from file paths
6631
- const terms = diff
6632
- .split('\n')
6633
- .flatMap((f) => f.replace(/[\/\\\.]/g, ' ').split(/\s+/))
6634
- .filter((t) => t.length > 2 && !['src', 'dist', 'test', 'tests', 'node_modules', 'index'].includes(t))
6635
- .slice(0, 10);
6636
- if (terms.length > 0)
6637
- return terms.join(' ');
6638
- }
6639
- // Try branch name
6640
- const branch = execSync('git branch --show-current 2>&1', {
6641
- encoding: 'utf8',
6642
- timeout: 3000,
6643
- windowsHide: true,
6644
- }).trim();
6645
- if (branch && branch !== 'main' && branch !== 'master') {
6646
- return branch.replace(/[-_\/]/g, ' ');
6647
- }
6648
- }
6649
- catch {
6650
- // Not a git repo or git not available, fall through
6651
- }
6652
- return '';
6653
- }
6654
5517
  // ---------------------------------------------------------------------------
6655
5518
  // Embed command
6656
5519
  // ---------------------------------------------------------------------------
@@ -6786,115 +5649,6 @@ async function cmdWatch(command, hippoRoot) {
6786
5649
  // ---------------------------------------------------------------------------
6787
5650
  // Learn command
6788
5651
  // ---------------------------------------------------------------------------
6789
- function learnFromRepo(hippoRoot, repoPath, days, label) {
6790
- const prefix = label ? `[${label}] ` : '';
6791
- if (!isGitRepo(repoPath)) {
6792
- console.log(`${prefix}No git history found (or not a git repository).`);
6793
- return { added: 0, skipped: 0, lowInfo: 0 };
6794
- }
6795
- const gitLog = fetchGitLog(repoPath, days);
6796
- if (!gitLog.trim()) {
6797
- console.log(`${prefix}No fix/revert/bug commits found in the specified period.`);
6798
- return { added: 0, skipped: 0, lowInfo: 0 };
6799
- }
6800
- // Same patterns as MCP hippo_learn: config.gitLearnPatterns (whose default
6801
- // equals extractLessons' built-in list) so a custom list applies everywhere.
6802
- const config = loadConfig(hippoRoot);
6803
- const parsedLessons = extractLessons(gitLog, config.gitLearnPatterns);
6804
- if (parsedLessons.length === 0) {
6805
- console.log(`${prefix}No fix/revert/bug commits found in the specified period.`);
6806
- return { added: 0, skipped: 0, lowInfo: 0 };
6807
- }
6808
- // DF4: admission gate lives at the write path, not in extractLessons
6809
- // (a published API surface that only parses). Bare subjects like "fixed
6810
- // signals" are dropped here, before they ever become a memory.
6811
- // The gate filters the loop INPUT, so a dropped lesson neither stores nor
6812
- // invalidates. That is deliberate, and it was argued both ways.
6813
- //
6814
- // Round 1 of review called the lost invalidation a P1: a migration subject
6815
- // too thin to store ("replace webpack with vite") would stop weakening
6816
- // stale webpack memories. True. So the loop was widened to walk every
6817
- // parsed lesson with the gate on the write alone.
6818
- //
6819
- // Round 2 then found the cure was worse. STORAGE is what makes invalidation
6820
- // idempotent here: a stored lesson is recognised by its same-text key on
6821
- // the next scan and short-circuits before invalidating again. A lesson that
6822
- // invalidates but is never stored has no such record, so every rescan
6823
- // re-invalidates, and invalidateMatching halves half_life_days each time.
6824
- // Measured: 7 -> 3 -> 1 over two runs. That is compounding data damage.
6825
- //
6826
- // Measured frequency decided it. Across 413 real auto-learn rows in 4
6827
- // stores, 24 are gated and ZERO of those carry an invalidation target; the
6828
- // 45 lessons that do carry targets all pass the gate and are unaffected
6829
- // either way. Both failure modes are empty on real data, so the tie breaks
6830
- // on which one is benign if it ever fires: not invalidating is a missed
6831
- // improvement, re-invalidating forever is damage.
6832
- //
6833
- // Documented limitation, pinned by test: a migration subject too thin to
6834
- // store also does not invalidate. Making invalidateMatching idempotent
6835
- // would allow both, and is backlogged - it is a latent issue for the manual
6836
- // `hippo invalidate` path too, not just this one.
6837
- const { kept: lessons, dropped } = partitionLessons(parsedLessons);
6838
- const lowInfo = dropped.length;
6839
- let added = 0;
6840
- let skipped = 0;
6841
- // AT1 (plan §3 containment): per-lesson refusal must not abort the rest
6842
- // of the git-log scan. No signature change (added/skipped return shape
6843
- // used by cmdLearn + cmdSleepCore callers) — counted locally, folded into
6844
- // the existing summary line.
6845
- let rejected = 0;
6846
- const gitLearnTags = ['error', 'git-learned'];
6847
- const existingForSchema = loadAllEntries(hippoRoot, resolveTenantId({}));
6848
- const keys = storedTextKeys(existingForSchema);
6849
- for (const lesson of lessons) {
6850
- if (keys.has(duplicateKey(lesson))) {
6851
- skipped++;
6852
- continue;
6853
- }
6854
- const target = extractInvalidationTarget(lesson);
6855
- if (target) {
6856
- const invResult = invalidateMatching(hippoRoot, target, resolveTenantId({}));
6857
- if (invResult.invalidated > 0) {
6858
- console.log(`${prefix} Invalidated ${invResult.invalidated} memories referencing "${target.from}"`);
6859
- }
6860
- }
6861
- const schemaFitVal = computeSchemaFit(lesson, gitLearnTags, existingForSchema);
6862
- const entry = createMemory(lesson, {
6863
- layer: Layer.Episodic,
6864
- tags: [...gitLearnTags],
6865
- source: 'git-learn',
6866
- confidence: 'observed',
6867
- schema_fit: schemaFitVal,
6868
- tenantId: resolveTenantId({}),
6869
- baseHalfLifeDays: config.defaultHalfLifeDays,
6870
- });
6871
- // Auto-tag with path context from the repo being learned
6872
- const learnPathTags = extractPathTags(repoPath);
6873
- for (const pt of learnPathTags) {
6874
- if (!entry.tags.includes(pt))
6875
- entry.tags.push(pt);
6876
- }
6877
- try {
6878
- writeEntry(hippoRoot, entry);
6879
- }
6880
- catch (err) {
6881
- if (err instanceof RejectedValueError) {
6882
- rejected++;
6883
- continue;
6884
- }
6885
- throw err;
6886
- }
6887
- updateStats(hippoRoot, { remembered: 1 });
6888
- keys.add(duplicateKey(lesson));
6889
- void embedMemory(hippoRoot, entry);
6890
- added++;
6891
- }
6892
- console.log(`${prefix}${added} new lessons added, ${skipped} duplicates skipped` +
6893
- (rejected > 0 ? `, ${rejected} rejected value(s) skipped` : '') +
6894
- (lowInfo > 0 ? `, ${lowInfo} low-information subject(s) dropped` : '') +
6895
- '.');
6896
- return { added, skipped, lowInfo };
6897
- }
6898
5652
  function cmdLearn(hippoRoot, flags) {
6899
5653
  requireInit(hippoRoot);
6900
5654
  if (!flags['git']) {
@@ -7133,223 +5887,6 @@ function cmdSync(hippoRoot, flags = {}) {
7133
5887
  // ---------------------------------------------------------------------------
7134
5888
  // Hook install/uninstall
7135
5889
  // ---------------------------------------------------------------------------
7136
- const HOOK_MARKERS = {
7137
- start: '<!-- hippo:start -->',
7138
- end: '<!-- hippo:end -->',
7139
- };
7140
- const HOOKS = {
7141
- 'claude-code': {
7142
- file: 'CLAUDE.md',
7143
- description: 'Claude Code',
7144
- content: `
7145
- ## Project Memory (Hippo)
7146
-
7147
- Pinned rules and recent writes auto-inject at every prompt via the installed
7148
- UserPromptSubmit hook; never re-run that part manually. At the START of a
7149
- task (not per prompt), additionally load task-specific context: git-aware
7150
- recall over the full store that per-prompt injection does not cover. Also
7151
- run it if the hook is not installed:
7152
- \`\`\`bash
7153
- hippo context --auto --budget 1500
7154
- \`\`\`
7155
-
7156
- When you find out why something failed, record it right then, while you
7157
- work, never as a closing step:
7158
- \`\`\`bash
7159
- hippo remember "<what went wrong and why>" --error
7160
- \`\`\`
7161
-
7162
- The installed hooks store failed tool calls and capture the session when it
7163
- ends, so there is nothing to run before you finish.
7164
- `.trim(),
7165
- },
7166
- 'codex': {
7167
- file: 'AGENTS.md',
7168
- description: 'OpenAI Codex',
7169
- content: `
7170
- ## Project Memory (Hippo)
7171
-
7172
- At the start of every task, run:
7173
- \`\`\`bash
7174
- hippo context --auto --budget 1500
7175
- \`\`\`
7176
- Read the output before writing any code.
7177
-
7178
- On errors or unexpected behaviour, record it right then, while you work,
7179
- never as a closing step:
7180
- \`\`\`bash
7181
- hippo remember "<description of what went wrong>" --error
7182
- \`\`\`
7183
-
7184
- When you learn something that should outlive this session (a decision and
7185
- its reason, a user preference, a lesson), record it right then, while you
7186
- work, never as a closing step. Leave out secrets and personal details:
7187
- \`\`\`bash
7188
- hippo remember "<what you learned and why>"
7189
- \`\`\`
7190
-
7191
- When Hippo's Codex wrapper is installed, session-end capture runs automatically.
7192
- If the wrapper is not installed, capture a brief summary manually:
7193
- \`\`\`bash
7194
- hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
7195
- \`\`\`
7196
- `.trim(),
7197
- },
7198
- 'cursor': {
7199
- file: 'AGENTS.md',
7200
- description: 'Cursor',
7201
- content: `
7202
- ## Project Memory (Hippo)
7203
-
7204
- At the start of every task, run:
7205
- \`\`\`bash
7206
- hippo context --auto --budget 1500
7207
- \`\`\`
7208
- Read the output before writing any code.
7209
-
7210
- On errors or unexpected behaviour, record it right then, while you work,
7211
- never as a closing step:
7212
- \`\`\`bash
7213
- hippo remember "<description of what went wrong>" --error
7214
- \`\`\`
7215
-
7216
- When you learn something that should outlive this session (a decision and
7217
- its reason, a user preference, a lesson), record it right then, while you
7218
- work, never as a closing step. Leave out secrets and personal details:
7219
- \`\`\`bash
7220
- hippo remember "<what you learned and why>"
7221
- \`\`\`
7222
-
7223
- When ending a session, capture a brief summary:
7224
- \`\`\`bash
7225
- hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
7226
- \`\`\`
7227
- `.trim(),
7228
- },
7229
- 'openclaw': {
7230
- file: 'AGENTS.md',
7231
- description: 'OpenClaw',
7232
- content: `
7233
- ## Project Memory (Hippo)
7234
-
7235
- At the start of every session, run:
7236
- \`\`\`bash
7237
- hippo context --auto --budget 1500
7238
- \`\`\`
7239
- Read the output before writing any code.
7240
-
7241
- On errors or unexpected behaviour, record it right then, while you work,
7242
- never as a closing step:
7243
- \`\`\`bash
7244
- hippo remember "<description of what went wrong>" --error
7245
- \`\`\`
7246
-
7247
- When you learn something that should outlive this session (a decision and
7248
- its reason, a user preference, a lesson), record it right then, while you
7249
- work, never as a closing step. Leave out secrets and personal details:
7250
- \`\`\`bash
7251
- hippo remember "<what you learned and why>"
7252
- \`\`\`
7253
-
7254
- When ending a session, capture a brief summary:
7255
- \`\`\`bash
7256
- hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
7257
- \`\`\`
7258
- `.trim(),
7259
- },
7260
- 'opencode': {
7261
- file: 'AGENTS.md',
7262
- description: 'OpenCode',
7263
- content: `
7264
- ## Project Memory (Hippo)
7265
-
7266
- At the start of every task, run:
7267
- \`\`\`bash
7268
- hippo context --auto --budget 1500
7269
- \`\`\`
7270
- Read the output before writing any code.
7271
-
7272
- On errors or unexpected behaviour, record it right then, while you work,
7273
- never as a closing step:
7274
- \`\`\`bash
7275
- hippo remember "<description of what went wrong>" --error
7276
- \`\`\`
7277
-
7278
- When you learn something that should outlive this session (a decision and
7279
- its reason, a user preference, a lesson), record it right then, while you
7280
- work, never as a closing step. Leave out secrets and personal details:
7281
- \`\`\`bash
7282
- hippo remember "<what you learned and why>"
7283
- \`\`\`
7284
-
7285
- When stuck or repeating yourself, check if this happened before:
7286
- \`\`\`bash
7287
- hippo recall "<what's going wrong>" --budget 2000
7288
- \`\`\`
7289
-
7290
- When ending a session, capture a brief summary:
7291
- \`\`\`bash
7292
- hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
7293
- \`\`\`
7294
- `.trim(),
7295
- },
7296
- 'pi': {
7297
- file: 'AGENTS.md',
7298
- description: 'Pi',
7299
- content: `
7300
- ## Project Memory (Hippo)
7301
-
7302
- At the start of every session, run:
7303
- \`\`\`bash
7304
- hippo context --auto --budget 1500
7305
- \`\`\`
7306
- Read the output before writing any code.
7307
-
7308
- On errors or unexpected behaviour, record it right then, while you work,
7309
- never as a closing step:
7310
- \`\`\`bash
7311
- hippo remember "<description of what went wrong>" --error
7312
- \`\`\`
7313
-
7314
- When you learn something that should outlive this session (a decision and
7315
- its reason, a user preference, a lesson), record it right then, while you
7316
- work, never as a closing step. Leave out secrets and personal details:
7317
- \`\`\`bash
7318
- hippo remember "<what you learned and why>"
7319
- \`\`\`
7320
-
7321
- When ending a session, capture a brief summary:
7322
- \`\`\`bash
7323
- hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
7324
- \`\`\`
7325
-
7326
- For full integration, copy the hippo-memory Pi extension to \`~/.pi/agent/extensions/hippo-memory/\`.
7327
- `.trim(),
7328
- },
7329
- };
7330
- // sha256 of each trimmed block an earlier hippo wrote, so init refreshes only blocks nobody edited. Add the old hash when a block changes.
7331
- const SHIPPED_HOOK_HASHES = new Map([
7332
- ['c04e48f2896a4fee9ae98f8f832e2d26a3910269df3beb5bcd6baee3cd9db68e', 'claude-code'],
7333
- ['e6b12bd8983c032e5ca8e95a97aeff4178a5a05026d10acad5b2e1b25d5656dd', 'claude-code'],
7334
- ['4c64e11d3e5be68fa547c9248d7553feb645a02f7cf13ba02f7275e1854baf44', 'claude-code'],
7335
- ['293bd319bbc86225a0ee027490a3322a0336257f5832f7fade65e4ffb2530654', 'claude-code'],
7336
- ['15abcece9712279fb4721f7a8f0ba117457400278977beb5cf5b5d7ba49f7b1a', 'codex'],
7337
- ['0c81a6b2c21473313001f624b80ea870e661aecbfda9bfe8503febc0d5f34533', 'codex'],
7338
- ['88e45358aba4f17912f113221c991dc758275991335d1daa4aa1974a69c46769', 'codex'],
7339
- ['e61632fe177450a06541c148a9a4f9182530d8df667806927a99792825903298', 'codex'],
7340
- ['a1415ecda9b2f8f317c233738e4a5ac16e6b2cc385a017c0c8ecfbfacbcab6a3', 'cursor'],
7341
- ['a38c428bbdfc14ec50f6f7b9183785170a4eae1ce9cde60257cca6efc7206b3a', 'cursor'],
7342
- ['0ec9f556abfd55e94f9e6fb47ece0fc5acb841977d144b35a2371e03645d8636', 'cursor'],
7343
- ['40524c3bd5a2eb04036567cc761451961d950995768bccd93a9900b0f75eafea', 'openclaw'],
7344
- ['7b3518e8c0feaa7b8b454cde7743f7598ad14cd9979e1680d0954484e2464aae', 'openclaw'],
7345
- ['1137dcf04568caf011e41db77bc55324faee88bc29c3a5fcc98ab687cd952a16', 'openclaw'],
7346
- ['4601c67c31f41cd5b1324cfccdb1afc66872b7fb0bc1e7c5789ecabb1f6bd942', 'opencode'],
7347
- ['90d9e21d8d1ecbe99a0fc7b7f2d9f8af7b5315a6b4b0203df4f7a9bdc0699b98', 'opencode'],
7348
- ['ca4e00284f1397ed2f2fcc53210c27f63b90edf6b37fd66dad5ee58b94ea3eee', 'opencode'],
7349
- ['8b8f5986d7f7ed15f06e68720d8913c3cab23d94366b411935ca2bbaa334553b', 'pi'],
7350
- ['37767b355e18beac726b05b9e2b898dab8c6135fd7b98f3aa52edc734d5dd283', 'pi'],
7351
- ['6e85a5cccb3cfeaa9a080713754936db730f96376f94cc9a9888a746149c7268', 'pi'],
7352
- ]);
7353
5890
  function cmdHook(args, flags) {
7354
5891
  const subcommand = args[0];
7355
5892
  const target = args[1];
@@ -7928,14 +6465,6 @@ function cmdDrillDown(hippoRoot, summaryId, flags) {
7928
6465
  // ---------------------------------------------------------------------------
7929
6466
  // Auth subcommands (A5 stub auth)
7930
6467
  // ---------------------------------------------------------------------------
7931
- function resolveAuthRoot(hippoRoot, flags) {
7932
- if (flags['global']) {
7933
- initGlobal();
7934
- return getGlobalRoot();
7935
- }
7936
- requireInit(hippoRoot);
7937
- return hippoRoot;
7938
- }
7939
6468
  function cmdAuthCreate(hippoRoot, flags) {
7940
6469
  const root = resolveAuthRoot(hippoRoot, flags);
7941
6470
  const tenantFlag = typeof flags['tenant'] === 'string' ? flags['tenant'] : undefined;
@@ -8015,53 +6544,31 @@ function cmdAuthList(hippoRoot, flags) {
8015
6544
  }
8016
6545
  function cmdAuthRevoke(hippoRoot, keyId, flags) {
8017
6546
  const root = resolveAuthRoot(hippoRoot, flags);
8018
- const asJson = Boolean(flags['json']);
6547
+ // The local CLI owns every tenant, so the revoke runs in the key's own tenant.
8019
6548
  const db = openHippoDb(root);
8020
- let exists = false;
8021
- let alreadyRevoked = false;
8022
- let revokedAt = null;
8023
- let keyTenantId = null;
6549
+ let keyTenant;
8024
6550
  try {
8025
- const row = db.prepare(`SELECT key_id, tenant_id, revoked_at FROM api_keys WHERE key_id = ?`).get(keyId);
8026
- if (!row) {
8027
- // Let the finally{} block close the db. M4: avoid manual close before
8028
- // process.exit() — the finally already handles it on every path.
8029
- console.error(`Unknown key_id: ${keyId}`);
8030
- process.exit(1);
8031
- }
8032
- exists = true;
8033
- keyTenantId = row.tenant_id;
8034
- if (row.revoked_at) {
8035
- alreadyRevoked = true;
8036
- revokedAt = row.revoked_at;
8037
- }
8038
- else {
8039
- revokeApiKey(db, keyId);
8040
- const updated = db.prepare(`SELECT revoked_at FROM api_keys WHERE key_id = ?`).get(keyId);
8041
- revokedAt = updated?.revoked_at ?? null;
8042
- }
8043
- // M1: emit auth_revoke audit event. Skip on no-op revoke (already revoked)
8044
- // so re-running the command doesn't pad the audit log with duplicates.
8045
- if (!alreadyRevoked && keyTenantId) {
8046
- try {
8047
- appendAuditEvent(db, {
8048
- tenantId: keyTenantId,
8049
- actor: 'cli',
8050
- op: 'auth_revoke',
8051
- targetId: keyId,
8052
- });
8053
- }
8054
- catch {
8055
- // Audit must not crash a successful revoke.
8056
- }
8057
- }
6551
+ // SAFETY: row's shape matches the single tenant_id column in the SELECT.
6552
+ const row = db.prepare(`SELECT tenant_id FROM api_keys WHERE key_id = ?`).get(keyId);
6553
+ keyTenant = row?.tenant_id;
8058
6554
  }
8059
6555
  finally {
8060
6556
  closeHippoDb(db);
8061
6557
  }
8062
- if (!exists)
8063
- return;
8064
- if (asJson) {
6558
+ if (keyTenant === undefined) {
6559
+ console.error(`Unknown key_id: ${keyId}`);
6560
+ process.exit(1);
6561
+ }
6562
+ const ctx = { hippoRoot: root, tenantId: keyTenant, actor: api.adminActor('cli') };
6563
+ let revokedAt;
6564
+ try {
6565
+ revokedAt = api.authRevoke(ctx, keyId).revokedAt;
6566
+ }
6567
+ catch (err) {
6568
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
6569
+ process.exit(1);
6570
+ }
6571
+ if (flags['json']) {
8065
6572
  console.log(JSON.stringify({ keyId, revokedAt }));
8066
6573
  return;
8067
6574
  }
@@ -9491,7 +7998,7 @@ async function main(command, args, flags, hippoRoot) {
9491
7998
  await cmdRefine(hippoRoot, flags);
9492
7999
  break;
9493
8000
  case 'sleep':
9494
- await cmdSleep(hippoRoot, flags);
8001
+ await (await import('./cli/sleep.js')).cmdSleep(hippoRoot, flags);
9495
8002
  break;
9496
8003
  case 'last-sleep':
9497
8004
  cmdLastSleep(flags);