hippo-memory 0.30.1 → 0.32.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.
package/README.md CHANGED
@@ -60,6 +60,20 @@ hippo recall "data pipeline issues" --budget 2000
60
60
 
61
61
  ---
62
62
 
63
+ ### What's new in v0.32.0
64
+
65
+ - **Correction without deletion.** `hippo supersede <old-id> "<new content>"` links the old memory as historical truth and creates a successor. Default recall shows only current beliefs; the old one stays in the store so you can audit what changed and when.
66
+ - **`--include-superseded`** on `recall` and `explain` surfaces historical memories with a `[superseded]` marker. Useful for "what did I used to think about X?"
67
+ - **`--as-of <ISO-date>`** returns the set of beliefs that were current at a past moment. Invalid dates exit with a clear format hint.
68
+ - **Schema v11, zero breaking changes.** Adds `valid_from` + `superseded_by` columns. Existing v10 stores upgrade on first open, no data loss, no manual migration.
69
+ - **Physics search ablation: CUT.** Benchmarked over 60 LongMemEval-oracle questions: physics-on is statistically worse than plain BM25 + embeddings on MRR, Recall@5, and NDCG@5 (paired bootstrap, 5000 iters, 95% CI excludes zero). Full results in `benchmarks/physics-ablation/`. Physics stays in the codebase this release; removal is a separate decision.
70
+
71
+ ### What's new in v0.31.0
72
+
73
+ - **Scope-aware corrections.** Tag a memory with `hippo remember --scope plan-eng-review` and it only surfaces strongly when that scope is active again. Matching scope gets 1.5x boost, mismatching scope is suppressed 0.5x, unscoped memories stay neutral. Corrections said during one skill stop polluting unrelated contexts.
74
+ - **Auto-detect from env.** `HIPPO_SCOPE`, `GSTACK_SKILL`, `OPENCLAW_SKILL` populate the scope automatically. Explicit `--scope` on any command overrides.
75
+ - **`hippo explain --why`** now shows the `scope:` multiplier when it fires, so you can see why a memory got ranked up or down.
76
+
63
77
  ### What's new in v0.30.1
64
78
 
65
79
  - **`hippo recall --layer <L>` is now a strict filter.** Previously the flag was accepted but silently dropped; other layers leaked into results. The RSI demo's `recall --layer trace` now does what it says.
package/dist/cli.js CHANGED
@@ -44,6 +44,7 @@ import { openHippoDb, closeHippoDb } from './db.js';
44
44
  import { captureError, extractLessons, deduplicateLesson, runWatched, fetchGitLog, isGitRepo, } from './autolearn.js';
45
45
  import { extractInvalidationTarget, invalidateMatching } from './invalidation.js';
46
46
  import { extractPathTags } from './path-context.js';
47
+ import { detectScope, scopeMatch } from './scope.js';
47
48
  import { getGlobalRoot, initGlobal, promoteToGlobal, shareMemory, listPeers, autoShare, transferScore, searchBothHybrid, syncGlobalToLocal, } from './shared.js';
48
49
  import { DAILY_TASK_NAME, buildDailyRunnerCommand, listRegisteredWorkspaces, registerWorkspace, runDailyMaintenance, } from './scheduler.js';
49
50
  import { importChatGPT, importClaude, importCursor, importGenericFile, importMarkdown, } from './importers.js';
@@ -413,6 +414,14 @@ function cmdRemember(hippoRoot, text, flags) {
413
414
  if (!entry.tags.includes(pt))
414
415
  entry.tags.push(pt);
415
416
  }
417
+ // Scope tagging: explicit --scope or auto-detected
418
+ const explicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
419
+ const activeScope = explicitScope || detectScope();
420
+ if (activeScope) {
421
+ const scopeTag = `scope:${activeScope}`;
422
+ if (!entry.tags.includes(scopeTag))
423
+ entry.tags.push(scopeTag);
424
+ }
416
425
  writeEntry(targetRoot, entry);
417
426
  updateStats(targetRoot, { remembered: 1 });
418
427
  const prefix = useGlobal ? '[global] ' : '';
@@ -429,6 +438,37 @@ function cmdRemember(hippoRoot, text, flags) {
429
438
  });
430
439
  }
431
440
  }
441
+ function cmdSupersede(hippoRoot, oldId, newContent, flags) {
442
+ requireInit(hippoRoot);
443
+ const old = readEntry(hippoRoot, oldId);
444
+ if (!old) {
445
+ console.error(`Error: memory ${oldId} not found.`);
446
+ process.exit(1);
447
+ }
448
+ if (old.superseded_by) {
449
+ console.error(`Error: memory ${oldId} is already superseded by ${old.superseded_by}. Supersede that one instead.`);
450
+ process.exit(1);
451
+ }
452
+ const layer = (typeof flags['layer'] === 'string' ? flags['layer'] : old.layer);
453
+ const rawTags = flags['tag'];
454
+ const tags = Array.isArray(rawTags)
455
+ ? rawTags.map((t) => String(t))
456
+ : typeof rawTags === 'string'
457
+ ? rawTags.split(',').map((t) => t.trim()).filter(Boolean)
458
+ : [...old.tags];
459
+ const pinned = flags['pin'] === true || old.pinned;
460
+ const newEntry = createMemory(newContent, {
461
+ layer,
462
+ tags,
463
+ pinned,
464
+ source: old.source,
465
+ confidence: 'verified',
466
+ });
467
+ old.superseded_by = newEntry.id;
468
+ writeEntry(hippoRoot, old);
469
+ writeEntry(hippoRoot, newEntry);
470
+ console.log(`Superseded ${oldId} → ${newEntry.id}`);
471
+ }
432
472
  async function cmdRecall(hippoRoot, query, flags) {
433
473
  requireInit(hippoRoot);
434
474
  const budget = parseInt(String(flags['budget'] ?? '4000'), 10);
@@ -437,9 +477,43 @@ async function cmdRecall(hippoRoot, query, flags) {
437
477
  const showWhy = Boolean(flags['why']);
438
478
  const forcePhysics = Boolean(flags['physics']);
439
479
  const forceClassic = Boolean(flags['classic']);
480
+ const includeSuperseded = Boolean(flags['include-superseded']);
481
+ const asOf = typeof flags['as-of'] === 'string' ? flags['as-of'] : undefined;
482
+ if (asOf !== undefined && Number.isNaN(new Date(asOf).getTime())) {
483
+ console.error(`Error: --as-of value "${asOf}" is not a valid ISO date (e.g. 2026-04-22 or 2026-04-22T12:00:00Z).`);
484
+ process.exit(1);
485
+ }
440
486
  const globalRoot = getGlobalRoot();
441
- const localEntries = loadSearchEntries(hippoRoot, query);
442
- const globalEntries = isInitialized(globalRoot) ? loadSearchEntries(globalRoot, query) : [];
487
+ let localEntries = loadSearchEntries(hippoRoot, query);
488
+ let globalEntries = isInitialized(globalRoot) ? loadSearchEntries(globalRoot, query) : [];
489
+ // Bi-temporal filtering for physics path (hybridSearch handles it internally)
490
+ if (asOf) {
491
+ const filterAsOf = (entries) => {
492
+ const asOfDate = new Date(asOf);
493
+ const successorValidFrom = new Map();
494
+ for (const e of entries) {
495
+ if (e.superseded_by) {
496
+ const successor = entries.find(s => s.id === e.superseded_by);
497
+ if (successor)
498
+ successorValidFrom.set(e.id, successor.valid_from);
499
+ }
500
+ }
501
+ return entries.filter(e => {
502
+ if (new Date(e.valid_from) > asOfDate)
503
+ return false;
504
+ if (!e.superseded_by)
505
+ return true;
506
+ const succVf = successorValidFrom.get(e.id);
507
+ return succVf ? new Date(succVf) > asOfDate : true;
508
+ });
509
+ };
510
+ localEntries = filterAsOf(localEntries);
511
+ globalEntries = filterAsOf(globalEntries);
512
+ }
513
+ else if (!includeSuperseded) {
514
+ localEntries = localEntries.filter(e => !e.superseded_by);
515
+ globalEntries = globalEntries.filter(e => !e.superseded_by);
516
+ }
443
517
  const hasGlobal = globalEntries.length > 0;
444
518
  // Determine search mode: --physics forces physics, --classic forces BM25+cosine,
445
519
  // default uses physics if config.physics.enabled is not false
@@ -459,6 +533,8 @@ async function cmdRecall(hippoRoot, query, flags) {
459
533
  const minResults = flags['min-results'] !== undefined
460
534
  ? parseInt(String(flags['min-results']), 10)
461
535
  : undefined;
536
+ const recallExplicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
537
+ const recallActiveScope = recallExplicitScope || detectScope();
462
538
  let results;
463
539
  if (usePhysics && !hasGlobal) {
464
540
  results = await physicsSearch(query, localEntries, {
@@ -466,17 +542,18 @@ async function cmdRecall(hippoRoot, query, flags) {
466
542
  hippoRoot,
467
543
  physicsConfig: config.physics,
468
544
  minResults,
545
+ scope: recallActiveScope,
469
546
  });
470
547
  }
471
548
  else if (hasGlobal) {
472
549
  // Use searchBothHybrid for merged results with embedding support
473
550
  results = await searchBothHybrid(query, hippoRoot, globalRoot, {
474
- budget, mmr: mmrEnabled, mmrLambda, localBump, minResults,
551
+ budget, mmr: mmrEnabled, mmrLambda, localBump, minResults, scope: recallActiveScope,
475
552
  });
476
553
  }
477
554
  else {
478
555
  results = await hybridSearch(query, localEntries, {
479
- budget, hippoRoot, mmr: mmrEnabled, mmrLambda, minResults,
556
+ budget, hippoRoot, mmr: mmrEnabled, mmrLambda, minResults, scope: recallActiveScope,
480
557
  });
481
558
  }
482
559
  // --outcome filter: drop trace entries whose trace_outcome !== target.
@@ -543,6 +620,10 @@ async function cmdRecall(hippoRoot, query, flags) {
543
620
  if (r.entry.layer === Layer.Trace) {
544
621
  base.trace_outcome = r.entry.trace_outcome;
545
622
  }
623
+ if (r.entry.superseded_by) {
624
+ base.superseded = true;
625
+ base.superseded_by = r.entry.superseded_by;
626
+ }
546
627
  if (showWhy) {
547
628
  const explanation = explainMatch(query, r);
548
629
  base.confidence = resolveConfidence(r.entry);
@@ -565,8 +646,9 @@ async function cmdRecall(hippoRoot, query, flags) {
565
646
  const strengthBar = '\u2588'.repeat(Math.round(e.strength * 10)) + '\u2591'.repeat(10 - Math.round(e.strength * 10));
566
647
  const isGlobal = isInitialized(globalRoot) && !localIndex.entries[e.id];
567
648
  const globalMark = isGlobal ? ' [global]' : '';
649
+ const supersededMark = e.superseded_by ? ' [superseded]' : '';
568
650
  const sourceMark = isGlobal ? ' [global]' : ' [local]';
569
- console.log(`--- ${e.id} [${e.layer}] ${confLabel}${globalMark} score=${fmt(r.score, 3)} strength=${fmt(e.strength)}`);
651
+ console.log(`--- ${e.id} [${e.layer}] ${confLabel}${globalMark}${supersededMark} score=${fmt(r.score, 3)} strength=${fmt(e.strength)}`);
570
652
  console.log(` [${strengthBar}] tags: ${e.tags.join(', ') || 'none'} | retrieved: ${e.retrieval_count}x`);
571
653
  if (showWhy) {
572
654
  const explanation = explainMatch(query, r);
@@ -585,10 +667,44 @@ async function cmdExplain(hippoRoot, query, flags) {
585
667
  const asJson = Boolean(flags['json']);
586
668
  const forcePhysics = Boolean(flags['physics']);
587
669
  const forceClassic = Boolean(flags['classic']);
670
+ const explainIncludeSuperseded = Boolean(flags['include-superseded']);
671
+ const explainAsOf = typeof flags['as-of'] === 'string' ? flags['as-of'] : undefined;
672
+ if (explainAsOf !== undefined && Number.isNaN(new Date(explainAsOf).getTime())) {
673
+ console.error(`Error: --as-of value "${explainAsOf}" is not a valid ISO date (e.g. 2026-04-22 or 2026-04-22T12:00:00Z).`);
674
+ process.exit(1);
675
+ }
588
676
  const globalRoot = getGlobalRoot();
589
- const localEntries = loadSearchEntries(hippoRoot, query);
590
- const globalEntries = isInitialized(globalRoot) ? loadSearchEntries(globalRoot, query) : [];
591
- const hasGlobal = globalEntries.length > 0;
677
+ let explainLocalEntries = loadSearchEntries(hippoRoot, query);
678
+ let explainGlobalEntries = isInitialized(globalRoot) ? loadSearchEntries(globalRoot, query) : [];
679
+ // Bi-temporal filtering
680
+ if (explainAsOf) {
681
+ const filterAsOfExplain = (entries) => {
682
+ const asOfDate = new Date(explainAsOf);
683
+ const successorValidFrom = new Map();
684
+ for (const e of entries) {
685
+ if (e.superseded_by) {
686
+ const successor = entries.find(s => s.id === e.superseded_by);
687
+ if (successor)
688
+ successorValidFrom.set(e.id, successor.valid_from);
689
+ }
690
+ }
691
+ return entries.filter(e => {
692
+ if (new Date(e.valid_from) > asOfDate)
693
+ return false;
694
+ if (!e.superseded_by)
695
+ return true;
696
+ const succVf = successorValidFrom.get(e.id);
697
+ return succVf ? new Date(succVf) > asOfDate : true;
698
+ });
699
+ };
700
+ explainLocalEntries = filterAsOfExplain(explainLocalEntries);
701
+ explainGlobalEntries = filterAsOfExplain(explainGlobalEntries);
702
+ }
703
+ else if (!explainIncludeSuperseded) {
704
+ explainLocalEntries = explainLocalEntries.filter(e => !e.superseded_by);
705
+ explainGlobalEntries = explainGlobalEntries.filter(e => !e.superseded_by);
706
+ }
707
+ const hasGlobal = explainGlobalEntries.length > 0;
592
708
  const config = loadConfig(hippoRoot);
593
709
  const usePhysics = forcePhysics
594
710
  || (!forceClassic && config.physics.enabled !== false);
@@ -602,33 +718,38 @@ async function cmdExplain(hippoRoot, query, flags) {
602
718
  : flags['local-bump'] !== undefined
603
719
  ? parseFloat(String(flags['local-bump']))
604
720
  : config.search.localBump;
721
+ const explainExplicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
722
+ const explainActiveScope = explainExplicitScope || detectScope();
605
723
  let results;
606
724
  let modeUsed;
607
725
  if (usePhysics && !hasGlobal) {
608
- results = await physicsSearch(query, localEntries, {
726
+ results = await physicsSearch(query, explainLocalEntries, {
609
727
  budget,
610
728
  hippoRoot,
611
729
  physicsConfig: config.physics,
612
730
  explain: true,
731
+ scope: explainActiveScope,
613
732
  });
614
733
  modeUsed = 'physics';
615
734
  }
616
735
  else if (hasGlobal) {
617
736
  results = await searchBothHybrid(query, hippoRoot, globalRoot, {
618
- budget, explain: true, mmr: mmrEnabled, mmrLambda, localBump,
737
+ budget, explain: true, mmr: mmrEnabled, mmrLambda, localBump, scope: explainActiveScope,
738
+ includeSuperseded: explainIncludeSuperseded, asOf: explainAsOf,
619
739
  });
620
740
  modeUsed = 'searchBothHybrid';
621
741
  }
622
742
  else {
623
- results = await hybridSearch(query, localEntries, {
624
- budget, hippoRoot, explain: true, mmr: mmrEnabled, mmrLambda,
743
+ results = await hybridSearch(query, explainLocalEntries, {
744
+ budget, hippoRoot, explain: true, mmr: mmrEnabled, mmrLambda, scope: explainActiveScope,
745
+ includeSuperseded: explainIncludeSuperseded, asOf: explainAsOf,
625
746
  });
626
747
  modeUsed = 'hybrid';
627
748
  }
628
749
  if (limit < results.length) {
629
750
  results = results.slice(0, limit);
630
751
  }
631
- const candidates = localEntries.length + globalEntries.length;
752
+ const candidates = explainLocalEntries.length + explainGlobalEntries.length;
632
753
  if (asJson) {
633
754
  const output = results.map((r, rank) => ({
634
755
  rank: rank + 1,
@@ -691,6 +812,8 @@ async function cmdExplain(hippoRoot, query, flags) {
691
812
  console.log(` recency: x${fmt(b.recencyMultiplier, 3)} (age=${b.ageDays}d)`);
692
813
  if (b.decisionBoost !== 1)
693
814
  console.log(` decision: x${fmt(b.decisionBoost, 2)} (tagged 'decision')`);
815
+ if (b.scopeBoost !== 1)
816
+ console.log(` scope: x${fmt(b.scopeBoost, 2)} (scope tag ${b.scopeBoost > 1 ? 'match' : 'mismatch'})`);
694
817
  if (b.pathBoost !== 1)
695
818
  console.log(` path: x${fmt(b.pathBoost, 3)} (cwd path tag overlap)`);
696
819
  if (b.sourceBump !== 1)
@@ -2190,6 +2313,8 @@ async function cmdContext(hippoRoot, args, flags) {
2190
2313
  }
2191
2314
  const budget = parseInt(String(flags['budget'] ?? '1500'), 10);
2192
2315
  const limit = parseLimitFlag(flags['limit']);
2316
+ const ctxExplicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
2317
+ const ctxActiveScope = ctxExplicitScope || detectScope();
2193
2318
  // If budget is 0, skip entirely (zero token cost)
2194
2319
  if (budget <= 0)
2195
2320
  return;
@@ -2207,8 +2332,11 @@ async function cmdContext(hippoRoot, args, flags) {
2207
2332
  // When the local store isn't initialized (pinned-only path in a fresh dir),
2208
2333
  // skip the local load — loadAllEntries would auto-create .hippo here and
2209
2334
  // we don't want to pollute arbitrary cwds.
2210
- const localEntries = hasLocal ? loadAllEntries(hippoRoot) : [];
2211
- const globalEntries = hasGlobal ? loadAllEntries(globalRoot) : [];
2335
+ let localEntries = hasLocal ? loadAllEntries(hippoRoot) : [];
2336
+ let globalEntries = hasGlobal ? loadAllEntries(globalRoot) : [];
2337
+ // Default context always filters superseded (no --include-superseded / --as-of for context)
2338
+ localEntries = localEntries.filter(e => !e.superseded_by);
2339
+ globalEntries = globalEntries.filter(e => !e.superseded_by);
2212
2340
  const allEntries = [...localEntries];
2213
2341
  if (allEntries.length === 0 && globalEntries.length === 0)
2214
2342
  return; // no memories, zero output
@@ -2239,12 +2367,16 @@ async function cmdContext(hippoRoot, args, flags) {
2239
2367
  ...pinnedLocal.map((e) => ({ entry: e, isGlobal: false })),
2240
2368
  ...pinnedGlobal.map((e) => ({ entry: e, isGlobal: true })),
2241
2369
  ]
2242
- .map(({ entry, isGlobal }) => ({
2243
- entry,
2244
- score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1),
2245
- tokens: estimateTokens(entry.content),
2246
- isGlobal,
2247
- }))
2370
+ .map(({ entry, isGlobal }) => {
2371
+ const scopeSig = scopeMatch(entry.tags, ctxActiveScope);
2372
+ const sBst = scopeSig === 1 ? 1.5 : scopeSig === -1 ? 0.5 : 1.0;
2373
+ return {
2374
+ entry,
2375
+ score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1) * sBst,
2376
+ tokens: estimateTokens(entry.content),
2377
+ isGlobal,
2378
+ };
2379
+ })
2248
2380
  .sort((a, b) => b.score - a.score);
2249
2381
  let usedP = 0;
2250
2382
  for (const r of rankedPinned) {
@@ -2287,7 +2419,7 @@ async function cmdContext(hippoRoot, args, flags) {
2287
2419
  else {
2288
2420
  let results;
2289
2421
  if (hasGlobal) {
2290
- const merged = await searchBothHybrid(query, hippoRoot, globalRoot, { budget });
2422
+ const merged = await searchBothHybrid(query, hippoRoot, globalRoot, { budget, scope: ctxActiveScope });
2291
2423
  const localIndex = loadIndex(hippoRoot);
2292
2424
  results = merged.map((r) => ({
2293
2425
  entry: r.entry,
@@ -2300,8 +2432,8 @@ async function cmdContext(hippoRoot, args, flags) {
2300
2432
  const ctxConfig = loadConfig(hippoRoot);
2301
2433
  const usePhysicsCtx = ctxConfig.physics?.enabled !== false;
2302
2434
  const ctxResults = usePhysicsCtx
2303
- ? await physicsSearch(query, localEntries, { budget, hippoRoot, physicsConfig: ctxConfig.physics })
2304
- : await hybridSearch(query, localEntries, { budget, hippoRoot });
2435
+ ? await physicsSearch(query, localEntries, { budget, hippoRoot, physicsConfig: ctxConfig.physics, scope: ctxActiveScope })
2436
+ : await hybridSearch(query, localEntries, { budget, hippoRoot, scope: ctxActiveScope });
2305
2437
  results = ctxResults.map((r) => ({
2306
2438
  entry: r.entry,
2307
2439
  score: r.score,
@@ -3518,6 +3650,16 @@ async function main() {
3518
3650
  await cmdRecall(hippoRoot, query, flags);
3519
3651
  break;
3520
3652
  }
3653
+ case 'supersede': {
3654
+ const oldId = args[0];
3655
+ const newContent = args.slice(1).join(' ').trim();
3656
+ if (!oldId || !newContent) {
3657
+ console.error('Usage: hippo supersede <old-id> "<new content>" [--layer L] [--tag T] [--pin]');
3658
+ process.exit(1);
3659
+ }
3660
+ cmdSupersede(hippoRoot, oldId, newContent, flags);
3661
+ break;
3662
+ }
3521
3663
  case 'explain': {
3522
3664
  const query = args.join(' ').trim();
3523
3665
  if (!query) {