hippo-memory 1.59.0 → 1.60.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 (59) hide show
  1. package/dist/api.d.ts +2 -0
  2. package/dist/api.js +25 -9
  3. package/dist/audit.js +1 -0
  4. package/dist/autolearn.js +5 -2
  5. package/dist/capture.js +8 -6
  6. package/dist/cli.d.ts +355 -0
  7. package/dist/cli.js +1236 -1081
  8. package/dist/compaction-record.js +7 -6
  9. package/dist/config.js +12 -11
  10. package/dist/connectors/github/cli-impl.js +1 -0
  11. package/dist/consolidate.js +4 -3
  12. package/dist/dag.js +7 -7
  13. package/dist/dashboard.js +4 -2
  14. package/dist/db.js +16 -3
  15. package/dist/delivery-recorder.js +1 -0
  16. package/dist/doctor.js +1 -0
  17. package/dist/dormant.js +1 -0
  18. package/dist/embedding-provider.js +3 -2
  19. package/dist/embeddings.js +10 -7
  20. package/dist/extract.js +4 -3
  21. package/dist/graph.js +3 -8
  22. package/dist/handoff.js +3 -0
  23. package/dist/hooks.js +3 -0
  24. package/dist/importers.js +6 -3
  25. package/dist/incidents.js +1 -0
  26. package/dist/judgment.js +5 -2
  27. package/dist/mcp/server.d.ts +5 -0
  28. package/dist/mcp/server.js +32 -13
  29. package/dist/memory.d.ts +5 -3
  30. package/dist/processes.js +1 -0
  31. package/dist/project-identity.d.ts +1 -1
  32. package/dist/project-identity.js +9 -4
  33. package/dist/raw-archive-mirror-cleanup.js +2 -1
  34. package/dist/recall-trace.d.ts +2 -2
  35. package/dist/recall-trace.js +10 -14
  36. package/dist/refine-llm.js +18 -10
  37. package/dist/rerankers/clef.d.ts +2 -2
  38. package/dist/rerankers/clef.js +68 -27
  39. package/dist/rerankers/cross-encoder.js +6 -4
  40. package/dist/rerankers/jev.d.ts +3 -1
  41. package/dist/rerankers/jev.js +20 -15
  42. package/dist/rerankers/llm.js +3 -3
  43. package/dist/same-text.d.ts +2 -0
  44. package/dist/same-text.js +4 -0
  45. package/dist/scheduler.js +1 -0
  46. package/dist/search.js +4 -2
  47. package/dist/secret-detect.js +1 -0
  48. package/dist/server.js +29 -12
  49. package/dist/shared.js +21 -19
  50. package/dist/stdin.js +1 -0
  51. package/dist/store.d.ts +33 -1
  52. package/dist/store.js +117 -16
  53. package/dist/token-ledger.js +1 -0
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  57. package/extensions/openclaw-plugin/package.json +1 -1
  58. package/openclaw.plugin.json +1 -1
  59. package/package.json +2 -1
package/dist/cli.js CHANGED
@@ -841,9 +841,12 @@ function parseRerankerFlag(flags) {
841
841
  }
842
842
  if (!fn)
843
843
  return {};
844
- const topK = flags['reranker-top-k'] !== undefined
845
- ? parseInt(String(flags['reranker-top-k']), 10)
846
- : name === 'jev' || isClefModel(name) ? JEV_DEFAULT_TOP_K : 50;
844
+ const raw = flags['reranker-top-k'];
845
+ const topK = raw !== undefined ? Number(raw) : name === 'jev' || isClefModel(name) ? JEV_DEFAULT_TOP_K : 50;
846
+ // slice(0, -1) would quietly drop the last candidate rather than fail.
847
+ if (!Number.isInteger(topK) || topK < 1) {
848
+ return { fail: failWith(`Invalid --reranker-top-k: "${String(raw)}". Must be a positive integer.`) };
849
+ }
847
850
  return { value: { fn, topK } };
848
851
  }
849
852
  function parseSalienceFlag(flags) {
@@ -7202,13 +7205,660 @@ function cmdSlack(hippoRoot, args, flags) {
7202
7205
  printError('Usage: hippo slack <backfill|dlq list|dlq replay <id> [--force]|workspaces add|workspaces list|workspaces remove> [...]');
7203
7206
  process.exit(1);
7204
7207
  }
7205
- export function usageText() {
7206
- return `
7207
- Hippo - memory for AI agents that learns what is wrong and ranks it down
7208
-
7209
- Usage: hippo <command> [options]
7210
-
7211
- Commands:
7208
+ async function handleRemember({ hippoRoot, args, flags }) {
7209
+ let text;
7210
+ if (args.length === 1 && args[0] === '-') {
7211
+ text = fs.readFileSync(0, 'utf-8').trim();
7212
+ }
7213
+ else {
7214
+ text = args.join(' ').trim();
7215
+ }
7216
+ if (!text || text.length < 3) {
7217
+ printError('Memory content too short (minimum 3 characters).');
7218
+ process.exit(1);
7219
+ }
7220
+ // Thin-client routing. When a server is up, simple `remember` calls go
7221
+ // over HTTP so the daemon stays single-writer (footgun #2). Rich CLI
7222
+ // flags (--pin, --layer, --extract, --global) still need the direct
7223
+ // path; we only intercept the minimal envelope. The salience gate is
7224
+ // NOT in richFlag and the route does not apply it, so a routed remember
7225
+ // stores what a direct one would skip. Measured 2026-09-07, tracked in
7226
+ // TODOS.md; do not read this list as covering salience.
7227
+ const richFlag = flags['pin'] || flags['global'] || flags['extract'] || flags['force'] ||
7228
+ flags['observed'] || flags['inferred'] || flags['verified'] ||
7229
+ flags['layer'] !== undefined;
7230
+ if (!richFlag) {
7231
+ const rememberKindRaw = typeof flags['kind'] === 'string' ? flags['kind'].toLowerCase() : undefined;
7232
+ const rememberKindAllowed = ['distilled', 'superseded'];
7233
+ if (rememberKindRaw === undefined || rememberKindAllowed.includes(rememberKindRaw)) {
7234
+ const tags = rememberTags(flags, process.cwd()).all;
7235
+ // B2 v1.12.6 — validate --owner on the thin-client path too.
7236
+ // Failure on this path exits early so the user gets the same
7237
+ // validation experience whether or not a server is up.
7238
+ const thinOwnerRaw = typeof flags['owner'] === 'string' ? flags['owner'] : undefined;
7239
+ const thinOwnerCheck = validateOwner(thinOwnerRaw, { strict: isStrictOwnerEnv() });
7240
+ if (!thinOwnerCheck.ok) {
7241
+ printError(thinOwnerCheck.message);
7242
+ process.exit(1);
7243
+ }
7244
+ if (thinOwnerCheck.message)
7245
+ printError(thinOwnerCheck.message);
7246
+ const remembered = await runViaServerIfAvailable(hippoRoot, async (info, apiKey) => {
7247
+ const result = await client.remember(info.url, apiKey, {
7248
+ content: text,
7249
+ kind: rememberKindRaw,
7250
+ scope: typeof flags['scope'] === 'string' ? flags['scope'] : undefined,
7251
+ owner: thinOwnerCheck.value,
7252
+ artifactRef: typeof flags['artifact-ref'] === 'string' ? flags['artifact-ref'] : undefined,
7253
+ tags,
7254
+ });
7255
+ console.log(`Remembered [${result.id}] (via ${info.url})`);
7256
+ console.log(` Kind: ${result.kind} | Tenant: ${result.tenantId}`);
7257
+ for (const w of result.warnings ?? [])
7258
+ printError(`Warning: ${w}`);
7259
+ });
7260
+ if (remembered)
7261
+ return;
7262
+ }
7263
+ }
7264
+ await cmdRemember(hippoRoot, text, flags);
7265
+ }
7266
+ async function handleRecall({ hippoRoot, args, flags }) {
7267
+ const query = args.join(' ').trim();
7268
+ if (!query) {
7269
+ printError('Please provide a search query.');
7270
+ process.exit(1);
7271
+ }
7272
+ await cmdRecall(hookStoreRoot(hippoRoot), query, flags);
7273
+ }
7274
+ function handleDrill({ hippoRoot, args, flags }) {
7275
+ const summaryId = args[0];
7276
+ if (!summaryId) {
7277
+ printError('Usage: hippo drill <summary-id> [--limit N] [--budget N]');
7278
+ process.exit(1);
7279
+ }
7280
+ cmdDrillDown(hippoRoot, summaryId, flags);
7281
+ }
7282
+ function handleAssemble({ hippoRoot, args, flags }) {
7283
+ const sessionId = typeof flags['session'] === 'string' ? flags['session'] : args[0];
7284
+ if (!sessionId) {
7285
+ printError('Usage: hippo assemble --session <id> [--budget N] [--fresh-tail N] [--no-summarize-older] [--json]');
7286
+ process.exit(1);
7287
+ }
7288
+ cmdAssemble(hippoRoot, sessionId, flags);
7289
+ }
7290
+ function handleSupersede({ hippoRoot, args, flags }) {
7291
+ const oldId = args[0];
7292
+ const newContent = args.slice(1).join(' ').trim();
7293
+ if (!oldId || !newContent) {
7294
+ printError('Usage: hippo supersede <old-id> "<new content>" [--layer L] [--tag T] [--pin]');
7295
+ process.exit(1);
7296
+ }
7297
+ cmdSupersede(hippoRoot, oldId, newContent, flags);
7298
+ }
7299
+ async function handleExplain({ hippoRoot, args, flags }) {
7300
+ const query = args.join(' ').trim();
7301
+ if (!query) {
7302
+ printError('Please provide a search query.');
7303
+ process.exit(1);
7304
+ }
7305
+ await cmdExplain(hippoRoot, query, flags);
7306
+ }
7307
+ async function handleEval({ hippoRoot, args, flags }) {
7308
+ const corpusPath = args[0] ? String(args[0]) : null;
7309
+ await cmdEval(hippoRoot, corpusPath, flags);
7310
+ }
7311
+ function handleTrace({ hippoRoot, args, flags }) {
7312
+ const sub = args[0] ? String(args[0]) : '';
7313
+ if (sub === 'record') {
7314
+ cmdTraceRecord(hippoRoot, flags);
7315
+ return;
7316
+ }
7317
+ if (!sub) {
7318
+ printError('Usage: hippo trace <memory-id> | hippo trace record --task <t> --steps <json> --outcome <o>');
7319
+ process.exit(1);
7320
+ }
7321
+ cmdTrace(hippoRoot, sub, flags);
7322
+ }
7323
+ async function handlePreCompact({ hippoRoot, flags }) {
7324
+ // Bounded wait, not a TTY guard: an idle non-TTY pipe must not hang.
7325
+ const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
7326
+ await runHookWithStores(async () => {
7327
+ resetHookInjection(hippoRoot, stdinText, null);
7328
+ await cmdPreCompact(hookStoreRoot(hippoRoot), {
7329
+ stdinText,
7330
+ stdinTimedOut,
7331
+ logFile: typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined,
7332
+ });
7333
+ });
7334
+ }
7335
+ async function handlePostCompact({ hippoRoot, flags }) {
7336
+ // PostCompact hook: saves the compaction summary and its memories, then prints one plain line, because Claude Code shows this hook's stdout as-is. Always exits 0.
7337
+ const { text } = await readStdinBounded();
7338
+ const logFlag = flags['log-file'];
7339
+ const store = hookStoreRoot(hippoRoot);
7340
+ const line = await runHookWithStores(() => cmdPostCompact(store, {
7341
+ stdinText: text,
7342
+ logFile: logFlag === true || logFlag === false || Array.isArray(logFlag) ? undefined : logFlag,
7343
+ // Passed in, since capture.ts importing the sync would close an import cycle.
7344
+ afterSave: (transcriptPath, originProject, log) => {
7345
+ const report = importAtCompaction(store, transcriptPath, originProject, { machine: currentMachine(), busyWaitMs: COMPACTION_DB_WAIT_MS });
7346
+ const summary = summaryLine(report);
7347
+ if (summary !== null)
7348
+ log(summary);
7349
+ for (const warning of report.warnings)
7350
+ log(`agent memories: ${warning}`);
7351
+ },
7352
+ }));
7353
+ if (line !== null && line !== undefined)
7354
+ console.log(line);
7355
+ }
7356
+ async function handleCaptureError({ hippoRoot }) {
7357
+ // PostToolUseFailure hook: every path exits 0, and nothing is created
7358
+ // when no store exists (the hook fires in every directory).
7359
+ const { text } = await readStdinBounded();
7360
+ try {
7361
+ const root = hookStoreRoot(hippoRoot);
7362
+ const payload = (text ?? '').trim();
7363
+ if (isInitialized(root) && payload) {
7364
+ // SAFETY: JSON.parse returns a JSON value by definition.
7365
+ const failure = JSON.parse(payload);
7366
+ await runHookWithStores(() => captureToolFailure(root, resolveTenantId({}), failure));
7367
+ }
7368
+ }
7369
+ catch {
7370
+ // A malformed payload or store error must never fail the agent's tool call.
7371
+ }
7372
+ }
7373
+ async function handleCompactResume({ hippoRoot }) {
7374
+ const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
7375
+ await runHookWithStores(() => {
7376
+ resetHookInjection(hippoRoot, stdinText, 'compact');
7377
+ cmdCompactResume(hookStoreRoot(hippoRoot), stdinText, stdinTimedOut);
7378
+ });
7379
+ }
7380
+ function handleAudit({ hippoRoot, args, flags }) {
7381
+ // `audit list` and `audit prune` -> A5 audit-log subcommands.
7382
+ // Other forms (no sub, --fix) keep the existing memory-quality auditor
7383
+ // for backwards compatibility.
7384
+ if (args[0] === 'list' || args[0] === 'prune') {
7385
+ cmdAuditLog(hippoRoot, args, flags);
7386
+ return;
7387
+ }
7388
+ requireInit(hippoRoot);
7389
+ const entries = loadAllEntries(hippoRoot, resolveTenantId({}));
7390
+ const result = auditMemories(entries, memoriesBackingObjects(hippoRoot));
7391
+ const shouldFix = Boolean(flags['fix']);
7392
+ if (result.issues.length === 0) {
7393
+ console.log(`All ${result.total} memories passed quality checks.`);
7394
+ }
7395
+ else {
7396
+ console.log(`Audited ${result.total} memories: ${result.clean} clean, ${result.issues.length} issues\n`);
7397
+ for (const issue of result.issues) {
7398
+ const icon = issue.severity === 'error' ? 'ERR' : 'WARN';
7399
+ console.log(` [${icon}] ${issue.memoryId}: ${issue.reason}`);
7400
+ console.log(` "${issue.content.slice(0, 80)}${issue.content.length > 80 ? '...' : ''}"`);
7401
+ }
7402
+ if (shouldFix) {
7403
+ const errors = result.issues.filter(i => i.severity === 'error');
7404
+ if (errors.length > 0 && flags['dry-run'] === true) {
7405
+ console.log(`\nWould remove ${errors.length} error-severity memories (dry run, nothing deleted).`);
7406
+ console.log(`${result.issues.length - errors.length} warnings would remain (review manually).`);
7407
+ }
7408
+ else if (errors.length > 0) {
7409
+ const removedCount = errors.filter((issue) => deleteEntry(hippoRoot, issue.memoryId, { reason: `audit --fix: ${issue.reason}`, automatic: true })).length;
7410
+ console.log(`\nRemoved ${removedCount} error-severity memories.`);
7411
+ console.log(`${result.issues.length - errors.length} warnings remain (review manually).`);
7412
+ }
7413
+ else {
7414
+ console.log(`\nNo error-severity issues. Warnings require manual review.`);
7415
+ }
7416
+ }
7417
+ else {
7418
+ console.log(`\nRun with --fix to auto-remove error-severity issues.`);
7419
+ }
7420
+ }
7421
+ }
7422
+ function handleCorrectionLatency({ hippoRoot, flags }) {
7423
+ requireInit(hippoRoot);
7424
+ const entries = loadAllEntries(hippoRoot);
7425
+ const report = buildCorrectionLatency(entries);
7426
+ if (flags['json']) {
7427
+ console.log(JSON.stringify(report, null, 2));
7428
+ }
7429
+ else if (report.count === 0) {
7430
+ console.log('No supersessions found. Correction latency is undefined.');
7431
+ }
7432
+ else {
7433
+ const fmt = (ms) => {
7434
+ if (ms === null)
7435
+ return 'n/a';
7436
+ if (ms < 1000)
7437
+ return `${ms}ms`;
7438
+ if (ms < 60_000)
7439
+ return `${(ms / 1000).toFixed(1)}s`;
7440
+ if (ms < 3_600_000)
7441
+ return `${(ms / 60_000).toFixed(1)}m`;
7442
+ return `${(ms / 3_600_000).toFixed(1)}h`;
7443
+ };
7444
+ console.log(`Corrections: ${report.count} total (${report.extractionCount} extraction-driven, ${report.manualCount} manual)`);
7445
+ console.log(`Latency p50: ${fmt(report.p50Ms)}, p95: ${fmt(report.p95Ms)}, max: ${fmt(report.maxMs)}`);
7446
+ if (report.extractionCount === 0 && report.manualCount > 0) {
7447
+ console.log(`\nAll ${report.manualCount} corrections were manual supersedes: no measurable observation lag.`);
7448
+ console.log(`To measure latency, route corrections through extraction (set new.extracted_from to the raw receipt).`);
7449
+ }
7450
+ }
7451
+ }
7452
+ function handleProvenance({ hippoRoot, flags }) {
7453
+ requireInit(hippoRoot);
7454
+ const entries = loadAllEntries(hippoRoot);
7455
+ const coverage = buildProvenanceCoverage(entries);
7456
+ if (flags['json']) {
7457
+ console.log(JSON.stringify(coverage, null, 2));
7458
+ }
7459
+ else if (coverage.rawTotal === 0) {
7460
+ console.log('No kind=raw memories present. Coverage gate trivially satisfied.');
7461
+ }
7462
+ else {
7463
+ const pct = (coverage.coverage * 100).toFixed(1);
7464
+ console.log(`Provenance coverage: ${coverage.rawWithEnvelope}/${coverage.rawTotal} raw rows envelope-complete (${pct}%)`);
7465
+ if (coverage.gaps.length > 0) {
7466
+ console.log(`\nGaps:`);
7467
+ for (const g of coverage.gaps) {
7468
+ console.log(` ${g.id}: missing ${g.missing.join(', ')}`);
7469
+ }
7470
+ }
7471
+ }
7472
+ if (flags['strict'] && coverage.coverage < 1) {
7473
+ process.exit(1);
7474
+ }
7475
+ }
7476
+ function handleDoctor({ flags }) {
7477
+ // SAFETY: package.json always carries a string "version" (checked at release by check-manifest-versions).
7478
+ const pkg = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'));
7479
+ const report = runDoctor({ version: pkg.version });
7480
+ console.log(flags['json'] ? JSON.stringify(report, null, 2) : formatDoctor(report));
7481
+ if (!report.ok)
7482
+ process.exit(1);
7483
+ }
7484
+ function handleSupportBundle({ flags }) {
7485
+ const outFlag = cardStringFlag(flags, 'out');
7486
+ if (outFlag === '') {
7487
+ printError('--out requires a file path.');
7488
+ process.exit(1);
7489
+ }
7490
+ const includeLogs = flags['include-logs'] === true;
7491
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
7492
+ const now = new Date();
7493
+ const bundle = buildSupportBundle({ cwd: process.cwd(), home, version: PACKAGE_VERSION, includeLogs, now });
7494
+ const stamp = now.toISOString().replace(/[:.]/g, '-');
7495
+ const file = outFlag ?? path.join(process.cwd(), `hippo-support-${stamp}.json`);
7496
+ const json = JSON.stringify(bundle, null, 2);
7497
+ try {
7498
+ fs.writeFileSync(file, `${json}\n`, { flag: 'wx', mode: 0o600 });
7499
+ }
7500
+ catch (err) {
7501
+ if (err instanceof Error && 'code' in err && err.code === 'EEXIST') {
7502
+ printError(`${file} already exists; pass --out to choose another file. Nothing was written.`);
7503
+ }
7504
+ else {
7505
+ printError(err instanceof Error ? err.message : String(err));
7506
+ }
7507
+ process.exit(1);
7508
+ }
7509
+ const kb = Math.round(Buffer.byteLength(json) / 1024);
7510
+ console.log(`Wrote ${file} (${kb} KB).`);
7511
+ console.log(includeLogs
7512
+ ? `It holds versions, doctor checks, config with secrets removed, store counts, and the last ${TAIL_MAX_LINES} lines of each hippo log with known secret shapes removed. Those log lines can quote memory text. Read it before you attach it to a ticket.`
7513
+ : 'It holds versions, doctor checks, config with secrets removed, store counts and log file names. It never holds memory text. Read it before you attach it to a ticket.');
7514
+ }
7515
+ async function handleForget({ hippoRoot, args, flags }) {
7516
+ const id = args[0];
7517
+ if (!id) {
7518
+ printError('Please provide a memory ID.');
7519
+ process.exit(1);
7520
+ }
7521
+ // Archive has its own HTTP route (POST /v1/memories/:id/archive); route
7522
+ // both branches the same way the direct path does.
7523
+ const archive = flags['archive'] === true;
7524
+ const reason = typeof flags['reason'] === 'string' ? flags['reason'] : null;
7525
+ if (archive && !reason) {
7526
+ printError(ARCHIVE_REASON_REQUIRED);
7527
+ process.exit(1);
7528
+ }
7529
+ if (flags['dry-run'] === true) {
7530
+ previewForget(hippoRoot, id, archive);
7531
+ return;
7532
+ }
7533
+ const routed = await runViaServerIfAvailable(hippoRoot, async (info, apiKey) => {
7534
+ try {
7535
+ if (archive) {
7536
+ await client.archiveRaw(info.url, apiKey, id, reason);
7537
+ console.log(`Archived ${id}`);
7538
+ }
7539
+ else {
7540
+ await client.forget(info.url, apiKey, id);
7541
+ console.log(`Forgot ${id}`);
7542
+ }
7543
+ }
7544
+ catch (err) {
7545
+ // A server that died after the health probe is the caller's transport
7546
+ // fallback to handle, not an error to report to the user.
7547
+ if (client.classifyTransportFailure(err) !== 'none')
7548
+ throw err;
7549
+ const msg = err instanceof Error ? err.message : String(err);
7550
+ printError(archive ? `Could not archive ${id}: ${msg}` : msg);
7551
+ process.exit(1);
7552
+ }
7553
+ });
7554
+ if (routed)
7555
+ return;
7556
+ cmdForget(hippoRoot, id, flags);
7557
+ }
7558
+ function handleInspect({ hippoRoot, args }) {
7559
+ const id = args[0];
7560
+ if (!id) {
7561
+ printError('Please provide a memory ID.');
7562
+ process.exit(1);
7563
+ }
7564
+ cmdInspect(hippoRoot, id);
7565
+ }
7566
+ async function handleContext({ hippoRoot, args, flags }) {
7567
+ // Bounded, not a TTY guard (DF1 T2, docs/plans/2026-08-23-df1-snapshot-lifecycle.md):
7568
+ // the hot stdin path and a manual run share this one command.
7569
+ const { text: stdinText } = await readStdinBounded();
7570
+ await runHookWithStores(() => cmdContext(hookStoreRoot(hippoRoot), args, flags, stdinText));
7571
+ }
7572
+ async function handleWatch({ hippoRoot, args }) {
7573
+ const watchCmd = args.join(' ').trim();
7574
+ await cmdWatch(watchCmd, hippoRoot);
7575
+ }
7576
+ async function handlePromote({ hippoRoot, args }) {
7577
+ const id = args[0];
7578
+ if (!id) {
7579
+ printError('Please provide a memory ID.');
7580
+ process.exit(1);
7581
+ }
7582
+ const promoted = await runViaServerIfAvailable(hippoRoot, async (info, apiKey) => {
7583
+ try {
7584
+ const result = await client.promote(info.url, apiKey, id);
7585
+ console.log(`Promoted ${id} to global store as ${result.globalId}`);
7586
+ }
7587
+ catch (err) {
7588
+ printError(`Failed to promote: ${err.message}`);
7589
+ process.exit(1);
7590
+ }
7591
+ });
7592
+ if (promoted)
7593
+ return;
7594
+ cmdPromote(hippoRoot, id);
7595
+ }
7596
+ function handleShare({ hippoRoot, args, flags }) {
7597
+ const shareId = args[0];
7598
+ if (shareId === '--auto' || flags['auto']) {
7599
+ // Auto-share mode
7600
+ requireInit(hippoRoot);
7601
+ const minScore = parseFloat(String(flags['min-score'] ?? '0.6'));
7602
+ const dryRun = Boolean(flags['dry-run']);
7603
+ const results = autoShare(hippoRoot, { minScore, dryRun, tenantId: resolveTenantId({}) });
7604
+ if (results.length === 0) {
7605
+ console.log('No memories meet the sharing threshold.');
7606
+ }
7607
+ else if (dryRun) {
7608
+ console.log(`Would share ${results.length} memories:\n`);
7609
+ for (const e of results) {
7610
+ const score = transferScore(e);
7611
+ console.log(` ${e.id} (transfer=${fmt(score)}) ${e.content.slice(0, 80)}...`);
7612
+ }
7613
+ }
7614
+ else {
7615
+ console.log(`Shared ${results.length} memories to global store.`);
7616
+ for (const e of results) {
7617
+ console.log(` ${e.id} <- ${e.source}`);
7618
+ }
7619
+ }
7620
+ }
7621
+ else if (shareId) {
7622
+ requireInit(hippoRoot);
7623
+ const force = Boolean(flags['force']);
7624
+ const tenantId = resolveTenantId({});
7625
+ const result = shareMemory(hippoRoot, shareId, { force, tenantId });
7626
+ if (result) {
7627
+ console.log(`Shared [${result.id}] to global store.`);
7628
+ console.log(` Source: ${result.source}`);
7629
+ }
7630
+ else {
7631
+ const entry = readEntry(hippoRoot, shareId, tenantId);
7632
+ if (entry) {
7633
+ const score = transferScore(entry);
7634
+ console.log(`Transfer score too low (${fmt(score)}). This memory looks project-specific.`);
7635
+ console.log('Use --force to share anyway.');
7636
+ }
7637
+ else {
7638
+ printError(`Memory not found: ${shareId}`);
7639
+ process.exit(1);
7640
+ }
7641
+ }
7642
+ }
7643
+ else {
7644
+ printError('Usage: hippo share <memory_id> [--force] or hippo share --auto [--dry-run]');
7645
+ process.exit(1);
7646
+ }
7647
+ }
7648
+ function handlePeers({ flags }) {
7649
+ // D4 v1.12.10: tenant-scoped by default. --all-tenants restores the
7650
+ // pre-D4 host-wide view for the rare operator who genuinely wants
7651
+ // cross-tenant peer discovery.
7652
+ const allTenants = flags['all-tenants'] === true;
7653
+ const tenantScope = allTenants ? undefined : resolveTenantId({});
7654
+ const peers = listPeers(undefined, tenantScope);
7655
+ if (peers.length === 0) {
7656
+ console.log('No peers found. Share memories with: hippo share <id>');
7657
+ }
7658
+ else {
7659
+ const scopeLabel = allTenants ? 'global store (all tenants)' : `global store (tenant "${tenantScope}")`;
7660
+ console.log(`${peers.length} project${peers.length === 1 ? '' : 's'} contributing to ${scopeLabel}:\n`);
7661
+ for (const p of peers) {
7662
+ console.log(` ${p.project.padEnd(25)} ${String(p.count).padStart(4)} memories (latest: ${p.latest.slice(0, 10)})`);
7663
+ }
7664
+ }
7665
+ }
7666
+ function handleExport({ hippoRoot, args, flags }) {
7667
+ requireInit(hippoRoot);
7668
+ const format = flags['format'] || 'json';
7669
+ const outputPath = args[0] || null;
7670
+ const entries = loadAllEntries(hippoRoot, resolveTenantId({}));
7671
+ let output;
7672
+ if (format === 'markdown' || format === 'md') {
7673
+ output = entries.map(e => {
7674
+ const meta = [
7675
+ `id: ${e.id}`,
7676
+ `created: ${e.created}`,
7677
+ `tags: ${e.tags.join(', ')}`,
7678
+ `confidence: ${e.confidence}`,
7679
+ `half_life: ${e.half_life_days}d`,
7680
+ `strength: ${e.strength.toFixed(2)}`,
7681
+ ].join(' | ');
7682
+ return `### ${e.id}\n\n${e.content}\n\n_${meta}_`;
7683
+ }).join('\n\n---\n\n');
7684
+ }
7685
+ else {
7686
+ output = JSON.stringify(entries, null, 2);
7687
+ }
7688
+ if (outputPath) {
7689
+ fs.writeFileSync(outputPath, output, 'utf8');
7690
+ console.log(`Exported ${entries.length} memories to ${outputPath}`);
7691
+ }
7692
+ else {
7693
+ console.log(output);
7694
+ }
7695
+ }
7696
+ async function handleCapture({ hippoRoot, flags }) {
7697
+ let captureSource = null;
7698
+ let captureFile;
7699
+ let transcriptPath;
7700
+ if (flags['stdin']) {
7701
+ captureSource = 'stdin';
7702
+ }
7703
+ else if (flags['file']) {
7704
+ captureSource = 'file';
7705
+ captureFile = String(flags['file']);
7706
+ }
7707
+ else if (flags['last-session']) {
7708
+ captureSource = 'last-session';
7709
+ }
7710
+ if (flags['transcript']) {
7711
+ transcriptPath = String(flags['transcript']);
7712
+ if (!captureSource)
7713
+ captureSource = 'last-session';
7714
+ }
7715
+ if (!captureSource) {
7716
+ printError('Usage: hippo capture --stdin|--file <path>|--last-session [--transcript <path>] [--log-file <path>] [--dry-run] [--global]');
7717
+ process.exit(1);
7718
+ }
7719
+ // Bounded, and only when last-session has no explicit path: the
7720
+ // --stdin source keeps its own blocking read in capture.ts by design.
7721
+ const bounded = captureSource === 'last-session' && !transcriptPath
7722
+ ? await readStdinBounded()
7723
+ : { text: undefined, timedOut: false };
7724
+ cmdCapture(hippoRoot, {
7725
+ source: captureSource,
7726
+ filePath: captureFile,
7727
+ transcriptPath,
7728
+ stdinText: bounded.text,
7729
+ stdinTimedOut: bounded.timedOut,
7730
+ logFile: typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined,
7731
+ dryRun: Boolean(flags['dry-run']),
7732
+ global: Boolean(flags['global']),
7733
+ tenantId: resolveTenantId({}),
7734
+ });
7735
+ }
7736
+ async function handleDashboard({ hippoRoot, flags }) {
7737
+ requireInit(hippoRoot);
7738
+ const port = parseInt(String(flags['port'] ?? '3333'), 10);
7739
+ const { serveDashboard } = await import('./dashboard.js');
7740
+ serveDashboard(hippoRoot, port);
7741
+ await new Promise(() => { }); // run until Ctrl+C
7742
+ }
7743
+ async function handleMcp() {
7744
+ // Start MCP server over stdio. Dynamic import keeps main CLI lean; the
7745
+ // dispatcher itself is transport-agnostic, so we explicitly attach the
7746
+ // stdio loop here. (HTTP/SSE transport is wired in src/server.ts and
7747
+ // imports the same module without triggering stdin handlers.)
7748
+ const mod = await import('./mcp/server.js');
7749
+ mod.startStdioLoop();
7750
+ // Server runs until stdin closes, so we never reach here
7751
+ await new Promise(() => { }); // hang forever
7752
+ }
7753
+ async function handleServe({ hippoRoot, flags }) {
7754
+ requireInit(hippoRoot);
7755
+ const portRaw = flags['port'] ?? process.env['HIPPO_PORT'] ?? '6789';
7756
+ const port = Number(portRaw);
7757
+ if (!Number.isFinite(port) || port < 0) {
7758
+ printError(`Invalid --port: ${String(portRaw)}`);
7759
+ process.exit(1);
7760
+ }
7761
+ const host = typeof flags['host'] === 'string' ? flags['host'] : '127.0.0.1';
7762
+ const { serve } = await import('./server.js');
7763
+ const handle = await serve({ hippoRoot, port, host, handleSignals: true });
7764
+ console.log(`hippo serve listening on ${handle.url} (pid ${process.pid})`);
7765
+ console.log(`pidfile: ${path.join(hippoRoot, 'server.pid')}`);
7766
+ console.log('press Ctrl+C to stop');
7767
+ // The SIGINT/SIGTERM handlers stop the server and exit. Hang until then.
7768
+ await new Promise(() => { });
7769
+ }
7770
+ function handleInvalidate({ hippoRoot, args, flags }) {
7771
+ requireInit(hippoRoot);
7772
+ if (flags['churn'] === true) {
7773
+ if (args[0] || flags['id'] !== undefined) {
7774
+ printError('Usage: hippo invalidate --churn [--dry-run]');
7775
+ printError('--churn takes no pattern or --id.');
7776
+ process.exit(1);
7777
+ }
7778
+ if (!isGitRepo(process.cwd())) {
7779
+ printError('hippo invalidate --churn must run inside a git repository.');
7780
+ process.exit(1);
7781
+ }
7782
+ const churnDryRun = flags['dry-run'] === true;
7783
+ let churnFailed = false;
7784
+ for (const { root, result } of runChurnStaleForRepo(hippoRoot, churnDryRun)) {
7785
+ if (result.error) {
7786
+ printError(`Churn-staleness check failed for ${root}: ${result.error}`);
7787
+ churnFailed = true;
7788
+ continue;
7789
+ }
7790
+ if (result.preview.length === 0) {
7791
+ console.log(`No churn-stale candidates in ${root}.`);
7792
+ }
7793
+ else if (churnDryRun) {
7794
+ console.log(`DRY RUN - ${result.marked} memories in ${root} WOULD be tagged churn-stale (${result.alreadyMarked} already tagged):`);
7795
+ }
7796
+ else {
7797
+ console.log(`Tagged ${result.marked} memories churn-stale in ${root} (${result.alreadyMarked} already tagged):`);
7798
+ }
7799
+ result.preview.forEach(p => console.log(` ${p.id} ${p.evidence} ${p.already ? '(already) ' : ''}${p.headline}`));
7800
+ if (result.skippedPinned.length > 0) {
7801
+ console.log(`Skipped ${result.skippedPinned.length} pinned: ${result.skippedPinned.join(', ')}`);
7802
+ }
7803
+ }
7804
+ if (churnFailed)
7805
+ process.exit(1);
7806
+ return;
7807
+ }
7808
+ const target = args[0];
7809
+ if (flags['id'] === true) {
7810
+ // Value-less --id must never silently fall through to pattern mode
7811
+ // (pattern mode writes broadly; an ignored --id reverses user intent).
7812
+ printError('--id requires a memory id');
7813
+ process.exit(1);
7814
+ }
7815
+ const onlyId = typeof flags['id'] === 'string' ? flags['id'] : undefined;
7816
+ if (typeof flags['dry-run'] === 'string') {
7817
+ // Dead: the earlier global BOOLEAN_FLAGS guard now exits first on any --dry-run=<v>.
7818
+ // Kept as defence in depth on a destructive command.
7819
+ printError('--dry-run takes no value');
7820
+ process.exit(1);
7821
+ }
7822
+ const dryRun = flags['dry-run'] === true;
7823
+ if ((target && onlyId) || (!target && !onlyId)) {
7824
+ printError('Usage: hippo invalidate "<old pattern>" [--dry-run] [--reason "<why>"]');
7825
+ printError(' hippo invalidate --id <memory-id> [--dry-run] [--reason "<why>"]');
7826
+ printError('Pass a pattern OR --id, not both. Tag matching is EXACT: the full pattern must equal a tag.');
7827
+ process.exit(1);
7828
+ }
7829
+ const reason = flags['reason'] || null;
7830
+ const invTarget = {
7831
+ from: target ?? `id:${onlyId}`,
7832
+ to: reason,
7833
+ type: 'migration',
7834
+ };
7835
+ const result = invalidateMatching(hippoRoot, invTarget, resolveTenantId({}), { dryRun, onlyId });
7836
+ const label = target ? `"${target}"` : `--id ${onlyId}`;
7837
+ if (result.dryRun) {
7838
+ if (result.invalidated === 0) {
7839
+ console.log(`DRY RUN - no memories would match ${label}.`);
7840
+ }
7841
+ else {
7842
+ console.log(`DRY RUN - ${result.invalidated} memories WOULD be invalidated:`);
7843
+ result.preview.forEach(p => console.log(` ${p.id} ${p.headline}`));
7844
+ }
7845
+ }
7846
+ else if (result.invalidated === 0) {
7847
+ console.log(`No memories matched ${label}.`);
7848
+ }
7849
+ else {
7850
+ console.log(`Invalidated ${result.invalidated} memories referencing ${label}.`);
7851
+ result.targets.forEach(id => console.log(` ${id}`));
7852
+ }
7853
+ if (result.skippedPinned.length > 0) {
7854
+ console.log(`Skipped ${result.skippedPinned.length} pinned: ${result.skippedPinned.join(', ')}`);
7855
+ }
7856
+ }
7857
+ /** Every verb main() dispatches, keyed by name, with its handler, aliases and help blocks. */
7858
+ export const COMMANDS = {
7859
+ init: {
7860
+ run: ({ hippoRoot, flags }) => { cmdInit(hippoRoot, flags); },
7861
+ usage: [`
7212
7862
  init Create .hippo/ structure in current directory
7213
7863
  --scan [dir] Find all git repos under dir (default: ~) and init each
7214
7864
  --days <n> Days of git history to seed (default: 365 for --scan, 30 for single)
@@ -7217,7 +7867,11 @@ Commands:
7217
7867
  (HIPPO_SKIP_AUTO_INTEGRATIONS=1 does the same)
7218
7868
  --no-schedule Skip auto-creating the machine-level daily runner
7219
7869
  --no-learn Skip seeding memories from git history and importing
7220
- coding agents' own memories (every init imports those)
7870
+ coding agents' own memories (every init imports those)`],
7871
+ },
7872
+ remember: {
7873
+ run: handleRemember,
7874
+ usage: [`
7221
7875
  remember <text> Store a memory
7222
7876
  --tag <tag> Add a tag (repeatable)
7223
7877
  --error Tag as error (boosts retention)
@@ -7225,11 +7879,11 @@ Commands:
7225
7879
  --verified Set confidence: verified (default)
7226
7880
  --observed Set confidence: observed
7227
7881
  --inferred Set confidence: inferred
7228
- --global Store in global store ($HIPPO_HOME or ~/.hippo/)
7229
- supersede <id> "<text>" Replace a memory with a new version; the old one points at it
7230
- --layer <layer> Layer for the new memory (default: the old memory's layer)
7231
- --tag <tag> Tag for the new memory (repeatable; default: the old memory's tags)
7232
- --pin Pin the new memory (default: pinned if the old one was)
7882
+ --global Store in global store ($HIPPO_HOME or ~/.hippo/)`],
7883
+ },
7884
+ recall: {
7885
+ run: handleRecall,
7886
+ usage: [`
7233
7887
  recall <query> Search and retrieve memories (local + global)
7234
7888
  --budget <n> Token budget for the whole printed block (default: 4000)
7235
7889
  --min-results <n> Minimum results regardless of budget (default: 1)
@@ -7314,24 +7968,48 @@ Commands:
7314
7968
  where you left off in one call. Anchored on the
7315
7969
  active snapshot's session_id; no anchor = no
7316
7970
  handoff/events (use 'hippo session resume' for
7317
- the explicit handoff-without-snapshot path).
7971
+ the explicit handoff-without-snapshot path).`],
7972
+ },
7973
+ drill: {
7974
+ run: handleDrill,
7975
+ usage: [`
7976
+ drill <summary-id> Walk down a DAG level-2 summary to its children
7977
+ --limit N Cap children list (default 50)
7978
+ --budget N Token budget for the printed children (≈ chars/4)
7979
+ --json Output as JSON`],
7980
+ },
7981
+ assemble: {
7982
+ run: handleAssemble,
7983
+ usage: [`
7984
+ assemble --session <id> Build a session's chronological context window
7985
+ --budget N Token budget for the printed window (default 4000)
7986
+ --fresh-tail N Recent rows always kept verbatim (default 10)
7987
+ --no-summarize-older Disable older-row summary substitution
7988
+ --scope <s> Restrict to exact scope (default: deny *:private:*)
7989
+ --json Output as JSON`],
7990
+ },
7991
+ supersede: {
7992
+ run: handleSupersede,
7993
+ usage: [`
7994
+ supersede <id> "<text>" Replace a memory with a new version; the old one points at it
7995
+ --layer <layer> Layer for the new memory (default: the old memory's layer)
7996
+ --tag <tag> Tag for the new memory (repeatable; default: the old memory's tags)
7997
+ --pin Pin the new memory (default: pinned if the old one was)`],
7998
+ },
7999
+ explain: {
8000
+ run: handleExplain,
8001
+ usage: [`
7318
8002
  explain <query> Show full score breakdown for each retrieved memory
7319
8003
  --budget <n> Token budget, counted as recall prints (default: 4000)
7320
8004
  --limit <n> Cap the number of results displayed
7321
8005
  --json Output as JSON
7322
8006
  --physics | --classic Force search mode (default: from config)
7323
8007
  --no-mmr Disable MMR diversity re-ranking
7324
- --mmr-lambda <f> MMR balance 0..1 (default: 0.7, 1.0 = pure relevance)
7325
- trace <id> Memory dossier: content, decay trajectory, retrievals,
7326
- outcomes, consolidation parents, open conflicts
7327
- --json Output as JSON
7328
- refine Rewrite consolidated semantic memories with Claude
7329
- --limit <n> Cap the number of memories processed this run
7330
- --all Ignore \`llm-refined\` tag and re-refine everything
7331
- --dry-run Call the API but don't write results back
7332
- --model <id> Override the default model (claude-sonnet-4-6)
7333
- --json Output summary as JSON
7334
- (requires ANTHROPIC_API_KEY in env)
8008
+ --mmr-lambda <f> MMR balance 0..1 (default: 0.7, 1.0 = pure relevance)`],
8009
+ },
8010
+ eval: {
8011
+ run: handleEval,
8012
+ usage: [`
7335
8013
  eval [<corpus.json>] Measure recall quality against a test corpus
7336
8014
  --bootstrap Generate a synthetic corpus from current memories
7337
8015
  --out <path> With --bootstrap, write to file instead of stdout
@@ -7344,34 +8022,150 @@ Commands:
7344
8022
  --local-bump <f> Local-over-global priority multiplier (default: 1.2)
7345
8023
  --equal-sources Shortcut for --local-bump 1.0
7346
8024
  --min-mrr <f> Exit non-zero if mean MRR falls below this
7347
- --json Output full summary as JSON
7348
- context Smart context injection for AI agents
7349
- --auto Auto-detect task from git state
7350
- --budget <n> Token budget for the whole printed block (default: 1500)
7351
- --pinned-only Only inject pinned memories (used by UserPromptSubmit hook)
7352
- --include-recent <n> With --pinned-only, also inject the last N writes regardless of pinning
7353
- (the hook payload's "prompt" drives prompt recall instead of --include-recent when pinnedInject.promptRecall is on, the default)
7354
- --format <fmt> Output format: markdown (default), json, or additional-context (Claude Code hook JSON)
7355
- --framing <mode> Framing: observe (default), suggest, assert
8025
+ --json Output full summary as JSON`],
8026
+ },
8027
+ trace: {
8028
+ run: handleTrace,
8029
+ usage: [`
8030
+ trace <id> Memory dossier: content, decay trajectory, retrievals,
8031
+ outcomes, consolidation parents, open conflicts
8032
+ --json Output as JSON`],
8033
+ },
8034
+ refine: {
8035
+ run: async ({ hippoRoot, flags }) => { await cmdRefine(hippoRoot, flags); },
8036
+ usage: [`
8037
+ refine Rewrite consolidated semantic memories with Claude
8038
+ --limit <n> Cap the number of memories processed this run
8039
+ --all Ignore \`llm-refined\` tag and re-refine everything
8040
+ --dry-run Call the API but don't write results back
8041
+ --model <id> Override the default model (claude-sonnet-4-6)
8042
+ --json Output summary as JSON
8043
+ (requires ANTHROPIC_API_KEY in env)`],
8044
+ },
8045
+ sleep: {
8046
+ run: async ({ hippoRoot, flags }) => { await (await import('./cli/sleep.js')).cmdSleep(hippoRoot, flags); },
8047
+ usage: [`
7356
8048
  sleep Run consolidation pass (auto-learns + dedup + auto-shares)
7357
8049
  Runs at Claude Code and OpenCode session end and in the daily job.
7358
8050
  With ANTHROPIC_API_KEY set it sends memory text to Anthropic for
7359
8051
  fact extraction; {"extraction":{"enabled":false}} turns that off
7360
8052
  --dry-run Preview without writing
7361
8053
  --no-learn Skip auto git-learn and the agent memory import before consolidation
7362
- --no-share Skip auto-sharing to global store
7363
- daily-runner Sweep registered workspaces and run daily learn+sleep
8054
+ --no-share Skip auto-sharing to global store`],
8055
+ },
8056
+ 'last-sleep': {
8057
+ run: ({ flags }) => { cmdLastSleep(flags); },
8058
+ usage: [`
8059
+ last-sleep Print the last 'hippo sleep --log-file' output to stderr and clear it
8060
+ --path <p> Log path (default: ~/.hippo/logs/last-sleep.log)
8061
+ --keep Print without clearing`],
8062
+ },
8063
+ 'session-end': {
8064
+ run: async ({ hippoRoot, flags }) => { await cmdSessionEnd(hippoRoot, flags); },
8065
+ usage: [`
8066
+ session-end SessionEnd hook: count this session's re-read tokens, run sleep, then
8067
+ capture from the session's last 20 user and 10 assistant messages,
8068
+ in a detached worker
8069
+ --log-file <path> Tee the worker's output to a log file (paired with 'hippo last-sleep')`],
8070
+ },
8071
+ '__session-end-worker': {
8072
+ run: async ({ hippoRoot, flags }) => { await cmdSessionEndWorker(hippoRoot, flags); },
8073
+ usage: [],
8074
+ },
8075
+ 'pre-compact': {
8076
+ run: handlePreCompact,
8077
+ usage: [`
8078
+ pre-compact PreCompact hook: record the compaction, save a working-state snapshot, and
8079
+ ask the summariser to end with a "Memories for hippo" list
8080
+ --log-file <p> Diagnostic log path (default: ~/.hippo/logs/pre-compact.log)`],
8081
+ },
8082
+ 'post-compact': {
8083
+ run: handlePostCompact,
8084
+ usage: [`
8085
+ post-compact PostCompact hook: keep that list as memories (a busy store leaves the save to
8086
+ the next hippo sleep) and print one line saying how many
8087
+ --log-file <p> Same log path as pre-compact (default: ~/.hippo/logs/pre-compact.log)`],
8088
+ },
8089
+ 'capture-error': {
8090
+ run: handleCaptureError,
8091
+ usage: [`
8092
+ capture-error Store a failed tool call as an error memory (reads the Claude Code
8093
+ PostToolUseFailure hook payload on stdin; skips routine failures)`],
8094
+ },
8095
+ 'compact-resume': {
8096
+ run: handleCompactResume,
8097
+ usage: [`
8098
+ compact-resume SessionStart(compact) hook: re-print the snapshot, if under 15 minutes old`],
8099
+ },
8100
+ 'codex-run': {
8101
+ run: ({ hippoRoot, args }) => { cmdCodexRun(hippoRoot, args); },
8102
+ usage: [`
8103
+ codex-run [-- ...args] Launch real Codex behind Hippo's session-end wrapper`],
8104
+ },
8105
+ '__codex-session-end-worker': {
8106
+ run: async ({ hippoRoot, flags }) => { await cmdCodexSessionEndWorker(hippoRoot, flags); },
8107
+ usage: [],
8108
+ },
8109
+ dedup: {
8110
+ run: ({ hippoRoot, flags }) => { cmdDedup(hippoRoot, flags); },
8111
+ usage: [`
7364
8112
  dedup Remove duplicate memories (keeps stronger copy)
7365
8113
  --dry-run Preview without removing
7366
- --threshold <n> Ignored, kept for old scripts: a duplicate is the same text apart from spacing
7367
- status Show memory health stats
7368
- audit [--fix] Check memory quality (--fix removes junk)
7369
- github GitHub connector subcommands (backfill, dlq)
7370
- backfill --repo <owner/name> [--since ISO] [--max <N>]
7371
- Paginated backfill of issues + comments
7372
- dlq list List DLQ entries for the active tenant
7373
- dlq replay <id> [--force]
7374
- Re-ingest a DLQ entry (--force skips sig check)
8114
+ --threshold <n> Ignored, kept for old scripts: a duplicate is the same text apart from spacing`],
8115
+ },
8116
+ dag: {
8117
+ run: ({ hippoRoot, flags }) => { cmdDag(hippoRoot, flags); },
8118
+ usage: [`
8119
+ dag Show the summary tree: entity profiles, topic summaries, facts
8120
+ --stats Count memories per DAG level instead`],
8121
+ },
8122
+ auth: {
8123
+ run: ({ hippoRoot, args, flags }) => { cmdAuth(hippoRoot, args, flags); },
8124
+ usage: [`
8125
+ auth <sub> Manage API keys (A5 stub auth)
8126
+ auth create Mint a new API key (plaintext shown ONCE)
8127
+ --label <s> Optional human label
8128
+ --role <r> admin | member (default: admin; member blocked from /v1/sleep)
8129
+ --tenant <id> Override tenant (defaults to HIPPO_TENANT)
8130
+ --json Output as JSON
8131
+ --global Operate on the global store
8132
+ auth list List API keys (active by default)
8133
+ --all Include revoked keys
8134
+ --json Output as JSON
8135
+ --global Operate on the global store
8136
+ auth revoke <key_id> Revoke an API key (subsequent validate fails)
8137
+ --json Output as JSON
8138
+ --global Operate on the global store
8139
+ auth grant <key_id> <scope> Let a member key read one restricted scope
8140
+ --json Output as JSON
8141
+ --global Operate on the global store
8142
+ auth ungrant <key_id> <scope> Remove a scope grant
8143
+ --json Output as JSON
8144
+ --global Operate on the global store`],
8145
+ },
8146
+ goal: {
8147
+ run: ({ hippoRoot, args, flags }) => { cmdGoal(hippoRoot, args, flags); },
8148
+ usage: [`
8149
+ goal <sub> dlPFC goal stack (B3) — scoped per session
8150
+ goal push <name> Push a new active goal; prints the new goal id
8151
+ --policy <type> schema-fit-biased | error-prioritized |
8152
+ recency-first | hybrid
8153
+ --success "<cond>" Optional success condition text
8154
+ --level <n> Goal level (default: 0)
8155
+ --parent <goalId> Parent goal id (for sub-goals)
8156
+ --session-id <s> Override session (defaults to HIPPO_SESSION_ID)
8157
+ --tenant-id <t> Override tenant (defaults to HIPPO_TENANT)
8158
+ goal list Show active goals as a table
8159
+ --all Include suspended/completed goals
8160
+ goal complete <id> Mark a goal completed
8161
+ --outcome <0..1> Outcome score; >=0.7 boosts, <0.3 decays recalled mems
8162
+ --no-propagate Close the goal without applying strength side-effects
8163
+ goal suspend <id> Move an active goal to suspended
8164
+ goal resume <id> Move a suspended goal back to active (depth-capped)`],
8165
+ },
8166
+ slack: {
8167
+ run: ({ hippoRoot, args, flags }) => { cmdSlack(hippoRoot, args, flags); },
8168
+ usage: [`
7375
8169
  slack Slack connector subcommands (backfill, dlq, workspaces)
7376
8170
  backfill --channel <id> [--since ISO]
7377
8171
  Backfill a channel's history (needs SLACK_BOT_TOKEN)
@@ -7379,53 +8173,109 @@ Commands:
7379
8173
  dlq replay <id> [--force]
7380
8174
  Re-ingest a DLQ entry (--force skips sig check)
7381
8175
  workspaces <add|list|remove>
7382
- Map Slack workspaces (team ids) to tenants
7383
- provenance Provenance coverage gate for kind='raw' rows
7384
- --json Output as JSON
7385
- --strict Exit non-zero when coverage < 100%
7386
- dag Show the summary tree: entity profiles, topic summaries, facts
7387
- --stats Count memories per DAG level instead
7388
- drill <summary-id> Walk down a DAG level-2 summary to its children
7389
- --limit N Cap children list (default 50)
7390
- --budget N Token budget for the printed children (≈ chars/4)
7391
- --json Output as JSON
7392
- assemble --session <id> Build a session's chronological context window
7393
- --budget N Token budget for the printed window (default 4000)
7394
- --fresh-tail N Recent rows always kept verbatim (default 10)
7395
- --no-summarize-older Disable older-row summary substitution
7396
- --scope <s> Restrict to exact scope (default: deny *:private:*)
7397
- --json Output as JSON
8176
+ Map Slack workspaces (team ids) to tenants`],
8177
+ },
8178
+ github: {
8179
+ run: async ({ hippoRoot, args, flags }) => { await cmdGithub(hippoRoot, args, flags); },
8180
+ usage: [`
8181
+ github GitHub connector subcommands (backfill, dlq)
8182
+ backfill --repo <owner/name> [--since ISO] [--max <N>]
8183
+ Paginated backfill of issues + comments
8184
+ dlq list List DLQ entries for the active tenant
8185
+ dlq replay <id> [--force]
8186
+ Re-ingest a DLQ entry (--force skips sig check)`],
8187
+ },
8188
+ audit: {
8189
+ run: handleAudit,
8190
+ usage: [`
8191
+ audit [--fix] Check memory quality (--fix removes junk)`, `
8192
+ audit <sub> Query the append-only audit log (A5 stub auth)
8193
+ audit list List audit events for the active tenant
8194
+ --op <op> Filter by op (remember | recall | promote |
8195
+ supersede | forget | archive_raw | auth_revoke)
8196
+ --since <iso> Lower bound on ts (ISO timestamp)
8197
+ --limit <n> Max events (default: 100, max: 10000)
8198
+ --json Output as JSON
8199
+ --global Operate on the global store`],
8200
+ },
8201
+ 'correction-latency': {
8202
+ run: handleCorrectionLatency,
8203
+ usage: [`
7398
8204
  correction-latency Wall-clock lag from receipt to supersession (p50/p95/max)
8205
+ --json Output as JSON`],
8206
+ },
8207
+ provenance: {
8208
+ run: handleProvenance,
8209
+ usage: [`
8210
+ provenance Provenance coverage gate for kind='raw' rows
7399
8211
  --json Output as JSON
8212
+ --strict Exit non-zero when coverage < 100%`],
8213
+ },
8214
+ status: {
8215
+ run: ({ hippoRoot }) => { cmdStatus(hippoRoot); },
8216
+ usage: [`
8217
+ status Show memory health stats`],
8218
+ },
8219
+ outcome: {
8220
+ run: ({ hippoRoot, flags }) => { cmdOutcome(hippoRoot, flags); },
8221
+ usage: [`
7400
8222
  outcome Apply feedback to last recall
7401
8223
  --good Memories were helpful
7402
8224
  --bad Memories were irrelevant
7403
- --id <id> Target a specific memory
8225
+ --id <id> Target a specific memory`],
8226
+ },
8227
+ conflicts: {
8228
+ run: ({ hippoRoot, flags }) => { cmdConflicts(hippoRoot, flags); },
8229
+ usage: [`
7404
8230
  conflicts List detected open memory conflicts
7405
8231
  --status <status> Filter by status (default: open)
7406
- --json Output as JSON
8232
+ --json Output as JSON`],
8233
+ },
8234
+ resolve: {
8235
+ run: ({ hippoRoot, args, flags }) => { cmdResolve(hippoRoot, args, flags); },
8236
+ usage: [`
7407
8237
  resolve <conflict_id> Resolve a memory conflict
7408
8238
  --keep <memory_id> Memory to keep (required)
7409
8239
  --forget Delete the losing memory (default: halve half-life)
7410
8240
  --reject-loser Tombstone the loser's value too (implies removal)
7411
- --reason "<why>" Reason for --reject-loser (default: conflict context)
8241
+ --reason "<why>" Reason for --reject-loser (default: conflict context)`],
8242
+ },
8243
+ reject: {
8244
+ run: ({ hippoRoot, args, flags }) => { cmdReject(hippoRoot, args, flags); },
8245
+ usage: [`
7412
8246
  reject <memory-id> Tombstone a value so it refuses re-ingestion
7413
8247
  reject --value "<t>" Pre-emptive form: tombstone a value not (currently) stored
7414
8248
  --reason "<why>" Required. The tombstone stores no content — this
7415
8249
  is its only human-readable identity.
7416
- --global Reject in the global store
8250
+ --global Reject in the global store`],
8251
+ },
8252
+ rejections: {
8253
+ run: ({ hippoRoot, flags }) => { cmdRejections(hippoRoot, flags); },
8254
+ usage: [`
7417
8255
  rejections List rejected-value tombstones for the active tenant
7418
8256
  --json Output as JSON
7419
- --global Operate on the global store
8257
+ --global Operate on the global store`],
8258
+ },
8259
+ unreject: {
8260
+ run: ({ hippoRoot, args, flags }) => { cmdUnreject(hippoRoot, args, flags); },
8261
+ usage: [`
7420
8262
  unreject <digest-prefix> Delete a tombstone (the only escape hatch)
7421
- --global Operate on the global store
8263
+ --global Operate on the global store`],
8264
+ },
8265
+ dormant: {
8266
+ run: ({ hippoRoot, args, flags }) => { cmdDormant(hippoRoot, args, flags); },
8267
+ usage: [`
7422
8268
  dormant [<query>] List faded memories sleep kept instead of deleting
7423
8269
  (on by default; "dormant": {"enabled": false} deletes instead)
7424
8270
  --limit <n> Max rows, newest first (default: 20)
7425
8271
  --json Output as JSON
7426
8272
  --global Operate on the global store
7427
8273
  dormant restore <id> Bring a dormant memory back to active memory
7428
- dormant forget <id> Delete a dormant memory permanently
8274
+ dormant forget <id> Delete a dormant memory permanently`],
8275
+ },
8276
+ projects: {
8277
+ run: async ({ hippoRoot, args, flags }) => { (await import('./cli/projects.js')).cmdProjects(hippoRoot, args, flags); },
8278
+ usage: [`
7429
8279
  projects [list] List the project names in a store, with a hint for old worktree names
7430
8280
  --json Output as JSON
7431
8281
  --global Operate on the global store
@@ -7434,21 +8284,21 @@ Commands:
7434
8284
  writes a backup and one audit event first)
7435
8285
  projects repair [--apply]
7436
8286
  Re-tag merged rows older versions of sleep saved as user-global,
7437
- by their parents' project (dry run unless --apply)
8287
+ by their parents' project (dry run unless --apply)`],
8288
+ },
8289
+ quarantine: {
8290
+ run: ({ hippoRoot, args, flags }) => { cmdQuarantine(hippoRoot, args, flags); },
8291
+ usage: [`
7438
8292
  quarantine [list] List memories a connector flagged as an instruction attempt, pending review
7439
8293
  --all Include approved and rejected rows too (default: pending only)
7440
8294
  --json Output as JSON
7441
8295
  --global Operate on the global store
7442
8296
  quarantine approve <id> Restore a quarantined memory to its original scope
7443
- quarantine reject <id> Keep a quarantined memory hidden for good
7444
- capture-error Store a failed tool call as an error memory (reads the Claude Code
7445
- PostToolUseFailure hook payload on stdin; skips routine failures)
7446
- doctor Check the install: Node, store, schema, sleep, agent hooks
7447
- --json Machine-readable report (exit code 1 on any failure)
7448
- support-bundle Write a redacted JSON file for a support ticket: versions, doctor,
7449
- config without secrets, store counts, log names; never memory text
7450
- --out <file> Where to write it (default: hippo-support-<time>.json here)
7451
- --include-logs Add the last ${TAIL_MAX_LINES} lines of each hippo log, known secret shapes removed
8297
+ quarantine reject <id> Keep a quarantined memory hidden for good`],
8298
+ },
8299
+ tokens: {
8300
+ run: ({ hippoRoot, flags }) => { cmdTokens(hippoRoot, flags); },
8301
+ usage: [`
7452
8302
  tokens Tokens of memory text hippo handed agents, per surface
7453
8303
  (hook, compact-resume, context, recall, MCP, HTTP), what
7454
8304
  skipping unchanged hook blocks saved, and how much of the
@@ -7456,12 +8306,34 @@ Commands:
7456
8306
  counted when a session ends. Estimates (characters / 4)
7457
8307
  --days <n> Window in days (default: 30)
7458
8308
  --json Output as JSON
7459
- --global Operate on the global store
8309
+ --global Operate on the global store`],
8310
+ },
8311
+ failures: {
8312
+ run: ({ hippoRoot, flags }) => { cmdFailures(hippoRoot, flags); },
8313
+ usage: [`
7460
8314
  failures Failed tool calls capture-error saw, by outcome, and how
7461
8315
  many errors first happened in another session
7462
8316
  --days <n> Window in days (default: 30)
7463
8317
  --json Output as JSON
7464
- --global Operate on the global store
8318
+ --global Operate on the global store`],
8319
+ },
8320
+ doctor: {
8321
+ run: handleDoctor,
8322
+ usage: [`
8323
+ doctor Check the install: Node, store, schema, sleep, agent hooks
8324
+ --json Machine-readable report (exit code 1 on any failure)`],
8325
+ },
8326
+ 'support-bundle': {
8327
+ run: handleSupportBundle,
8328
+ usage: [`
8329
+ support-bundle Write a redacted JSON file for a support ticket: versions, doctor,
8330
+ config without secrets, store counts, log names; never memory text
8331
+ --out <file> Where to write it (default: hippo-support-<time>.json here)
8332
+ --include-logs Add the last ${TAIL_MAX_LINES} lines of each hippo log, known secret shapes removed`],
8333
+ },
8334
+ snapshot: {
8335
+ run: ({ hippoRoot, args, flags }) => { cmdSnapshot(hippoRoot, args, flags); },
8336
+ usage: [`
7465
8337
  snapshot <sub> Persist or inspect the current active task
7466
8338
  snapshot save Save active task state
7467
8339
  --task <task>
@@ -7472,7 +8344,11 @@ Commands:
7472
8344
  snapshot show Show the active task snapshot
7473
8345
  --json Output as JSON
7474
8346
  snapshot clear Clear the active task snapshot
7475
- --status <status> Mark final status (default: cleared)
8347
+ --status <status> Mark final status (default: cleared)`],
8348
+ },
8349
+ session: {
8350
+ run: ({ hippoRoot, args, flags }) => { cmdSession(hippoRoot, args, flags); },
8351
+ usage: [`
7476
8352
  session <sub> Append or inspect short-term session history
7477
8353
  session log Append a structured session event
7478
8354
  --id <session-id>
@@ -7489,7 +8365,11 @@ Commands:
7489
8365
  --id <session-id> Filter by session
7490
8366
  --json Output as JSON
7491
8367
  session resume Re-inject latest handoff as context output
7492
- --id <session-id> Filter by session
8368
+ --id <session-id> Filter by session`],
8369
+ },
8370
+ handoff: {
8371
+ run: ({ hippoRoot, args, flags }) => { cmdHandoff(hippoRoot, args, flags); },
8372
+ usage: [`
7493
8373
  handoff <sub> Manage session handoffs for continuity
7494
8374
  handoff create Create a new session handoff
7495
8375
  --summary <text> Handoff summary (required)
@@ -7505,7 +8385,11 @@ Commands:
7505
8385
  handoff latest Show the most recent handoff
7506
8386
  --session <id> Filter by session
7507
8387
  --json Output as JSON
7508
- handoff show <id> Show a specific handoff by ID
8388
+ handoff show <id> Show a specific handoff by ID`],
8389
+ },
8390
+ card: {
8391
+ run: ({ hippoRoot, args, flags }) => { cmdCard(hippoRoot, args, flags); },
8392
+ usage: [`
7509
8393
  card <sub> Manage claimable work-queue cards
7510
8394
  card create Create a new card
7511
8395
  --title <text> Card title (required)
@@ -7533,29 +8417,134 @@ Commands:
7533
8417
  card reclaim Return every running card whose lease has expired to ready
7534
8418
  card comment <id> Add a comment to a card
7535
8419
  --body <text> Comment body (required)
7536
- --author <name> Comment author (default: cli)
8420
+ --author <name> Comment author (default: cli)`],
8421
+ },
8422
+ predict: {
8423
+ run: ({ hippoRoot, args, flags }) => { cmdPredict(hippoRoot, args, flags); },
8424
+ usage: [`
8425
+ predict "<claim>" Record a prediction to score against the actual outcome later
8426
+ --class <c> Reference class (required)
8427
+ --estimate <v> Numeric estimate
8428
+ --unit <u> Unit of the estimate
8429
+ --target <YYYY-MM-DD> When the outcome is due
8430
+ predict close <id> Close a prediction
8431
+ --state <s> closed | closed-unknown (required)
8432
+ --actual <v> The actual value
8433
+ --note "<text>" Closure note
8434
+ predict list [--class <c>] [--status open|closed|closed-unknown|all] [--limit N]
8435
+ List predictions (closed and closed-unknown need --class)
8436
+ predict show <id> Show one prediction
8437
+ predict baserate --class <c>
8438
+ How past estimates in a class compared with the actuals`],
8439
+ },
8440
+ current: {
8441
+ run: ({ hippoRoot, args, flags }) => { cmdCurrent(hippoRoot, args, flags); },
8442
+ usage: [`
7537
8443
  current <sub> Show compact current state for agent injection
7538
8444
  current show Active task + recent session events (default)
7539
- --json Output as JSON
8445
+ --json Output as JSON`],
8446
+ },
8447
+ forget: {
8448
+ run: handleForget,
8449
+ usage: [`
7540
8450
  forget <id> Force remove a memory
7541
8451
  --archive Archive a raw (append-only) memory instead of deleting
7542
- --reason "<why>" Reason recorded on the archive (required with --archive)
7543
- inspect <id> Show full memory detail
8452
+ --reason "<why>" Reason recorded on the archive (required with --archive)`],
8453
+ },
8454
+ inspect: {
8455
+ run: handleInspect,
8456
+ usage: [`
8457
+ inspect <id> Show full memory detail`],
8458
+ },
8459
+ context: {
8460
+ run: handleContext,
8461
+ usage: [`
8462
+ context Smart context injection for AI agents
8463
+ --auto Auto-detect task from git state
8464
+ --budget <n> Token budget for the whole printed block (default: 1500)
8465
+ --pinned-only Only inject pinned memories (used by UserPromptSubmit hook)
8466
+ --include-recent <n> With --pinned-only, also inject the last N writes regardless of pinning
8467
+ (the hook payload's "prompt" drives prompt recall instead of --include-recent when pinnedInject.promptRecall is on, the default)
8468
+ --format <fmt> Output format: markdown (default), json, or additional-context (Claude Code hook JSON)
8469
+ --framing <mode> Framing: observe (default), suggest, assert`],
8470
+ },
8471
+ hook: {
8472
+ run: ({ args, flags }) => { cmdHook(args, flags); },
8473
+ usage: [`
8474
+ hook <sub> [target] Manage framework integrations
8475
+ hook list Show available hooks
8476
+ hook install <target> Install hook (claude-code|codex|cursor|openclaw|opencode|pi)
8477
+ claude-code adds 7 hooks to ~/.claude/settings.json;
8478
+ opencode installs a plugin; codex adds 2 hooks to
8479
+ $CODEX_HOME/hooks.json (trust them once in /hooks) and
8480
+ wraps the detected launcher in place; all but claude-code
8481
+ also patch an existing AGENTS.md
8482
+ hook uninstall <target> Remove hook`],
8483
+ },
8484
+ setup: {
8485
+ run: ({ flags }) => { cmdSetup(flags); },
8486
+ usage: [`
8487
+ setup One-shot: detect installed AI tools and install their hooks:
8488
+ claude-code gets 7 hooks in ~/.claude/settings.json, opencode
8489
+ a plugin, codex 2 hooks in its hooks.json plus a launcher
8490
+ wrapper; other tools get a hint. Then imports each agent's
8491
+ user-level memories into the global store
8492
+ --all Install for every JSON-hook tool, even if not detected
8493
+ --dry-run Show what would be installed without writing
8494
+ --no-schedule Skip installing or repairing the daily runner
8495
+ --no-learn Skip the agent memory import`],
8496
+ },
8497
+ 'daily-runner': {
8498
+ run: () => { cmdDailyRunner(); },
8499
+ usage: [`
8500
+ daily-runner Sweep registered workspaces and run daily learn+sleep`],
8501
+ },
8502
+ embed: {
8503
+ run: async ({ hippoRoot, flags }) => { await cmdEmbed(hippoRoot, flags); },
8504
+ usage: [`
7544
8505
  embed Embed all memories for semantic search
7545
- --status Show embedding coverage
7546
- watch "<command>" Run command, auto-learn from failures
8506
+ --status Show embedding coverage`],
8507
+ },
8508
+ watch: {
8509
+ run: handleWatch,
8510
+ usage: [`
8511
+ watch "<command>" Run command, auto-learn from failures`],
8512
+ },
8513
+ learn: {
8514
+ run: ({ hippoRoot, flags }) => { cmdLearn(hippoRoot, flags); },
8515
+ usage: [`
7547
8516
  learn Learn lessons from repository history
7548
8517
  --git Scan recent git commits for lessons
7549
8518
  --days <n> Scan this many days back (default: 7)
7550
- --repos <paths> Comma-separated repo paths to scan
7551
- promote <id> Copy a local memory to the global store
8519
+ --repos <paths> Comma-separated repo paths to scan`],
8520
+ },
8521
+ promote: {
8522
+ run: handlePromote,
8523
+ usage: [`
8524
+ promote <id> Copy a local memory to the global store`],
8525
+ },
8526
+ sync: {
8527
+ run: ({ hippoRoot, flags }) => { cmdSync(hippoRoot, flags); },
8528
+ usage: [`
8529
+ sync Pull global memories into local project`],
8530
+ },
8531
+ share: {
8532
+ run: handleShare,
8533
+ usage: [`
7552
8534
  share <id> Share a memory with attribution + transfer scoring
7553
8535
  --force Share even if transfer score is low
7554
8536
  --auto Auto-share all high-transfer-score memories
7555
8537
  --dry-run Preview what would be shared
7556
- --min-score <n> Minimum transfer score (default: 0.6)
7557
- peers List projects contributing to global store
7558
- sync Pull global memories into local project
8538
+ --min-score <n> Minimum transfer score (default: 0.6)`],
8539
+ },
8540
+ peers: {
8541
+ run: handlePeers,
8542
+ usage: [`
8543
+ peers List projects contributing to global store`],
8544
+ },
8545
+ import: {
8546
+ run: ({ hippoRoot, args, flags }) => { cmdImport(hippoRoot, args, flags); },
8547
+ usage: [`
7559
8548
  import Import memories from other AI tools
7560
8549
  --chatgpt <path> Import from ChatGPT memory export (JSON or txt)
7561
8550
  --claude <path> Import from CLAUDE.md or Claude memory.json
@@ -7572,9 +8561,17 @@ Commands:
7572
8561
  picks the tools; "none" or [] turns the import off
7573
8562
  --dry-run Preview without writing
7574
8563
  --global Write to global store ($HIPPO_HOME or ~/.hippo/)
7575
- --tag <tag> Add extra tag (repeatable)
8564
+ --tag <tag> Add extra tag (repeatable)`],
8565
+ },
8566
+ export: {
8567
+ run: handleExport,
8568
+ usage: [`
7576
8569
  export [file] Export all memories (default: stdout)
7577
- --format <fmt> Output format: json (default) or markdown
8570
+ --format <fmt> Output format: json (default) or markdown`],
8571
+ },
8572
+ capture: {
8573
+ run: handleCapture,
8574
+ usage: [`
7578
8575
  capture Extract memories from conversation text
7579
8576
  --stdin Read from piped input
7580
8577
  --file <path> Read from a file
@@ -7583,61 +8580,80 @@ Commands:
7583
8580
  --transcript <path> Explicit transcript path (implies --last-session)
7584
8581
  --log-file <path> Tee output to a log file (paired with 'hippo last-sleep')
7585
8582
  --dry-run Preview without writing
7586
- --global Write to global store ($HIPPO_HOME or ~/.hippo/)
7587
- setup One-shot: detect installed AI tools and install their hooks:
7588
- claude-code gets 7 hooks in ~/.claude/settings.json, opencode
7589
- a plugin, codex 2 hooks in its hooks.json plus a launcher
7590
- wrapper; other tools get a hint. Then imports each agent's
7591
- user-level memories into the global store
7592
- --all Install for every JSON-hook tool, even if not detected
7593
- --dry-run Show what would be installed without writing
7594
- --no-schedule Skip installing or repairing the daily runner
7595
- --no-learn Skip the agent memory import
7596
- last-sleep Print the last 'hippo sleep --log-file' output to stderr and clear it
7597
- --path <p> Log path (default: ~/.hippo/logs/last-sleep.log)
7598
- --keep Print without clearing
7599
- session-end SessionEnd hook: count this session's re-read tokens, run sleep, then
7600
- capture from the session's last 20 user and 10 assistant messages,
7601
- in a detached worker
7602
- --log-file <path> Tee the worker's output to a log file (paired with 'hippo last-sleep')
7603
- pre-compact PreCompact hook: record the compaction, save a working-state snapshot, and
7604
- ask the summariser to end with a "Memories for hippo" list
7605
- --log-file <p> Diagnostic log path (default: ~/.hippo/logs/pre-compact.log)
7606
- compact-resume SessionStart(compact) hook: re-print the snapshot, if under 15 minutes old
7607
- post-compact PostCompact hook: keep that list as memories (a busy store leaves the save to
7608
- the next hippo sleep) and print one line saying how many
7609
- --log-file <p> Same log path as pre-compact (default: ~/.hippo/logs/pre-compact.log)
7610
- codex-run [-- ...args] Launch real Codex behind Hippo's session-end wrapper
7611
- hook <sub> [target] Manage framework integrations
7612
- hook list Show available hooks
7613
- hook install <target> Install hook (claude-code|codex|cursor|openclaw|opencode|pi)
7614
- claude-code adds 7 hooks to ~/.claude/settings.json;
7615
- opencode installs a plugin; codex adds 2 hooks to
7616
- $CODEX_HOME/hooks.json (trust them once in /hooks) and
7617
- wraps the detected launcher in place; all but claude-code
7618
- also patch an existing AGENTS.md
7619
- hook uninstall <target> Remove hook
7620
- predict "<claim>" Record a prediction to score against the actual outcome later
7621
- --class <c> Reference class (required)
7622
- --estimate <v> Numeric estimate
7623
- --unit <u> Unit of the estimate
7624
- --target <YYYY-MM-DD> When the outcome is due
7625
- predict close <id> Close a prediction
7626
- --state <s> closed | closed-unknown (required)
7627
- --actual <v> The actual value
7628
- --note "<text>" Closure note
7629
- predict list [--class <c>] [--status open|closed|closed-unknown|all] [--limit N]
7630
- List predictions (closed and closed-unknown need --class)
7631
- predict show <id> Show one prediction
7632
- predict baserate --class <c>
7633
- How past estimates in a class compared with the actuals
8583
+ --global Write to global store ($HIPPO_HOME or ~/.hippo/)`],
8584
+ },
8585
+ dashboard: {
8586
+ run: handleDashboard,
8587
+ usage: [`
8588
+ dashboard Open web dashboard for memory health
8589
+ --port <n> Port to serve on (default: 3333)`],
8590
+ },
8591
+ wm: {
8592
+ run: ({ hippoRoot, args, flags }) => { cmdWm(hippoRoot, args, flags); },
8593
+ usage: [`
8594
+ wm <sub> Working memory — bounded buffer for current state
8595
+ wm push Push a working memory entry
8596
+ --scope <scope> Scope name (default: default)
8597
+ --content <text> Content to store (required)
8598
+ --importance <n> Priority 0-1 (default: 0.5)
8599
+ --session <id> Session ID
8600
+ --task <id> Task ID
8601
+ wm read Read working memory entries
8602
+ --scope <scope> Filter by scope
8603
+ --session <id> Filter by session
8604
+ --limit <n> Max entries (default: 20)
8605
+ --json Output as JSON
8606
+ wm clear Clear working memory entries
8607
+ --scope <scope> Filter by scope
8608
+ --session <id> Filter by session
8609
+ wm flush Same as clear; nothing runs it at session end
8610
+ --scope <scope> Filter by scope
8611
+ --session <id> Filter by session`],
8612
+ },
8613
+ mcp: {
8614
+ run: handleMcp,
8615
+ usage: [`
8616
+ mcp Start MCP server (stdio transport)`],
8617
+ },
8618
+ serve: {
8619
+ run: handleServe,
8620
+ usage: [`
8621
+ serve Start the HTTP API server for this store (Ctrl+C stops it)
8622
+ --port <n> Port to serve on (default: $HIPPO_PORT or 6789)
8623
+ --host <host> Address to bind (default: 127.0.0.1)`],
8624
+ },
8625
+ invalidate: {
8626
+ run: handleInvalidate,
8627
+ usage: [`
8628
+ invalidate "<pattern>" Actively weaken memories matching an old pattern
8629
+ (content overlap, or a tag EXACTLY equal to the
8630
+ full pattern - never token-level tag matching)
8631
+ --id <memory-id> Invalidate exactly one memory (instead of a pattern)
8632
+ --dry-run Preview what would be hit; writes nothing
8633
+ Note: a pattern equal to the system tag
8634
+ 'invalidated' re-weakens previously invalidated
8635
+ memories - preview with --dry-run first
8636
+ --reason "<why>" Optional: what replaced it
8637
+ invalidate --churn FE2: tag memories 'churn-stale' whose named file
8638
+ changed or was deleted, or whose named symbol or
8639
+ npm script was removed, in this repo's git history
8640
+ since the memory was stored or confirmed
8641
+ --dry-run Preview what would be tagged; writes nothing`],
8642
+ },
8643
+ decide: {
8644
+ run: ({ hippoRoot, args, flags }) => { cmdDecide(hippoRoot, args, flags); },
8645
+ usage: [`
7634
8646
  decide "<decision>" Record a decision (first-class object + memory mirror)
7635
8647
  --context "<why>" Why this decision was made
7636
8648
  --supersedes <mem-id> Supersede the decision backed by this memory id
7637
8649
  decide list [--status active|superseded|closed|all] [--limit N]
7638
8650
  List decisions (table is authoritative, survives decay)
7639
8651
  decide get <id> Show a decision by its table id
7640
- decide close <id> Retire (close) an active decision by its table id
8652
+ decide close <id> Retire (close) an active decision by its table id`],
8653
+ },
8654
+ incident: {
8655
+ run: ({ hippoRoot, args, flags }) => { cmdIncident(hippoRoot, args, flags); },
8656
+ usage: [`
7641
8657
  incident "<incident>" Record an incident (first-class object + memory mirror)
7642
8658
  --context "<details>" What happened / surrounding detail
7643
8659
  --link <mem-id> Link a memory as evidence (repeatable)
@@ -7646,7 +8662,11 @@ Commands:
7646
8662
  incident get <id> Show an incident by its table id
7647
8663
  incident resolve <id> Resolve an open incident (open -> resolved)
7648
8664
  --resolution "<text>" How it was resolved (required)
7649
- incident close <id> Retire (close) an open or resolved incident by its table id
8665
+ incident close <id> Retire (close) an open or resolved incident by its table id`],
8666
+ },
8667
+ process: {
8668
+ run: ({ hippoRoot, args, flags }) => { cmdProcess(hippoRoot, args, flags); },
8669
+ usage: [`
7650
8670
  process new "<name>" Record a process map (first-class object + memory mirror)
7651
8671
  --step "<text>" An ordered step (repeatable)
7652
8672
  --description "<text>" Optional summary of the process
@@ -7657,7 +8677,11 @@ Commands:
7657
8677
  --step "<text>" A step of the new version (repeatable, required)
7658
8678
  --change "<summary>" What changed in this version (the delta note)
7659
8679
  --description "<text>" Optional summary of the new version
7660
- process close <id> Retire (close) an active process by its table id
8680
+ process close <id> Retire (close) an active process by its table id`],
8681
+ },
8682
+ policy: {
8683
+ run: ({ hippoRoot, args, flags }) => { cmdPolicy(hippoRoot, args, flags); },
8684
+ usage: [`
7661
8685
  policy new "<name>" Record a policy (bi-temporal first-class object + mirror)
7662
8686
  --text "<rule>" The policy rule/statement (required)
7663
8687
  --from "<iso>" Effective-from date (default: now)
@@ -7672,7 +8696,11 @@ Commands:
7672
8696
  --from "<iso>" New effective-from (default: now)
7673
8697
  --to "<iso>" New effective-to (optional)
7674
8698
  --change "<summary>" What changed in this version (the delta note)
7675
- policy close <id> Retire (close) an active policy by its table id
8699
+ policy close <id> Retire (close) an active policy by its table id`],
8700
+ },
8701
+ skill: {
8702
+ run: ({ hippoRoot, args, flags }) => { cmdSkill(hippoRoot, args, flags); },
8703
+ usage: [`
7676
8704
  skill new "<name>" Record a skill (reusable agent-followable capability)
7677
8705
  --instructions "<txt>" The skill body (required)
7678
8706
  --trigger "<when>" Optional: when to apply this skill
@@ -7684,7 +8712,12 @@ Commands:
7684
8712
  --instructions "<txt>" The new skill body (required)
7685
8713
  --trigger "<when>" Optional new trigger
7686
8714
  --change "<summary>" What changed in this version (the delta note)
7687
- skill close <id> Retire (close) an active skill by its table id
8715
+ skill close <id> Retire (close) an active skill by its table id`],
8716
+ },
8717
+ brief: {
8718
+ run: ({ hippoRoot, args, flags }) => { cmdProjectBrief(hippoRoot, args, flags); },
8719
+ aliases: ['project-brief'],
8720
+ usage: [`
7688
8721
  brief new "<repo>" Record a repo-scoped project brief
7689
8722
  --summary "<text>" The brief body (required)
7690
8723
  brief list [--status active|superseded|closed|all] [--repo "<repo>"] [--limit N]
@@ -7695,7 +8728,12 @@ Commands:
7695
8728
  --change "<summary>" What changed in this version (the delta note)
7696
8729
  brief close <id> Retire (close) an active project brief by its table id
7697
8730
  brief refresh "<repo>" Auto-assemble the brief from the repo's receipts (path:<repo>)
7698
- --dry-run Print the assembled brief without writing it
8731
+ --dry-run Print the assembled brief without writing it`],
8732
+ },
8733
+ note: {
8734
+ run: ({ hippoRoot, args, flags }) => { cmdCustomerNote(hippoRoot, args, flags); },
8735
+ aliases: ['customer-note'],
8736
+ usage: [`
7699
8737
  note new "<customer>" Record a customer/account-scoped note
7700
8738
  --text "<note>" The note body (required)
7701
8739
  note list [--status active|superseded|closed|all] [--customer "<id>"] [--limit N]
@@ -7704,91 +8742,35 @@ Commands:
7704
8742
  note supersede <id> Record a new version that supersedes an active note
7705
8743
  --text "<note>" The new note body (required)
7706
8744
  --change "<summary>" What changed in this version (the delta note)
7707
- note close <id> Retire (close) an active customer note by its table id
8745
+ note close <id> Retire (close) an active customer note by its table id`],
8746
+ },
8747
+ graph: {
8748
+ run: ({ hippoRoot, args, flags }) => { cmdGraph(hippoRoot, args, flags); },
8749
+ usage: [`
7708
8750
  graph extract Rebuild the entity/relation graph from consolidated objects
7709
- (decisions/policies/customer-notes/project-briefs); idempotent
7710
- invalidate "<pattern>" Actively weaken memories matching an old pattern
7711
- (content overlap, or a tag EXACTLY equal to the
7712
- full pattern - never token-level tag matching)
7713
- --id <memory-id> Invalidate exactly one memory (instead of a pattern)
7714
- --dry-run Preview what would be hit; writes nothing
7715
- Note: a pattern equal to the system tag
7716
- 'invalidated' re-weakens previously invalidated
7717
- memories - preview with --dry-run first
7718
- --reason "<why>" Optional: what replaced it
7719
- invalidate --churn FE2: tag memories 'churn-stale' whose named file
7720
- changed or was deleted, or whose named symbol or
7721
- npm script was removed, in this repo's git history
7722
- since the memory was stored or confirmed
7723
- --dry-run Preview what would be tagged; writes nothing
7724
- wm <sub> Working memory — bounded buffer for current state
7725
- wm push Push a working memory entry
7726
- --scope <scope> Scope name (default: default)
7727
- --content <text> Content to store (required)
7728
- --importance <n> Priority 0-1 (default: 0.5)
7729
- --session <id> Session ID
7730
- --task <id> Task ID
7731
- wm read Read working memory entries
7732
- --scope <scope> Filter by scope
7733
- --session <id> Filter by session
7734
- --limit <n> Max entries (default: 20)
7735
- --json Output as JSON
7736
- wm clear Clear working memory entries
7737
- --scope <scope> Filter by scope
7738
- --session <id> Filter by session
7739
- wm flush Same as clear; nothing runs it at session end
7740
- --scope <scope> Filter by scope
7741
- --session <id> Filter by session
7742
- dashboard Open web dashboard for memory health
7743
- --port <n> Port to serve on (default: 3333)
7744
- mcp Start MCP server (stdio transport)
7745
- serve Start the HTTP API server for this store (Ctrl+C stops it)
7746
- --port <n> Port to serve on (default: $HIPPO_PORT or 6789)
7747
- --host <host> Address to bind (default: 127.0.0.1)
7748
- goal <sub> dlPFC goal stack (B3) — scoped per session
7749
- goal push <name> Push a new active goal; prints the new goal id
7750
- --policy <type> schema-fit-biased | error-prioritized |
7751
- recency-first | hybrid
7752
- --success "<cond>" Optional success condition text
7753
- --level <n> Goal level (default: 0)
7754
- --parent <goalId> Parent goal id (for sub-goals)
7755
- --session-id <s> Override session (defaults to HIPPO_SESSION_ID)
7756
- --tenant-id <t> Override tenant (defaults to HIPPO_TENANT)
7757
- goal list Show active goals as a table
7758
- --all Include suspended/completed goals
7759
- goal complete <id> Mark a goal completed
7760
- --outcome <0..1> Outcome score; >=0.7 boosts, <0.3 decays recalled mems
7761
- --no-propagate Close the goal without applying strength side-effects
7762
- goal suspend <id> Move an active goal to suspended
7763
- goal resume <id> Move a suspended goal back to active (depth-capped)
7764
- auth <sub> Manage API keys (A5 stub auth)
7765
- auth create Mint a new API key (plaintext shown ONCE)
7766
- --label <s> Optional human label
7767
- --role <r> admin | member (default: admin; member blocked from /v1/sleep)
7768
- --tenant <id> Override tenant (defaults to HIPPO_TENANT)
7769
- --json Output as JSON
7770
- --global Operate on the global store
7771
- auth list List API keys (active by default)
7772
- --all Include revoked keys
7773
- --json Output as JSON
7774
- --global Operate on the global store
7775
- auth revoke <key_id> Revoke an API key (subsequent validate fails)
7776
- --json Output as JSON
7777
- --global Operate on the global store
7778
- auth grant <key_id> <scope> Let a member key read one restricted scope
7779
- --json Output as JSON
7780
- --global Operate on the global store
7781
- auth ungrant <key_id> <scope> Remove a scope grant
7782
- --json Output as JSON
7783
- --global Operate on the global store
7784
- audit <sub> Query the append-only audit log (A5 stub auth)
7785
- audit list List audit events for the active tenant
7786
- --op <op> Filter by op (remember | recall | promote |
7787
- supersede | forget | archive_raw | auth_revoke)
7788
- --since <iso> Lower bound on ts (ISO timestamp)
7789
- --limit <n> Max events (default: 100, max: 10000)
7790
- --json Output as JSON
7791
- --global Operate on the global store
8751
+ (decisions/policies/customer-notes/project-briefs); idempotent`],
8752
+ },
8753
+ };
8754
+ const COMMAND_INDEX = new Map(Object.entries(COMMANDS).flatMap(([verb, spec]) => [verb, ...(spec.aliases ?? [])].map((name) => [name, spec])));
8755
+ // The full listing order; a verb listed twice prints its next help block.
8756
+ const USAGE_ORDER = [
8757
+ 'init', 'remember', 'supersede', 'recall', 'explain', 'trace', 'refine', 'eval', 'context', 'sleep',
8758
+ 'daily-runner', 'dedup', 'status', 'audit', 'github', 'slack', 'provenance', 'dag', 'drill', 'assemble',
8759
+ 'correction-latency', 'outcome', 'conflicts', 'resolve', 'reject', 'rejections', 'unreject', 'dormant',
8760
+ 'projects', 'quarantine', 'capture-error', 'doctor', 'support-bundle', 'tokens', 'failures', 'snapshot',
8761
+ 'session', 'handoff', 'card', 'current', 'forget', 'inspect', 'embed', 'watch', 'learn', 'promote',
8762
+ 'share', 'peers', 'sync', 'import', 'export', 'capture', 'setup', 'last-sleep', 'session-end',
8763
+ 'pre-compact', 'compact-resume', 'post-compact', 'codex-run', 'hook', 'predict', 'decide', 'incident',
8764
+ 'process', 'policy', 'skill', 'brief', 'note', 'graph', 'invalidate', 'wm', 'dashboard', 'mcp', 'serve',
8765
+ 'goal', 'auth', 'audit',
8766
+ ];
8767
+ const USAGE_HEADER = `
8768
+ Hippo - memory for AI agents that learns what is wrong and ranks it down
8769
+
8770
+ Usage: hippo <command> [options]
8771
+
8772
+ Commands:`;
8773
+ const USAGE_EXAMPLES = `
7792
8774
 
7793
8775
  Examples:
7794
8776
  hippo init
@@ -7843,25 +8825,22 @@ Examples:
7843
8825
  hippo outcome --good
7844
8826
  hippo status
7845
8827
  `;
8828
+ export function usageText() {
8829
+ const printed = new Map();
8830
+ const blocks = USAGE_ORDER.map((verb) => {
8831
+ const index = printed.get(verb) ?? 0;
8832
+ printed.set(verb, index + 1);
8833
+ return COMMANDS[verb].usage[index];
8834
+ });
8835
+ return USAGE_HEADER + blocks.join('') + USAGE_EXAMPLES;
7846
8836
  }
7847
8837
  function printUsage() {
7848
8838
  console.log(usageText());
7849
8839
  }
7850
- const USAGE_ALIASES = new Map([['project-brief', 'brief'], ['customer-note', 'note']]);
7851
- // Cut from usageText() so a verb's help can never drift from the full listing.
8840
+ /** One verb's help blocks as `hippo <verb> --help` prints them, or null for a verb with none. */
7852
8841
  export function verbUsage(verb) {
7853
- const name = USAGE_ALIASES.get(verb) ?? verb;
7854
- const block = [];
7855
- let inBlock = false;
7856
- for (const line of usageText().split('\n')) {
7857
- if (/^ {2}\S/.test(line))
7858
- inBlock = line.trimStart().split(' ', 1)[0] === name;
7859
- else if (!line.startsWith(' '))
7860
- inBlock = false;
7861
- if (inBlock)
7862
- block.push(line);
7863
- }
7864
- return block.length > 0 ? block.join('\n') : null;
8842
+ const usage = COMMAND_INDEX.get(verb)?.usage ?? [];
8843
+ return usage.length > 0 ? usage.join('').slice(1) : null;
7865
8844
  }
7866
8845
  // These sub-commands have fuller usage text than their lines in usageText().
7867
8846
  const SUBCOMMAND_USAGE = new Map([
@@ -7948,837 +8927,13 @@ async function main(command, args, flags, hippoRoot) {
7948
8927
  printError(refusal);
7949
8928
  process.exit(2);
7950
8929
  }
7951
- switch (command) {
7952
- case 'init':
7953
- cmdInit(hippoRoot, flags);
7954
- break;
7955
- case 'remember': {
7956
- let text;
7957
- if (args.length === 1 && args[0] === '-') {
7958
- text = fs.readFileSync(0, 'utf-8').trim();
7959
- }
7960
- else {
7961
- text = args.join(' ').trim();
7962
- }
7963
- if (!text || text.length < 3) {
7964
- printError('Memory content too short (minimum 3 characters).');
7965
- process.exit(1);
7966
- }
7967
- // Thin-client routing. When a server is up, simple `remember` calls go
7968
- // over HTTP so the daemon stays single-writer (footgun #2). Rich CLI
7969
- // flags (--pin, --layer, --extract, --global) still need the direct
7970
- // path; we only intercept the minimal envelope. The salience gate is
7971
- // NOT in richFlag and the route does not apply it, so a routed remember
7972
- // stores what a direct one would skip. Measured 2026-09-07, tracked in
7973
- // TODOS.md; do not read this list as covering salience.
7974
- const richFlag = flags['pin'] || flags['global'] || flags['extract'] || flags['force'] ||
7975
- flags['observed'] || flags['inferred'] || flags['verified'] ||
7976
- flags['layer'] !== undefined;
7977
- if (!richFlag) {
7978
- const rememberKindRaw = typeof flags['kind'] === 'string' ? flags['kind'].toLowerCase() : undefined;
7979
- const rememberKindAllowed = ['distilled', 'superseded'];
7980
- if (rememberKindRaw === undefined || rememberKindAllowed.includes(rememberKindRaw)) {
7981
- const tags = rememberTags(flags, process.cwd()).all;
7982
- // B2 v1.12.6 — validate --owner on the thin-client path too.
7983
- // Failure on this path exits early so the user gets the same
7984
- // validation experience whether or not a server is up.
7985
- const thinOwnerRaw = typeof flags['owner'] === 'string' ? flags['owner'] : undefined;
7986
- const thinOwnerCheck = validateOwner(thinOwnerRaw, { strict: isStrictOwnerEnv() });
7987
- if (!thinOwnerCheck.ok) {
7988
- printError(thinOwnerCheck.message);
7989
- process.exit(1);
7990
- }
7991
- if (thinOwnerCheck.message)
7992
- printError(thinOwnerCheck.message);
7993
- const remembered = await runViaServerIfAvailable(hippoRoot, async (info, apiKey) => {
7994
- const result = await client.remember(info.url, apiKey, {
7995
- content: text,
7996
- kind: rememberKindRaw,
7997
- scope: typeof flags['scope'] === 'string' ? flags['scope'] : undefined,
7998
- owner: thinOwnerCheck.value,
7999
- artifactRef: typeof flags['artifact-ref'] === 'string' ? flags['artifact-ref'] : undefined,
8000
- tags,
8001
- });
8002
- console.log(`Remembered [${result.id}] (via ${info.url})`);
8003
- console.log(` Kind: ${result.kind} | Tenant: ${result.tenantId}`);
8004
- for (const w of result.warnings ?? [])
8005
- printError(`Warning: ${w}`);
8006
- });
8007
- if (remembered)
8008
- break;
8009
- }
8010
- }
8011
- await cmdRemember(hippoRoot, text, flags);
8012
- break;
8013
- }
8014
- case 'recall': {
8015
- const query = args.join(' ').trim();
8016
- if (!query) {
8017
- printError('Please provide a search query.');
8018
- process.exit(1);
8019
- }
8020
- await cmdRecall(hookStoreRoot(hippoRoot), query, flags);
8021
- break;
8022
- }
8023
- case 'drill': {
8024
- const summaryId = args[0];
8025
- if (!summaryId) {
8026
- printError('Usage: hippo drill <summary-id> [--limit N] [--budget N]');
8027
- process.exit(1);
8028
- }
8029
- cmdDrillDown(hippoRoot, summaryId, flags);
8030
- break;
8031
- }
8032
- case 'assemble': {
8033
- const sessionId = typeof flags['session'] === 'string' ? flags['session'] : args[0];
8034
- if (!sessionId) {
8035
- printError('Usage: hippo assemble --session <id> [--budget N] [--fresh-tail N] [--no-summarize-older] [--json]');
8036
- process.exit(1);
8037
- }
8038
- cmdAssemble(hippoRoot, sessionId, flags);
8039
- break;
8040
- }
8041
- case 'supersede': {
8042
- const oldId = args[0];
8043
- const newContent = args.slice(1).join(' ').trim();
8044
- if (!oldId || !newContent) {
8045
- printError('Usage: hippo supersede <old-id> "<new content>" [--layer L] [--tag T] [--pin]');
8046
- process.exit(1);
8047
- }
8048
- cmdSupersede(hippoRoot, oldId, newContent, flags);
8049
- break;
8050
- }
8051
- case 'explain': {
8052
- const query = args.join(' ').trim();
8053
- if (!query) {
8054
- printError('Please provide a search query.');
8055
- process.exit(1);
8056
- }
8057
- await cmdExplain(hippoRoot, query, flags);
8058
- break;
8059
- }
8060
- case 'eval': {
8061
- const corpusPath = args[0] ? String(args[0]) : null;
8062
- await cmdEval(hippoRoot, corpusPath, flags);
8063
- break;
8064
- }
8065
- case 'trace': {
8066
- const sub = args[0] ? String(args[0]) : '';
8067
- if (sub === 'record') {
8068
- cmdTraceRecord(hippoRoot, flags);
8069
- break;
8070
- }
8071
- if (!sub) {
8072
- printError('Usage: hippo trace <memory-id> | hippo trace record --task <t> --steps <json> --outcome <o>');
8073
- process.exit(1);
8074
- }
8075
- cmdTrace(hippoRoot, sub, flags);
8076
- break;
8077
- }
8078
- case 'refine':
8079
- await cmdRefine(hippoRoot, flags);
8080
- break;
8081
- case 'sleep':
8082
- await (await import('./cli/sleep.js')).cmdSleep(hippoRoot, flags);
8083
- break;
8084
- case 'last-sleep':
8085
- cmdLastSleep(flags);
8086
- break;
8087
- case 'session-end':
8088
- await cmdSessionEnd(hippoRoot, flags);
8089
- break;
8090
- case '__session-end-worker':
8091
- await cmdSessionEndWorker(hippoRoot, flags);
8092
- break;
8093
- case 'pre-compact': {
8094
- // Bounded wait, not a TTY guard: an idle non-TTY pipe must not hang.
8095
- const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
8096
- await runHookWithStores(async () => {
8097
- resetHookInjection(hippoRoot, stdinText, null);
8098
- await cmdPreCompact(hookStoreRoot(hippoRoot), {
8099
- stdinText,
8100
- stdinTimedOut,
8101
- logFile: typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined,
8102
- });
8103
- });
8104
- break;
8105
- }
8106
- case 'post-compact': {
8107
- // PostCompact hook: saves the compaction summary and its memories, then prints one plain line, because Claude Code shows this hook's stdout as-is. Always exits 0.
8108
- const { text } = await readStdinBounded();
8109
- const logFlag = flags['log-file'];
8110
- const store = hookStoreRoot(hippoRoot);
8111
- const line = await runHookWithStores(() => cmdPostCompact(store, {
8112
- stdinText: text,
8113
- logFile: logFlag === true || logFlag === false || Array.isArray(logFlag) ? undefined : logFlag,
8114
- // Passed in, since capture.ts importing the sync would close an import cycle.
8115
- afterSave: (transcriptPath, originProject, log) => {
8116
- const report = importAtCompaction(store, transcriptPath, originProject, { machine: currentMachine(), busyWaitMs: COMPACTION_DB_WAIT_MS });
8117
- const summary = summaryLine(report);
8118
- if (summary !== null)
8119
- log(summary);
8120
- for (const warning of report.warnings)
8121
- log(`agent memories: ${warning}`);
8122
- },
8123
- }));
8124
- if (line !== null && line !== undefined)
8125
- console.log(line);
8126
- break;
8127
- }
8128
- case 'capture-error': {
8129
- // PostToolUseFailure hook: every path exits 0, and nothing is created
8130
- // when no store exists (the hook fires in every directory).
8131
- const { text } = await readStdinBounded();
8132
- try {
8133
- const root = hookStoreRoot(hippoRoot);
8134
- const payload = (text ?? '').trim();
8135
- if (isInitialized(root) && payload) {
8136
- // SAFETY: JSON.parse returns a JSON value by definition.
8137
- const failure = JSON.parse(payload);
8138
- await runHookWithStores(() => captureToolFailure(root, resolveTenantId({}), failure));
8139
- }
8140
- }
8141
- catch {
8142
- // A malformed payload or store error must never fail the agent's tool call.
8143
- }
8144
- break;
8145
- }
8146
- case 'compact-resume': {
8147
- const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
8148
- await runHookWithStores(() => {
8149
- resetHookInjection(hippoRoot, stdinText, 'compact');
8150
- cmdCompactResume(hookStoreRoot(hippoRoot), stdinText, stdinTimedOut);
8151
- });
8152
- break;
8153
- }
8154
- case 'codex-run':
8155
- cmdCodexRun(hippoRoot, args);
8156
- break;
8157
- case '__codex-session-end-worker':
8158
- await cmdCodexSessionEndWorker(hippoRoot, flags);
8159
- break;
8160
- case 'dedup':
8161
- cmdDedup(hippoRoot, flags);
8162
- break;
8163
- case 'dag':
8164
- cmdDag(hippoRoot, flags);
8165
- break;
8166
- case 'auth':
8167
- cmdAuth(hippoRoot, args, flags);
8168
- break;
8169
- case 'goal':
8170
- cmdGoal(hippoRoot, args, flags);
8171
- break;
8172
- case 'slack':
8173
- cmdSlack(hippoRoot, args, flags);
8174
- break;
8175
- case 'github':
8176
- await cmdGithub(hippoRoot, args, flags);
8177
- break;
8178
- case 'audit': {
8179
- // `audit list` and `audit prune` -> A5 audit-log subcommands.
8180
- // Other forms (no sub, --fix) keep the existing memory-quality auditor
8181
- // for backwards compatibility.
8182
- if (args[0] === 'list' || args[0] === 'prune') {
8183
- cmdAuditLog(hippoRoot, args, flags);
8184
- break;
8185
- }
8186
- requireInit(hippoRoot);
8187
- const entries = loadAllEntries(hippoRoot, resolveTenantId({}));
8188
- const result = auditMemories(entries, memoriesBackingObjects(hippoRoot));
8189
- const shouldFix = Boolean(flags['fix']);
8190
- if (result.issues.length === 0) {
8191
- console.log(`All ${result.total} memories passed quality checks.`);
8192
- }
8193
- else {
8194
- console.log(`Audited ${result.total} memories: ${result.clean} clean, ${result.issues.length} issues\n`);
8195
- for (const issue of result.issues) {
8196
- const icon = issue.severity === 'error' ? 'ERR' : 'WARN';
8197
- console.log(` [${icon}] ${issue.memoryId}: ${issue.reason}`);
8198
- console.log(` "${issue.content.slice(0, 80)}${issue.content.length > 80 ? '...' : ''}"`);
8199
- }
8200
- if (shouldFix) {
8201
- const errors = result.issues.filter(i => i.severity === 'error');
8202
- if (errors.length > 0 && flags['dry-run'] === true) {
8203
- console.log(`\nWould remove ${errors.length} error-severity memories (dry run, nothing deleted).`);
8204
- console.log(`${result.issues.length - errors.length} warnings would remain (review manually).`);
8205
- }
8206
- else if (errors.length > 0) {
8207
- const removedCount = errors.filter((issue) => deleteEntry(hippoRoot, issue.memoryId, { reason: `audit --fix: ${issue.reason}`, automatic: true })).length;
8208
- console.log(`\nRemoved ${removedCount} error-severity memories.`);
8209
- console.log(`${result.issues.length - errors.length} warnings remain (review manually).`);
8210
- }
8211
- else {
8212
- console.log(`\nNo error-severity issues. Warnings require manual review.`);
8213
- }
8214
- }
8215
- else {
8216
- console.log(`\nRun with --fix to auto-remove error-severity issues.`);
8217
- }
8218
- }
8219
- break;
8220
- }
8221
- case 'correction-latency': {
8222
- requireInit(hippoRoot);
8223
- const entries = loadAllEntries(hippoRoot);
8224
- const report = buildCorrectionLatency(entries);
8225
- if (flags['json']) {
8226
- console.log(JSON.stringify(report, null, 2));
8227
- }
8228
- else if (report.count === 0) {
8229
- console.log('No supersessions found. Correction latency is undefined.');
8230
- }
8231
- else {
8232
- const fmt = (ms) => {
8233
- if (ms === null)
8234
- return 'n/a';
8235
- if (ms < 1000)
8236
- return `${ms}ms`;
8237
- if (ms < 60_000)
8238
- return `${(ms / 1000).toFixed(1)}s`;
8239
- if (ms < 3_600_000)
8240
- return `${(ms / 60_000).toFixed(1)}m`;
8241
- return `${(ms / 3_600_000).toFixed(1)}h`;
8242
- };
8243
- console.log(`Corrections: ${report.count} total (${report.extractionCount} extraction-driven, ${report.manualCount} manual)`);
8244
- console.log(`Latency p50: ${fmt(report.p50Ms)}, p95: ${fmt(report.p95Ms)}, max: ${fmt(report.maxMs)}`);
8245
- if (report.extractionCount === 0 && report.manualCount > 0) {
8246
- console.log(`\nAll ${report.manualCount} corrections were manual supersedes: no measurable observation lag.`);
8247
- console.log(`To measure latency, route corrections through extraction (set new.extracted_from to the raw receipt).`);
8248
- }
8249
- }
8250
- break;
8251
- }
8252
- case 'provenance': {
8253
- requireInit(hippoRoot);
8254
- const entries = loadAllEntries(hippoRoot);
8255
- const coverage = buildProvenanceCoverage(entries);
8256
- if (flags['json']) {
8257
- console.log(JSON.stringify(coverage, null, 2));
8258
- }
8259
- else if (coverage.rawTotal === 0) {
8260
- console.log('No kind=raw memories present. Coverage gate trivially satisfied.');
8261
- }
8262
- else {
8263
- const pct = (coverage.coverage * 100).toFixed(1);
8264
- console.log(`Provenance coverage: ${coverage.rawWithEnvelope}/${coverage.rawTotal} raw rows envelope-complete (${pct}%)`);
8265
- if (coverage.gaps.length > 0) {
8266
- console.log(`\nGaps:`);
8267
- for (const g of coverage.gaps) {
8268
- console.log(` ${g.id}: missing ${g.missing.join(', ')}`);
8269
- }
8270
- }
8271
- }
8272
- if (flags['strict'] && coverage.coverage < 1) {
8273
- process.exit(1);
8274
- }
8275
- break;
8276
- }
8277
- case 'status':
8278
- cmdStatus(hippoRoot);
8279
- break;
8280
- case 'outcome':
8281
- cmdOutcome(hippoRoot, flags);
8282
- break;
8283
- case 'conflicts':
8284
- cmdConflicts(hippoRoot, flags);
8285
- break;
8286
- case 'resolve':
8287
- cmdResolve(hippoRoot, args, flags);
8288
- break;
8289
- case 'reject':
8290
- cmdReject(hippoRoot, args, flags);
8291
- break;
8292
- case 'rejections':
8293
- cmdRejections(hippoRoot, flags);
8294
- break;
8295
- case 'unreject':
8296
- cmdUnreject(hippoRoot, args, flags);
8297
- break;
8298
- case 'dormant':
8299
- cmdDormant(hippoRoot, args, flags);
8300
- break;
8301
- case 'projects':
8302
- (await import('./cli/projects.js')).cmdProjects(hippoRoot, args, flags);
8303
- break;
8304
- case 'quarantine':
8305
- cmdQuarantine(hippoRoot, args, flags);
8306
- break;
8307
- case 'tokens':
8308
- cmdTokens(hippoRoot, flags);
8309
- break;
8310
- case 'failures':
8311
- cmdFailures(hippoRoot, flags);
8312
- break;
8313
- case 'doctor': {
8314
- // SAFETY: package.json always carries a string "version" (checked at release by check-manifest-versions).
8315
- const pkg = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'));
8316
- const report = runDoctor({ version: pkg.version });
8317
- console.log(flags['json'] ? JSON.stringify(report, null, 2) : formatDoctor(report));
8318
- if (!report.ok)
8319
- process.exit(1);
8320
- break;
8321
- }
8322
- case 'support-bundle': {
8323
- const outFlag = cardStringFlag(flags, 'out');
8324
- if (outFlag === '') {
8325
- printError('--out requires a file path.');
8326
- process.exit(1);
8327
- }
8328
- const includeLogs = flags['include-logs'] === true;
8329
- const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
8330
- const now = new Date();
8331
- const bundle = buildSupportBundle({ cwd: process.cwd(), home, version: PACKAGE_VERSION, includeLogs, now });
8332
- const stamp = now.toISOString().replace(/[:.]/g, '-');
8333
- const file = outFlag ?? path.join(process.cwd(), `hippo-support-${stamp}.json`);
8334
- const json = JSON.stringify(bundle, null, 2);
8335
- try {
8336
- fs.writeFileSync(file, `${json}\n`, { flag: 'wx', mode: 0o600 });
8337
- }
8338
- catch (err) {
8339
- if (err instanceof Error && 'code' in err && err.code === 'EEXIST') {
8340
- printError(`${file} already exists; pass --out to choose another file. Nothing was written.`);
8341
- }
8342
- else {
8343
- printError(err instanceof Error ? err.message : String(err));
8344
- }
8345
- process.exit(1);
8346
- }
8347
- const kb = Math.round(Buffer.byteLength(json) / 1024);
8348
- console.log(`Wrote ${file} (${kb} KB).`);
8349
- console.log(includeLogs
8350
- ? `It holds versions, doctor checks, config with secrets removed, store counts, and the last ${TAIL_MAX_LINES} lines of each hippo log with known secret shapes removed. Those log lines can quote memory text. Read it before you attach it to a ticket.`
8351
- : 'It holds versions, doctor checks, config with secrets removed, store counts and log file names. It never holds memory text. Read it before you attach it to a ticket.');
8352
- break;
8353
- }
8354
- case 'snapshot':
8355
- cmdSnapshot(hippoRoot, args, flags);
8356
- break;
8357
- case 'session':
8358
- cmdSession(hippoRoot, args, flags);
8359
- break;
8360
- case 'handoff':
8361
- cmdHandoff(hippoRoot, args, flags);
8362
- break;
8363
- case 'card':
8364
- cmdCard(hippoRoot, args, flags);
8365
- break;
8366
- case 'predict':
8367
- cmdPredict(hippoRoot, args, flags);
8368
- break;
8369
- case 'current':
8370
- cmdCurrent(hippoRoot, args, flags);
8371
- break;
8372
- case 'forget': {
8373
- const id = args[0];
8374
- if (!id) {
8375
- printError('Please provide a memory ID.');
8376
- process.exit(1);
8377
- }
8378
- // Archive has its own HTTP route (POST /v1/memories/:id/archive); route
8379
- // both branches the same way the direct path does.
8380
- const archive = flags['archive'] === true;
8381
- const reason = typeof flags['reason'] === 'string' ? flags['reason'] : null;
8382
- if (archive && !reason) {
8383
- printError(ARCHIVE_REASON_REQUIRED);
8384
- process.exit(1);
8385
- }
8386
- if (flags['dry-run'] === true) {
8387
- previewForget(hippoRoot, id, archive);
8388
- break;
8389
- }
8390
- const routed = await runViaServerIfAvailable(hippoRoot, async (info, apiKey) => {
8391
- try {
8392
- if (archive) {
8393
- await client.archiveRaw(info.url, apiKey, id, reason);
8394
- console.log(`Archived ${id}`);
8395
- }
8396
- else {
8397
- await client.forget(info.url, apiKey, id);
8398
- console.log(`Forgot ${id}`);
8399
- }
8400
- }
8401
- catch (err) {
8402
- // A server that died after the health probe is the caller's transport
8403
- // fallback to handle, not an error to report to the user.
8404
- if (client.classifyTransportFailure(err) !== 'none')
8405
- throw err;
8406
- const msg = err instanceof Error ? err.message : String(err);
8407
- printError(archive ? `Could not archive ${id}: ${msg}` : msg);
8408
- process.exit(1);
8409
- }
8410
- });
8411
- if (routed)
8412
- break;
8413
- cmdForget(hippoRoot, id, flags);
8414
- break;
8415
- }
8416
- case 'inspect': {
8417
- const id = args[0];
8418
- if (!id) {
8419
- printError('Please provide a memory ID.');
8420
- process.exit(1);
8421
- }
8422
- cmdInspect(hippoRoot, id);
8423
- break;
8424
- }
8425
- case 'context': {
8426
- // Bounded, not a TTY guard (DF1 T2, docs/plans/2026-08-23-df1-snapshot-lifecycle.md):
8427
- // the hot stdin path and a manual run share this one command.
8428
- const { text: stdinText } = await readStdinBounded();
8429
- await runHookWithStores(() => cmdContext(hookStoreRoot(hippoRoot), args, flags, stdinText));
8430
- break;
8431
- }
8432
- case 'hook':
8433
- cmdHook(args, flags);
8434
- break;
8435
- case 'setup':
8436
- cmdSetup(flags);
8437
- break;
8438
- case 'daily-runner':
8439
- cmdDailyRunner();
8440
- break;
8441
- case 'embed':
8442
- await cmdEmbed(hippoRoot, flags);
8443
- break;
8444
- case 'watch': {
8445
- const watchCmd = args.join(' ').trim();
8446
- await cmdWatch(watchCmd, hippoRoot);
8447
- break;
8448
- }
8449
- case 'learn':
8450
- cmdLearn(hippoRoot, flags);
8451
- break;
8452
- case 'promote': {
8453
- const id = args[0];
8454
- if (!id) {
8455
- printError('Please provide a memory ID.');
8456
- process.exit(1);
8457
- }
8458
- const promoted = await runViaServerIfAvailable(hippoRoot, async (info, apiKey) => {
8459
- try {
8460
- const result = await client.promote(info.url, apiKey, id);
8461
- console.log(`Promoted ${id} to global store as ${result.globalId}`);
8462
- }
8463
- catch (err) {
8464
- printError(`Failed to promote: ${err.message}`);
8465
- process.exit(1);
8466
- }
8467
- });
8468
- if (promoted)
8469
- break;
8470
- cmdPromote(hippoRoot, id);
8471
- break;
8472
- }
8473
- case 'sync':
8474
- cmdSync(hippoRoot, flags);
8475
- break;
8476
- case 'share': {
8477
- const shareId = args[0];
8478
- if (shareId === '--auto' || flags['auto']) {
8479
- // Auto-share mode
8480
- requireInit(hippoRoot);
8481
- const minScore = parseFloat(String(flags['min-score'] ?? '0.6'));
8482
- const dryRun = Boolean(flags['dry-run']);
8483
- const results = autoShare(hippoRoot, { minScore, dryRun, tenantId: resolveTenantId({}) });
8484
- if (results.length === 0) {
8485
- console.log('No memories meet the sharing threshold.');
8486
- }
8487
- else if (dryRun) {
8488
- console.log(`Would share ${results.length} memories:\n`);
8489
- for (const e of results) {
8490
- const score = transferScore(e);
8491
- console.log(` ${e.id} (transfer=${fmt(score)}) ${e.content.slice(0, 80)}...`);
8492
- }
8493
- }
8494
- else {
8495
- console.log(`Shared ${results.length} memories to global store.`);
8496
- for (const e of results) {
8497
- console.log(` ${e.id} <- ${e.source}`);
8498
- }
8499
- }
8500
- }
8501
- else if (shareId) {
8502
- requireInit(hippoRoot);
8503
- const force = Boolean(flags['force']);
8504
- const tenantId = resolveTenantId({});
8505
- const result = shareMemory(hippoRoot, shareId, { force, tenantId });
8506
- if (result) {
8507
- console.log(`Shared [${result.id}] to global store.`);
8508
- console.log(` Source: ${result.source}`);
8509
- }
8510
- else {
8511
- const entry = readEntry(hippoRoot, shareId, tenantId);
8512
- if (entry) {
8513
- const score = transferScore(entry);
8514
- console.log(`Transfer score too low (${fmt(score)}). This memory looks project-specific.`);
8515
- console.log('Use --force to share anyway.');
8516
- }
8517
- else {
8518
- printError(`Memory not found: ${shareId}`);
8519
- process.exit(1);
8520
- }
8521
- }
8522
- }
8523
- else {
8524
- printError('Usage: hippo share <memory_id> [--force] or hippo share --auto [--dry-run]');
8525
- process.exit(1);
8526
- }
8527
- break;
8528
- }
8529
- case 'peers': {
8530
- // D4 v1.12.10: tenant-scoped by default. --all-tenants restores the
8531
- // pre-D4 host-wide view for the rare operator who genuinely wants
8532
- // cross-tenant peer discovery.
8533
- const allTenants = flags['all-tenants'] === true;
8534
- const tenantScope = allTenants ? undefined : resolveTenantId({});
8535
- const peers = listPeers(undefined, tenantScope);
8536
- if (peers.length === 0) {
8537
- console.log('No peers found. Share memories with: hippo share <id>');
8538
- }
8539
- else {
8540
- const scopeLabel = allTenants ? 'global store (all tenants)' : `global store (tenant "${tenantScope}")`;
8541
- console.log(`${peers.length} project${peers.length === 1 ? '' : 's'} contributing to ${scopeLabel}:\n`);
8542
- for (const p of peers) {
8543
- console.log(` ${p.project.padEnd(25)} ${String(p.count).padStart(4)} memories (latest: ${p.latest.slice(0, 10)})`);
8544
- }
8545
- }
8546
- break;
8547
- }
8548
- case 'import':
8549
- cmdImport(hippoRoot, args, flags);
8550
- break;
8551
- case 'export': {
8552
- requireInit(hippoRoot);
8553
- const format = flags['format'] || 'json';
8554
- const outputPath = args[0] || null;
8555
- const entries = loadAllEntries(hippoRoot, resolveTenantId({}));
8556
- let output;
8557
- if (format === 'markdown' || format === 'md') {
8558
- output = entries.map(e => {
8559
- const meta = [
8560
- `id: ${e.id}`,
8561
- `created: ${e.created}`,
8562
- `tags: ${e.tags.join(', ')}`,
8563
- `confidence: ${e.confidence}`,
8564
- `half_life: ${e.half_life_days}d`,
8565
- `strength: ${e.strength.toFixed(2)}`,
8566
- ].join(' | ');
8567
- return `### ${e.id}\n\n${e.content}\n\n_${meta}_`;
8568
- }).join('\n\n---\n\n');
8569
- }
8570
- else {
8571
- output = JSON.stringify(entries, null, 2);
8572
- }
8573
- if (outputPath) {
8574
- fs.writeFileSync(outputPath, output, 'utf8');
8575
- console.log(`Exported ${entries.length} memories to ${outputPath}`);
8576
- }
8577
- else {
8578
- console.log(output);
8579
- }
8580
- break;
8581
- }
8582
- case 'capture': {
8583
- let captureSource = null;
8584
- let captureFile;
8585
- let transcriptPath;
8586
- if (flags['stdin']) {
8587
- captureSource = 'stdin';
8588
- }
8589
- else if (flags['file']) {
8590
- captureSource = 'file';
8591
- captureFile = String(flags['file']);
8592
- }
8593
- else if (flags['last-session']) {
8594
- captureSource = 'last-session';
8595
- }
8596
- if (flags['transcript']) {
8597
- transcriptPath = String(flags['transcript']);
8598
- if (!captureSource)
8599
- captureSource = 'last-session';
8600
- }
8601
- if (!captureSource) {
8602
- printError('Usage: hippo capture --stdin|--file <path>|--last-session [--transcript <path>] [--log-file <path>] [--dry-run] [--global]');
8603
- process.exit(1);
8604
- }
8605
- // Bounded, and only when last-session has no explicit path: the
8606
- // --stdin source keeps its own blocking read in capture.ts by design.
8607
- const bounded = captureSource === 'last-session' && !transcriptPath
8608
- ? await readStdinBounded()
8609
- : { text: undefined, timedOut: false };
8610
- cmdCapture(hippoRoot, {
8611
- source: captureSource,
8612
- filePath: captureFile,
8613
- transcriptPath,
8614
- stdinText: bounded.text,
8615
- stdinTimedOut: bounded.timedOut,
8616
- logFile: typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined,
8617
- dryRun: Boolean(flags['dry-run']),
8618
- global: Boolean(flags['global']),
8619
- tenantId: resolveTenantId({}),
8620
- });
8621
- break;
8622
- }
8623
- case 'dashboard': {
8624
- requireInit(hippoRoot);
8625
- const port = parseInt(String(flags['port'] ?? '3333'), 10);
8626
- const { serveDashboard } = await import('./dashboard.js');
8627
- serveDashboard(hippoRoot, port);
8628
- await new Promise(() => { }); // run until Ctrl+C
8629
- break;
8630
- }
8631
- case 'wm':
8632
- cmdWm(hippoRoot, args, flags);
8633
- break;
8634
- case 'mcp': {
8635
- // Start MCP server over stdio. Dynamic import keeps main CLI lean; the
8636
- // dispatcher itself is transport-agnostic, so we explicitly attach the
8637
- // stdio loop here. (HTTP/SSE transport is wired in src/server.ts and
8638
- // imports the same module without triggering stdin handlers.)
8639
- const mod = await import('./mcp/server.js');
8640
- mod.startStdioLoop();
8641
- // Server runs until stdin closes, so we never reach here
8642
- await new Promise(() => { }); // hang forever
8643
- break;
8644
- }
8645
- case 'serve': {
8646
- requireInit(hippoRoot);
8647
- const portRaw = flags['port'] ?? process.env['HIPPO_PORT'] ?? '6789';
8648
- const port = Number(portRaw);
8649
- if (!Number.isFinite(port) || port < 0) {
8650
- printError(`Invalid --port: ${String(portRaw)}`);
8651
- process.exit(1);
8652
- }
8653
- const host = typeof flags['host'] === 'string' ? flags['host'] : '127.0.0.1';
8654
- const { serve } = await import('./server.js');
8655
- const handle = await serve({ hippoRoot, port, host, handleSignals: true });
8656
- console.log(`hippo serve listening on ${handle.url} (pid ${process.pid})`);
8657
- console.log(`pidfile: ${path.join(hippoRoot, 'server.pid')}`);
8658
- console.log('press Ctrl+C to stop');
8659
- // The SIGINT/SIGTERM handlers stop the server and exit. Hang until then.
8660
- await new Promise(() => { });
8661
- break;
8662
- }
8663
- case 'invalidate': {
8664
- requireInit(hippoRoot);
8665
- if (flags['churn'] === true) {
8666
- if (args[0] || flags['id'] !== undefined) {
8667
- printError('Usage: hippo invalidate --churn [--dry-run]');
8668
- printError('--churn takes no pattern or --id.');
8669
- process.exit(1);
8670
- }
8671
- if (!isGitRepo(process.cwd())) {
8672
- printError('hippo invalidate --churn must run inside a git repository.');
8673
- process.exit(1);
8674
- }
8675
- const churnDryRun = flags['dry-run'] === true;
8676
- let churnFailed = false;
8677
- for (const { root, result } of runChurnStaleForRepo(hippoRoot, churnDryRun)) {
8678
- if (result.error) {
8679
- printError(`Churn-staleness check failed for ${root}: ${result.error}`);
8680
- churnFailed = true;
8681
- continue;
8682
- }
8683
- if (result.preview.length === 0) {
8684
- console.log(`No churn-stale candidates in ${root}.`);
8685
- }
8686
- else if (churnDryRun) {
8687
- console.log(`DRY RUN - ${result.marked} memories in ${root} WOULD be tagged churn-stale (${result.alreadyMarked} already tagged):`);
8688
- }
8689
- else {
8690
- console.log(`Tagged ${result.marked} memories churn-stale in ${root} (${result.alreadyMarked} already tagged):`);
8691
- }
8692
- result.preview.forEach(p => console.log(` ${p.id} ${p.evidence} ${p.already ? '(already) ' : ''}${p.headline}`));
8693
- if (result.skippedPinned.length > 0) {
8694
- console.log(`Skipped ${result.skippedPinned.length} pinned: ${result.skippedPinned.join(', ')}`);
8695
- }
8696
- }
8697
- if (churnFailed)
8698
- process.exit(1);
8699
- break;
8700
- }
8701
- const target = args[0];
8702
- if (flags['id'] === true) {
8703
- // Value-less --id must never silently fall through to pattern mode
8704
- // (pattern mode writes broadly; an ignored --id reverses user intent).
8705
- printError('--id requires a memory id');
8706
- process.exit(1);
8707
- }
8708
- const onlyId = typeof flags['id'] === 'string' ? flags['id'] : undefined;
8709
- if (typeof flags['dry-run'] === 'string') {
8710
- // Dead: the earlier global BOOLEAN_FLAGS guard now exits first on any --dry-run=<v>.
8711
- // Kept as defence in depth on a destructive command.
8712
- printError('--dry-run takes no value');
8713
- process.exit(1);
8714
- }
8715
- const dryRun = flags['dry-run'] === true;
8716
- if ((target && onlyId) || (!target && !onlyId)) {
8717
- printError('Usage: hippo invalidate "<old pattern>" [--dry-run] [--reason "<why>"]');
8718
- printError(' hippo invalidate --id <memory-id> [--dry-run] [--reason "<why>"]');
8719
- printError('Pass a pattern OR --id, not both. Tag matching is EXACT: the full pattern must equal a tag.');
8720
- process.exit(1);
8721
- }
8722
- const reason = flags['reason'] || null;
8723
- const invTarget = {
8724
- from: target ?? `id:${onlyId}`,
8725
- to: reason,
8726
- type: 'migration',
8727
- };
8728
- const result = invalidateMatching(hippoRoot, invTarget, resolveTenantId({}), { dryRun, onlyId });
8729
- const label = target ? `"${target}"` : `--id ${onlyId}`;
8730
- if (result.dryRun) {
8731
- if (result.invalidated === 0) {
8732
- console.log(`DRY RUN - no memories would match ${label}.`);
8733
- }
8734
- else {
8735
- console.log(`DRY RUN - ${result.invalidated} memories WOULD be invalidated:`);
8736
- result.preview.forEach(p => console.log(` ${p.id} ${p.headline}`));
8737
- }
8738
- }
8739
- else if (result.invalidated === 0) {
8740
- console.log(`No memories matched ${label}.`);
8741
- }
8742
- else {
8743
- console.log(`Invalidated ${result.invalidated} memories referencing ${label}.`);
8744
- result.targets.forEach(id => console.log(` ${id}`));
8745
- }
8746
- if (result.skippedPinned.length > 0) {
8747
- console.log(`Skipped ${result.skippedPinned.length} pinned: ${result.skippedPinned.join(', ')}`);
8748
- }
8749
- break;
8750
- }
8751
- case 'decide':
8752
- cmdDecide(hippoRoot, args, flags);
8753
- break;
8754
- case 'incident':
8755
- cmdIncident(hippoRoot, args, flags);
8756
- break;
8757
- case 'process':
8758
- cmdProcess(hippoRoot, args, flags);
8759
- break;
8760
- case 'policy':
8761
- cmdPolicy(hippoRoot, args, flags);
8762
- break;
8763
- case 'skill':
8764
- cmdSkill(hippoRoot, args, flags);
8765
- break;
8766
- case 'brief':
8767
- case 'project-brief':
8768
- cmdProjectBrief(hippoRoot, args, flags);
8769
- break;
8770
- case 'note':
8771
- case 'customer-note':
8772
- cmdCustomerNote(hippoRoot, args, flags);
8773
- break;
8774
- case 'graph':
8775
- cmdGraph(hippoRoot, args, flags);
8776
- break;
8777
- default:
8778
- printError(`Unknown command: ${command}`);
8779
- printUsage();
8780
- process.exit(1);
8930
+ const spec = COMMAND_INDEX.get(command);
8931
+ if (!spec) {
8932
+ printError(`Unknown command: ${command}`);
8933
+ printUsage();
8934
+ process.exit(1);
8781
8935
  }
8936
+ await spec.run({ hippoRoot, args, flags });
8782
8937
  }
8783
8938
  export async function runCli(argv = process.argv) {
8784
8939
  const { command, args, flags } = parseArgs(argv);