hippo-memory 0.31.0 → 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,14 @@ 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
+
63
71
  ### What's new in v0.31.0
64
72
 
65
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.
package/dist/cli.js CHANGED
@@ -438,6 +438,37 @@ function cmdRemember(hippoRoot, text, flags) {
438
438
  });
439
439
  }
440
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
+ }
441
472
  async function cmdRecall(hippoRoot, query, flags) {
442
473
  requireInit(hippoRoot);
443
474
  const budget = parseInt(String(flags['budget'] ?? '4000'), 10);
@@ -446,9 +477,43 @@ async function cmdRecall(hippoRoot, query, flags) {
446
477
  const showWhy = Boolean(flags['why']);
447
478
  const forcePhysics = Boolean(flags['physics']);
448
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
+ }
449
486
  const globalRoot = getGlobalRoot();
450
- const localEntries = loadSearchEntries(hippoRoot, query);
451
- 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
+ }
452
517
  const hasGlobal = globalEntries.length > 0;
453
518
  // Determine search mode: --physics forces physics, --classic forces BM25+cosine,
454
519
  // default uses physics if config.physics.enabled is not false
@@ -555,6 +620,10 @@ async function cmdRecall(hippoRoot, query, flags) {
555
620
  if (r.entry.layer === Layer.Trace) {
556
621
  base.trace_outcome = r.entry.trace_outcome;
557
622
  }
623
+ if (r.entry.superseded_by) {
624
+ base.superseded = true;
625
+ base.superseded_by = r.entry.superseded_by;
626
+ }
558
627
  if (showWhy) {
559
628
  const explanation = explainMatch(query, r);
560
629
  base.confidence = resolveConfidence(r.entry);
@@ -577,8 +646,9 @@ async function cmdRecall(hippoRoot, query, flags) {
577
646
  const strengthBar = '\u2588'.repeat(Math.round(e.strength * 10)) + '\u2591'.repeat(10 - Math.round(e.strength * 10));
578
647
  const isGlobal = isInitialized(globalRoot) && !localIndex.entries[e.id];
579
648
  const globalMark = isGlobal ? ' [global]' : '';
649
+ const supersededMark = e.superseded_by ? ' [superseded]' : '';
580
650
  const sourceMark = isGlobal ? ' [global]' : ' [local]';
581
- 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)}`);
582
652
  console.log(` [${strengthBar}] tags: ${e.tags.join(', ') || 'none'} | retrieved: ${e.retrieval_count}x`);
583
653
  if (showWhy) {
584
654
  const explanation = explainMatch(query, r);
@@ -597,10 +667,44 @@ async function cmdExplain(hippoRoot, query, flags) {
597
667
  const asJson = Boolean(flags['json']);
598
668
  const forcePhysics = Boolean(flags['physics']);
599
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
+ }
600
676
  const globalRoot = getGlobalRoot();
601
- const localEntries = loadSearchEntries(hippoRoot, query);
602
- const globalEntries = isInitialized(globalRoot) ? loadSearchEntries(globalRoot, query) : [];
603
- 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;
604
708
  const config = loadConfig(hippoRoot);
605
709
  const usePhysics = forcePhysics
606
710
  || (!forceClassic && config.physics.enabled !== false);
@@ -619,7 +723,7 @@ async function cmdExplain(hippoRoot, query, flags) {
619
723
  let results;
620
724
  let modeUsed;
621
725
  if (usePhysics && !hasGlobal) {
622
- results = await physicsSearch(query, localEntries, {
726
+ results = await physicsSearch(query, explainLocalEntries, {
623
727
  budget,
624
728
  hippoRoot,
625
729
  physicsConfig: config.physics,
@@ -631,19 +735,21 @@ async function cmdExplain(hippoRoot, query, flags) {
631
735
  else if (hasGlobal) {
632
736
  results = await searchBothHybrid(query, hippoRoot, globalRoot, {
633
737
  budget, explain: true, mmr: mmrEnabled, mmrLambda, localBump, scope: explainActiveScope,
738
+ includeSuperseded: explainIncludeSuperseded, asOf: explainAsOf,
634
739
  });
635
740
  modeUsed = 'searchBothHybrid';
636
741
  }
637
742
  else {
638
- results = await hybridSearch(query, localEntries, {
743
+ results = await hybridSearch(query, explainLocalEntries, {
639
744
  budget, hippoRoot, explain: true, mmr: mmrEnabled, mmrLambda, scope: explainActiveScope,
745
+ includeSuperseded: explainIncludeSuperseded, asOf: explainAsOf,
640
746
  });
641
747
  modeUsed = 'hybrid';
642
748
  }
643
749
  if (limit < results.length) {
644
750
  results = results.slice(0, limit);
645
751
  }
646
- const candidates = localEntries.length + globalEntries.length;
752
+ const candidates = explainLocalEntries.length + explainGlobalEntries.length;
647
753
  if (asJson) {
648
754
  const output = results.map((r, rank) => ({
649
755
  rank: rank + 1,
@@ -2226,8 +2332,11 @@ async function cmdContext(hippoRoot, args, flags) {
2226
2332
  // When the local store isn't initialized (pinned-only path in a fresh dir),
2227
2333
  // skip the local load — loadAllEntries would auto-create .hippo here and
2228
2334
  // we don't want to pollute arbitrary cwds.
2229
- const localEntries = hasLocal ? loadAllEntries(hippoRoot) : [];
2230
- 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);
2231
2340
  const allEntries = [...localEntries];
2232
2341
  if (allEntries.length === 0 && globalEntries.length === 0)
2233
2342
  return; // no memories, zero output
@@ -3541,6 +3650,16 @@ async function main() {
3541
3650
  await cmdRecall(hippoRoot, query, flags);
3542
3651
  break;
3543
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
+ }
3544
3663
  case 'explain': {
3545
3664
  const query = args.join(' ').trim();
3546
3665
  if (!query) {