hippo-memory 1.52.7 → 1.52.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +159 -99
  2. package/dist/api.d.ts +52 -18
  3. package/dist/api.js +155 -86
  4. package/dist/audit.d.ts +2 -1
  5. package/dist/audit.js +63 -0
  6. package/dist/autolearn.js +3 -2
  7. package/dist/capture.d.ts +40 -0
  8. package/dist/capture.js +141 -119
  9. package/dist/churn-git.js +2 -2
  10. package/dist/cli.d.ts +1 -4
  11. package/dist/cli.js +695 -702
  12. package/dist/codex-patch.d.ts +12 -0
  13. package/dist/codex-patch.js +71 -0
  14. package/dist/config.d.ts +0 -1
  15. package/dist/config.js +0 -4
  16. package/dist/connectors/slack/types.d.ts +0 -1
  17. package/dist/consolidate.js +85 -32
  18. package/dist/context-render.d.ts +36 -0
  19. package/dist/context-render.js +154 -0
  20. package/dist/dag.js +3 -2
  21. package/dist/dashboard.js +4 -0
  22. package/dist/db.js +6 -6
  23. package/dist/dedupe.d.ts +6 -6
  24. package/dist/dedupe.js +10 -9
  25. package/dist/doctor.d.ts +1 -1
  26. package/dist/doctor.js +35 -2
  27. package/dist/dormant.d.ts +4 -0
  28. package/dist/dormant.js +17 -2
  29. package/dist/embedding-provider.d.ts +2 -1
  30. package/dist/embedding-provider.js +2 -1
  31. package/dist/embeddings.js +23 -3
  32. package/dist/extensions/openclaw-plugin/index.js +1 -0
  33. package/dist/extract.js +5 -1
  34. package/dist/forward-claim-detector.d.ts +1 -1
  35. package/dist/forward-claim-detector.js +1 -1
  36. package/dist/graph-recall.d.ts +3 -1
  37. package/dist/graph-recall.js +13 -10
  38. package/dist/handoff.d.ts +2 -0
  39. package/dist/hooks.d.ts +19 -5
  40. package/dist/hooks.js +131 -30
  41. package/dist/importers.js +5 -12
  42. package/dist/judgment.d.ts +30 -0
  43. package/dist/judgment.js +122 -0
  44. package/dist/mcp/server.js +174 -213
  45. package/dist/merged-row.d.ts +6 -0
  46. package/dist/merged-row.js +35 -0
  47. package/dist/multihop.d.ts +2 -1
  48. package/dist/multihop.js +7 -4
  49. package/dist/physics-state.d.ts +0 -4
  50. package/dist/physics-state.js +0 -6
  51. package/dist/predictions.d.ts +2 -17
  52. package/dist/predictions.js +2 -15
  53. package/dist/reject-flow.d.ts +7 -5
  54. package/dist/reject-flow.js +41 -12
  55. package/dist/salience.js +12 -5
  56. package/dist/same-text.d.ts +17 -0
  57. package/dist/same-text.js +38 -0
  58. package/dist/scheduler.d.ts +4 -0
  59. package/dist/scheduler.js +8 -0
  60. package/dist/search.d.ts +7 -0
  61. package/dist/search.js +16 -32
  62. package/dist/secret-detect.d.ts +2 -0
  63. package/dist/secret-detect.js +9 -3
  64. package/dist/server-detect.js +9 -33
  65. package/dist/server.js +6 -62
  66. package/dist/session-digest.d.ts +79 -0
  67. package/dist/session-digest.js +528 -0
  68. package/dist/shared.d.ts +11 -3
  69. package/dist/shared.js +41 -31
  70. package/dist/store.d.ts +4 -5
  71. package/dist/store.js +26 -13
  72. package/dist/token-ledger.d.ts +46 -8
  73. package/dist/token-ledger.js +140 -21
  74. package/dist/version.d.ts +1 -1
  75. package/dist/version.js +1 -1
  76. package/dist-ui/assets/index-BhT8RvO6.js +61 -0
  77. package/dist-ui/index.html +1 -1
  78. package/extensions/openclaw-plugin/README.md +4 -4
  79. package/extensions/openclaw-plugin/index.ts +1 -0
  80. package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
  81. package/extensions/openclaw-plugin/package.json +1 -1
  82. package/openclaw.plugin.json +2 -2
  83. package/package.json +2 -2
  84. package/dist-ui/assets/index-BgmA7Hwe.js +0 -61
@@ -16,7 +16,8 @@ import { loadAllEntries, writeEntry, strengthenRetrieved, readEntry, loadFreshAc
16
16
  import { shareMemory, listPeers, getGlobalRoot, initGlobal } from '../shared.js';
17
17
  import { consolidate } from '../consolidate.js';
18
18
  import { execSync } from 'child_process';
19
- import { fetchGitLog, extractLessons, partitionLessons, deduplicateLesson, isGitRepo } from '../autolearn.js';
19
+ import { fetchGitLog, extractLessons, partitionLessons, isGitRepo } from '../autolearn.js';
20
+ import { dropHeldCopies, duplicateKey, storedTextKeys } from '../same-text.js';
20
21
  import { loadConfig } from '../config.js';
21
22
  import { confidenceLabel } from '../memory.js';
22
23
  import { resolveTenantId } from '../tenant.js';
@@ -74,6 +75,7 @@ function isJsonObjectRecord(v) {
74
75
  return v !== undefined && v !== null && typeof v === 'object' && !Array.isArray(v);
75
76
  }
76
77
  import { formatHandoffEvidenceLine } from '../handoff.js';
78
+ import { assembleCost, assembleText, drillCost, drillText, printedTokens } from '../context-render.js';
77
79
  function formatContinuityBlock(block) {
78
80
  const lines = ['## Continuity'];
79
81
  if (block.activeSnapshot) {
@@ -123,19 +125,54 @@ function formatContinuityBlock(block) {
123
125
  }
124
126
  return lines.join('\n');
125
127
  }
126
- function formatMemories(results, hippoRoot) {
128
+ const NO_MEMORIES = 'No relevant memories found.';
129
+ function memoriesHeading(count) {
130
+ return `Found ${count} memories:\n`;
131
+ }
132
+ function formatMemory(r) {
133
+ const conf = confidenceLabel(r.entry).text;
134
+ const tags = r.entry.tags.length > 0 ? ` tags: ${r.entry.tags.join(', ')}` : '';
135
+ return `[${conf}]${tags} (strength=${r.entry.strength.toFixed(2)})\n${r.entry.content}\n`;
136
+ }
137
+ function formatMemories(results) {
127
138
  if (results.length === 0)
128
- return 'No relevant memories found.';
129
- const config = loadConfig(hippoRoot);
130
- const lines = [`Found ${results.length} memories:\n`];
131
- for (const r of results) {
132
- const conf = confidenceLabel(r.entry).text;
133
- const tags = r.entry.tags.length > 0 ? ` tags: ${r.entry.tags.join(', ')}` : '';
134
- lines.push(`[${conf}]${tags} (strength=${r.entry.strength.toFixed(2)})`);
135
- lines.push(r.entry.content);
136
- lines.push('');
139
+ return NO_MEMORIES;
140
+ return [memoriesHeading(results.length), ...results.map(formatMemory)].join('\n');
141
+ }
142
+ /** What a memory costs the budget: the text formatMemories prints for it. */
143
+ const memoryCost = (r) => printedTokens(formatMemory(r));
144
+ // The widest heading or the empty-list line, whichever costs more, so either prints inside the budget.
145
+ function memoriesReserve(budget) {
146
+ return Math.max(printedTokens(memoriesHeading(budget)), estimateTokens(NO_MEMORIES));
147
+ }
148
+ // Rows the ranked list already shows drop out of this section, so pricing every row bounds what it prints.
149
+ function tailSection(rows) {
150
+ if (rows.length === 0)
151
+ return '';
152
+ const lines = ['', '## Fresh tail / substituted summaries'];
153
+ for (const r of rows) {
154
+ const tag = r.isSummary ? '[summary]' : '[tail]';
155
+ const head = r.content.length > 200 ? r.content.slice(0, 200) + '…' : r.content;
156
+ if (r.isSummary && r.substitutedFor && r.substitutedFor.length > 0) {
157
+ lines.push(`- ${tag} ${r.id} (covers ${r.substitutedFor.length} rows): ${head}`);
158
+ }
159
+ else {
160
+ lines.push(`- ${tag} ${r.id}: ${head}`);
161
+ }
137
162
  }
138
- return lines.join('\n');
163
+ return '\n' + lines.join('\n');
164
+ }
165
+ // J3.2: the hint depends on the query alone, so api.recall's copy is the one shown; JSON.stringify fences the phrase.
166
+ function planningSection(r) {
167
+ if (r.planningFallacyHint) {
168
+ const h = r.planningFallacyHint;
169
+ return `## Planning fallacy hint\nClass: ${h.classTag}\n${h.baserateSummary}\n(detected: ${JSON.stringify(h.detectedPhrase)})\n\n---\n\n`;
170
+ }
171
+ if (r.planningFallacyWatching) {
172
+ const w = r.planningFallacyWatching;
173
+ return `## Planning fallacy watch\nReason: ${w.reason}\n${w.suggestion}\n(detected: ${JSON.stringify(w.detectedPhrase)})\n\n---\n\n`;
174
+ }
175
+ return '';
139
176
  }
140
177
  // ── Tool definitions ──
141
178
  const TOOLS = [
@@ -512,6 +549,7 @@ async function executeTool(name, args, ctx) {
512
549
  // never actually saw. Real MCP tracing is the reserved 'mcp'
513
550
  // pipeline value (schema v40) — a follow-up, not v1 scope.
514
551
  suppressRecallTrace: true,
552
+ keepHeldCopies: true,
515
553
  ...recallExtra,
516
554
  });
517
555
  // Existing physics/hybrid scorer continues to drive user-visible
@@ -532,10 +570,26 @@ async function executeTool(name, args, ctx) {
532
570
  ? allEntries.filter((e) => e.scope === explicitScope)
533
571
  : allEntries.filter((e) => passesScopeFilterForRecall(e.scope ?? null, undefined));
534
572
  const droppedPreRankCountMcp = allEntries.length - entries.length;
573
+ // Sections are paid in print order, ahead of the memories and after the heading; one that does not fit is dropped whole.
574
+ let left = budget - memoriesReserve(budget);
575
+ const pays = (piece) => {
576
+ const tokens = estimateTokens(piece);
577
+ if (tokens > left)
578
+ return false;
579
+ left -= tokens;
580
+ return true;
581
+ };
582
+ const planPiece = planningSection(apiResult);
583
+ const showPlan = planPiece !== '' && pays(planPiece);
584
+ const tailRows = apiResult.results.filter((r) => r.isFreshTail || r.isSummary);
585
+ const showTail = tailRows.length > 0 && pays(tailSection(tailRows));
586
+ const continuityPiece = includeContinuity && apiResult.continuity ? `\n\n${formatContinuityBlock(apiResult.continuity)}` : '';
587
+ const showContinuity = continuityPiece !== '' && pays(continuityPiece);
535
588
  const usePhysics = config.physics?.enabled !== false;
589
+ const fit = { budget: Math.max(0, left), cost: memoryCost, hippoRoot };
536
590
  let results = usePhysics
537
- ? await physicsSearch(query, entries, { budget, hippoRoot, physicsConfig: config.physics })
538
- : await hybridSearch(query, entries, { budget, hippoRoot });
591
+ ? await physicsSearch(query, entries, { ...fit, physicsConfig: config.physics })
592
+ : await hybridSearch(query, entries, fit);
539
593
  // v1.12.13 / C5 — droppedByBudget for MCP is an UPPER BOUND. The
540
594
  // difference (entries.length - results.length) lumps three things
541
595
  // together: rows hybridSearch/physicsSearch internally dropped because
@@ -553,7 +607,7 @@ async function executeTool(name, args, ctx) {
553
607
  // compute droppedByBudget = scoredCount - results.length, with the
554
608
  // remainder (entries.length - scoredCount) attributed to
555
609
  // droppedPreRank or a new "noQueryMatch" counter.
556
- const droppedByBudgetCountMcp = Math.max(0, entries.length - results.length);
610
+ const droppedByBudgetFor = (shown) => Math.max(0, entries.length - shown);
557
611
  // v1.7.4 -- dlPFC goal-stack boost on the MCP physics/hybrid result
558
612
  // list BEFORE formatMemories. MCP's user-visible primary ordering does
559
613
  // NOT come from api.recall (apiResult above), so the boost has to run
@@ -572,26 +626,71 @@ async function executeTool(name, args, ctx) {
572
626
  closeHippoDb(dbForBoost);
573
627
  }
574
628
  }
575
- const retrievedIds = results.map((r) => r.entry.id);
629
+ // J1, J2 and C5: MCP ranks its own list (its top-1 can differ from api.recall's), so its hints and Cutoff block are its own.
630
+ const anchorRing = process.env.HIPPO_ANCHORING !== 'off' && sessionId
631
+ ? getOrCreateRing(sessionRecallHistoryMcp, buildSessionKey(tenantId, sessionId))
632
+ : null;
633
+ const queryHash = hashQueryText(query);
634
+ const render = (cut) => {
635
+ const list = dropHeldCopies(cut, (r) => r.entry); // after every cut, so a merged row cut here never hides its sources
636
+ const anchoring = anchorRing ? detectAnchoring(snapshotRing(anchorRing), queryHash, list[0]?.entry.id ?? null) : null;
637
+ const availability = process.env.HIPPO_AVAILABILITY !== 'off'
638
+ ? detectAvailabilityBias({
639
+ topK: list.map((r) => ({ id: r.entry.id, created: r.entry.created })),
640
+ pool: entries.map((e) => ({ id: e.id, created: e.created })),
641
+ })
642
+ : null;
643
+ const shownIds = new Set(list.map((r) => r.entry.id));
644
+ const shownKeys = storedTextKeys(list.map((r) => r.entry));
645
+ const tail = showTail
646
+ ? dropHeldCopies(tailRows.filter((r) => !shownIds.has(r.id) && !shownKeys.has(duplicateKey(r.content))), (r) => r)
647
+ : [];
648
+ const s = buildSuppressionSummary({
649
+ totalCandidates: totalCandidatesCountMcp,
650
+ droppedPreRank: droppedPreRankCountMcp + cut.length - list.length, // the bucket CLI and API recall put hidden copies in
651
+ droppedByBudget: droppedByBudgetFor(cut.length),
652
+ summarySubstitutionsAdded: tail.filter((r) => r.isSummary).length,
653
+ freshTailAdded: tail.filter((r) => r.isFreshTail && !r.isSummary).length,
654
+ suppressedByInterference: anchoring?.reason === 'memory_dominance' ? 1 : 0,
655
+ });
656
+ // Anchoring is the stronger pull, so it prints first; the Cutoff block sits above the list, where the agent reads it.
657
+ let text = anchoring ? `## Anchoring hint\n${anchoring.summary}\n[anchored_on: ${anchoring.memoryId}]\n\n---\n\n` : '';
658
+ if (availability)
659
+ text += `## Availability bias\n${availability.summary}\n\n---\n\n`;
660
+ if (showPlan)
661
+ text += planPiece;
662
+ const cutoffClauses = [];
663
+ if (s.droppedByBudget > 0)
664
+ cutoffClauses.push(`${s.droppedByBudget} dropped to fit limit`);
665
+ if (s.droppedPreRank > 0)
666
+ cutoffClauses.push(`${s.droppedPreRank} filtered pre-rank`);
667
+ if (s.summarySubstitutionsAdded > 0)
668
+ cutoffClauses.push(`${s.summarySubstitutionsAdded} summary substitutions added`);
669
+ if (s.freshTailAdded > 0)
670
+ cutoffClauses.push(`${s.freshTailAdded} fresh-tail added`);
671
+ if (s.suppressedByInterference > 0)
672
+ cutoffClauses.push(`${s.suppressedByInterference} suppressed by interference`);
673
+ if (cutoffClauses.length > 0) {
674
+ text += `## Cutoff\nShowing ${list.length} of ${s.totalCandidates} candidates; ${cutoffClauses.join('; ')}.\n\n---\n\n`;
675
+ }
676
+ // v1.6.3: the fresh-tail and summary rows api.recall produced follow the ranked list, or the MCP fields go unanswered.
677
+ text += formatMemories(list) + tailSection(tail) + (showContinuity ? continuityPiece : '');
678
+ return { anchoring, availability, text, list };
679
+ };
680
+ let rendered = render(results);
681
+ // The hints, Cutoff block and heading vary with the list, so the lowest-ranked entry goes until the whole response fits.
682
+ while (results.length > 1 && estimateTokens(rendered.text) > budget) {
683
+ results = results.slice(0, -1);
684
+ rendered = render(results);
685
+ }
686
+ const { anchoring: mcpAnchoringHint, availability: mcpAvailabilityHint, list: shown } = rendered;
687
+ const retrievedIds = shown.map((r) => r.entry.id);
576
688
  strengthenRetrieved(hippoRoot, retrievedIds);
577
689
  lastRecalledIds.set(resolveClientKey(ctx), retrievedIds);
578
- // v0.33 / J1 — MCP per-pipeline anchoring detector. UNLIKE J3.2's
579
- // planningFallacyHint (which is pipeline-invariant because it
580
- // depends only on queryText + predictions table state), the
581
- // anchoring hint depends on (a) per-pipeline top-1 ranking (MCP's
582
- // physics/hybrid winner can differ from api.recall's BM25 winner)
583
- // and (b) per-pipeline ring buffer. So MCP computes its OWN hint
584
- // against MCP's own top-1, mirroring the C5 per-pipeline rule.
585
- let mcpAnchoringHint = null;
586
690
  if (process.env.HIPPO_ANCHORING !== 'off') {
587
- if (sessionId) {
588
- const ringKey = buildSessionKey(tenantId, sessionId);
589
- const ring = getOrCreateRing(sessionRecallHistoryMcp, ringKey);
590
- const queryHash = hashQueryText(query);
591
- const topId = results[0]?.entry.id ?? null;
592
- mcpAnchoringHint = detectAnchoring(snapshotRing(ring), queryHash, topId);
593
- appendRecall(ring, queryHash, topId, mcpAnchoringHint?.memoryId);
594
- // Pipeline-local audit emission (lockstep with CLI / api.recall).
691
+ if (anchorRing) {
692
+ // Appended after the final detect: anchoredOn feeds the cooldown for the next recall on this session.
693
+ appendRecall(anchorRing, queryHash, shown[0]?.entry.id ?? null, mcpAnchoringHint?.memoryId);
595
694
  if (mcpAnchoringHint?.reason === 'memory_dominance') {
596
695
  const dbForAudit = openHippoDb(hippoRoot);
597
696
  try {
@@ -650,160 +749,25 @@ async function executeTool(name, args, ctx) {
650
749
  }
651
750
  }
652
751
  }
653
- // v0.32 / J3.2 — auto-injection of reference-class baserate hint
654
- // when the query carries a forward-prediction phrase. Read from
655
- // apiResult.planningFallacyHint (already computed inside api.recall
656
- // with the caller identity threaded through ctx.actor.subject -
657
- // auth-resolved actor under HTTP-MCP, 'mcp' for stdio). The hint is
658
- // pipeline-INVARIANT — same (hippoRoot, tenantId, query) inputs
659
- // produce the same hint regardless of which downstream search
660
- // pipeline (api.recall band vs physics/hybrid) renders the memory
661
- // list, so re-computing here would double the audit emission for
662
- // identical telemetry. C5 per-pipeline rule does NOT apply here
663
- // because the hint depends on queryText, not on the matched memory
664
- // set. Prepend BEFORE the memory list so the agent sees it first.
665
- // v0.33 / J1: Anchoring hint goes ABOVE planning-fallacy hint
666
- // (anchoring is the stronger cognitive-pull warning).
667
- // v1.13.3 / C5 follow-up — Build MCP-pipeline suppressionSummary BEFORE
668
- // the response is assembled so the Cutoff block can render at TOP
669
- // alongside the other Track J hints. The dogfood
670
- // (docs/dogfood/2026-05-27-track-j-warnings.md) showed the v1.13.0-v1.13.2
671
- // bottom-placement was dark: a fresh sub-agent summarised the visible
672
- // memories with zero mention of the dropped pool. Top-placement + plain-
673
- // English rewrite fixes the read-rate without any system-prompt addendum.
674
- const physicsIds = new Set(results.map((r) => r.entry.id));
675
- const tailOrSummary = apiResult.results.filter((r) => (r.isFreshTail || r.isSummary) && !physicsIds.has(r.id));
676
- const freshTailAddedMcp = tailOrSummary.filter((r) => r.isFreshTail && !r.isSummary).length;
677
- const summarySubsAddedMcp = tailOrSummary.filter((r) => r.isSummary).length;
678
- // v0.33 / J1: suppressedByInterference bumped on MCP's R2 fire.
679
- const mcpSuppressedByInterference = mcpAnchoringHint?.reason === 'memory_dominance' ? 1 : 0;
680
- const mcpSuppressionSummary = buildSuppressionSummary({
681
- totalCandidates: totalCandidatesCountMcp,
682
- droppedPreRank: droppedPreRankCountMcp,
683
- droppedByBudget: droppedByBudgetCountMcp,
684
- summarySubstitutionsAdded: summarySubsAddedMcp,
685
- freshTailAdded: freshTailAddedMcp,
686
- suppressedByInterference: mcpSuppressedByInterference,
687
- });
688
- // v1.13.x / J2 — MCP per-pipeline availability/recency-bias detector.
689
- // Like the anchoring hint above (and unlike J3.2's pipeline-invariant
690
- // planningFallacyHint), this depends on MCP's OWN returned top-K and the
691
- // scope-filtered candidate pool (entries) it was drawn from, so MCP
692
- // computes its own hint here. Soft warning only. Gated by
693
- // HIPPO_AVAILABILITY=off; audit emission is pipeline-local (actor =
694
- // auth-resolved ctx.actor under HTTP-MCP, 'mcp' for stdio).
695
- let mcpAvailabilityHint = null;
696
- if (process.env.HIPPO_AVAILABILITY !== 'off') {
697
- mcpAvailabilityHint = detectAvailabilityBias({
698
- topK: results.map((r) => ({ id: r.entry.id, created: r.entry.created })),
699
- pool: entries.map((e) => ({ id: e.id, created: e.created })),
700
- });
701
- if (mcpAvailabilityHint) {
702
- const dbForAudit = openHippoDb(hippoRoot);
703
- try {
704
- appendAuditEvent(dbForAudit, {
705
- tenantId,
706
- actor: ctx?.actor ?? 'mcp',
707
- op: 'recall_availability_detected',
708
- metadata: {
709
- recent_fraction: mcpAvailabilityHint.recentFraction,
710
- older_passed_over: mcpAvailabilityHint.olderCandidatesPassedOver,
711
- returned_count: mcpAvailabilityHint.returnedCount,
712
- },
713
- });
714
- }
715
- finally {
716
- closeHippoDb(dbForAudit);
717
- }
718
- }
719
- }
720
- let response = '';
721
- if (mcpAnchoringHint) {
722
- response =
723
- `## Anchoring hint\n` +
724
- `${mcpAnchoringHint.summary}\n` +
725
- `[anchored_on: ${mcpAnchoringHint.memoryId}]\n` +
726
- `\n---\n\n`;
727
- }
728
- // v1.13.x / J2 — availability/recency-bias hint, rendered below the
729
- // anchoring hint and above the planning-fallacy hint. Soft warning only.
730
752
  if (mcpAvailabilityHint) {
731
- response += `## Availability bias\n${mcpAvailabilityHint.summary}\n\n---\n\n`;
732
- }
733
- if (apiResult.planningFallacyHint) {
734
- const h = apiResult.planningFallacyHint;
735
- const safePhrase = JSON.stringify(h.detectedPhrase);
736
- response +=
737
- `## Planning fallacy hint\n` +
738
- `Class: ${h.classTag}\n` +
739
- `${h.baserateSummary}\n` +
740
- `(detected: ${safePhrase})\n` +
741
- `\n---\n\n`;
742
- }
743
- else if (apiResult.planningFallacyWatching) {
744
- // v1.13.4 / J3.2 follow-up — surface the watching variant when
745
- // the regex matched but no baserate could be produced
746
- // (no_class_match / tiebreak). Mutually exclusive with the hint
747
- // block above. Suggestion text directs the user toward an action
748
- // (typically: tag a prediction class) that would unblock the
749
- // hint next time.
750
- const w = apiResult.planningFallacyWatching;
751
- const safePhrase = JSON.stringify(w.detectedPhrase);
752
- response +=
753
- `## Planning fallacy watch\n` +
754
- `Reason: ${w.reason}\n` +
755
- `${w.suggestion}\n` +
756
- `(detected: ${safePhrase})\n` +
757
- `\n---\n\n`;
758
- }
759
- // v1.13.3 / C5 follow-up — Cutoff block (was "WYSIATI:" line at bottom
760
- // in v1.13.0-v1.13.2). Top placement so the agent reads the cutoff
761
- // before scrolling the result list. "Cutoff" is plain English; the old
762
- // "WYSIATI:" acronym was opaque to agents without Kahneman context per
763
- // the 2026-05-27 dogfood Trial 1.
764
- const sMcp = mcpSuppressionSummary;
765
- const cutoffClauses = [];
766
- if (sMcp.droppedByBudget > 0)
767
- cutoffClauses.push(`${sMcp.droppedByBudget} dropped to fit limit`);
768
- if (sMcp.droppedPreRank > 0)
769
- cutoffClauses.push(`${sMcp.droppedPreRank} filtered pre-rank`);
770
- if (sMcp.summarySubstitutionsAdded > 0)
771
- cutoffClauses.push(`${sMcp.summarySubstitutionsAdded} summary substitutions added`);
772
- if (sMcp.freshTailAdded > 0)
773
- cutoffClauses.push(`${sMcp.freshTailAdded} fresh-tail added`);
774
- if (sMcp.suppressedByInterference > 0)
775
- cutoffClauses.push(`${sMcp.suppressedByInterference} suppressed by interference`);
776
- if (cutoffClauses.length > 0) {
777
- response +=
778
- `## Cutoff\n` +
779
- `Showing ${results.length} of ${sMcp.totalCandidates} candidates; ${cutoffClauses.join('; ')}.\n` +
780
- `\n---\n\n`;
781
- }
782
- response += formatMemories(results, hippoRoot);
783
- // v1.6.3 codex P2 fix. The physics/hybrid scorer drives the primary
784
- // ranked block above, so user-visible ordering is preserved. But
785
- // when the v1.5.0+/v1.5.2 RecallOpts are passed, we MUST also surface
786
- // the fresh-tail and substituted-summary items apiRecall produced —
787
- // otherwise the advertised MCP fields are silently ignored. Append
788
- // them as their own section, deduplicated against the physics ranking.
789
- if (tailOrSummary.length > 0) {
790
- const lines = ['', '## Fresh tail / substituted summaries'];
791
- for (const r of tailOrSummary) {
792
- const tag = r.isSummary ? '[summary]' : '[tail]';
793
- const head = r.content.length > 200 ? r.content.slice(0, 200) + '…' : r.content;
794
- if (r.isSummary && r.substitutedFor && r.substitutedFor.length > 0) {
795
- lines.push(`- ${tag} ${r.id} (covers ${r.substitutedFor.length} rows): ${head}`);
796
- }
797
- else {
798
- lines.push(`- ${tag} ${r.id}: ${head}`);
799
- }
753
+ const dbForAudit = openHippoDb(hippoRoot);
754
+ try {
755
+ appendAuditEvent(dbForAudit, {
756
+ tenantId,
757
+ actor: ctx?.actor ?? 'mcp',
758
+ op: 'recall_availability_detected',
759
+ metadata: {
760
+ recent_fraction: mcpAvailabilityHint.recentFraction,
761
+ older_passed_over: mcpAvailabilityHint.olderCandidatesPassedOver,
762
+ returned_count: mcpAvailabilityHint.returnedCount,
763
+ },
764
+ });
765
+ }
766
+ finally {
767
+ closeHippoDb(dbForAudit);
800
768
  }
801
- response += '\n' + lines.join('\n');
802
- }
803
- if (includeContinuity && apiResult.continuity) {
804
- response += '\n\n' + formatContinuityBlock(apiResult.continuity);
805
769
  }
806
- return response;
770
+ return rendered.text;
807
771
  }
808
772
  case 'hippo_assemble': {
809
773
  const sessionId = String(args.session_id || '');
@@ -830,14 +794,9 @@ async function executeTool(name, args, ctx) {
830
794
  const r = apiAssemble(apiCtx, sessionId, {
831
795
  summarizeOlder,
832
796
  ...assembleExtra,
797
+ cost: assembleCost(sessionId),
833
798
  });
834
- const lines = [];
835
- lines.push(`Session ${r.sessionId} — ${r.items.length} items, ${r.tokens} tokens (raw=${r.totalRaw}, summarized=${r.summarized}, evicted=${r.evicted})`);
836
- for (const it of r.items) {
837
- const prefix = it.isSummary ? '[summary]' : it.isFreshTail ? '[tail]' : '[older]';
838
- lines.push(` ${prefix} ${it.createdAt} ${it.id} - ${it.content}`);
839
- }
840
- return lines.join('\n');
799
+ return assembleText(r);
841
800
  }
842
801
  case 'hippo_drill': {
843
802
  const summaryId = String(args.summary_id || '');
@@ -868,7 +827,7 @@ async function executeTool(name, args, ctx) {
868
827
  drillExtra.budget = budget;
869
828
  if (depth !== undefined)
870
829
  drillExtra.depth = depth;
871
- const r = apiDrillDown(apiCtx, summaryId, { ...drillExtra });
830
+ const r = apiDrillDown(apiCtx, summaryId, { ...drillExtra, cost: drillCost });
872
831
  if ('failure' in r) {
873
832
  // v1.6.4: only not_drillable is caller-actionable. not_found
874
833
  // intentionally collapses cross-tenant + scope-blocked + missing
@@ -879,15 +838,7 @@ async function executeTool(name, args, ctx) {
879
838
  }
880
839
  return `No drillable summary at id=${summaryId}.`;
881
840
  }
882
- const lines = [];
883
- lines.push(`Summary ${r.summary.id} — ${r.summary.descendantCount} descendants${r.summary.earliestAt ? ` (${r.summary.earliestAt} -> ${r.summary.latestAt})` : ''}`);
884
- lines.push(` ${r.summary.content}`);
885
- lines.push('');
886
- lines.push(`Children (${r.children.length}/${r.totalChildren}${r.truncated ? ', truncated' : ''}):`);
887
- for (const c of r.children) {
888
- lines.push(` [L${c.dagLevel}] ${c.id} - ${c.content}`);
889
- }
890
- return lines.join('\n');
841
+ return drillText(r);
891
842
  }
892
843
  case 'hippo_predict_baserate': {
893
844
  // J3 reference-class / planning-fallacy detector. Reads from the E2
@@ -989,9 +940,9 @@ async function executeTool(name, args, ctx) {
989
940
  // Auto-detect query from git
990
941
  let query = '';
991
942
  try {
992
- const branch = execSync('git rev-parse --abbrev-ref HEAD 2>/dev/null', { encoding: 'utf-8' }).trim();
993
- const diff = execSync('git diff --cached --stat 2>/dev/null', { encoding: 'utf-8' }).trim();
994
- const log = execSync('git log -1 --pretty=format:"%s" 2>/dev/null', { encoding: 'utf-8' }).trim();
943
+ const branch = execSync('git rev-parse --abbrev-ref HEAD 2>/dev/null', { encoding: 'utf-8', windowsHide: true }).trim();
944
+ const diff = execSync('git diff --cached --stat 2>/dev/null', { encoding: 'utf-8', windowsHide: true }).trim();
945
+ const log = execSync('git log -1 --pretty=format:"%s" 2>/dev/null', { encoding: 'utf-8', windowsHide: true }).trim();
995
946
  query = [branch, log, diff].filter(Boolean).join(' ');
996
947
  }
997
948
  catch { /* not a git repo */ }
@@ -1031,13 +982,6 @@ async function executeTool(name, args, ctx) {
1031
982
  return true;
1032
983
  return classifyOriginProject(e.origin_project, mcpProjectName) !== 'cross-project';
1033
984
  });
1034
- const usePhysicsCtx = config.physics?.enabled !== false;
1035
- const results = usePhysicsCtx
1036
- ? await physicsSearch(query, entries, { budget, hippoRoot, physicsConfig: config.physics })
1037
- : await hybridSearch(query, entries, { budget, hippoRoot });
1038
- const retrievedIds = results.map((r) => r.entry.id);
1039
- strengthenRetrieved(hippoRoot, retrievedIds);
1040
- lastRecalledIds.set(resolveClientKey(ctx), retrievedIds);
1041
985
  // DF1 (docs/plans/2026-08-23-df1-snapshot-lifecycle.md, T2): bounded
1042
986
  // read, no session id available on this surface (freshness bound
1043
987
  // only) — an orphaned snapshot must age out here too, not just on the
@@ -1061,8 +1005,23 @@ async function executeTool(name, args, ctx) {
1061
1005
  '',
1062
1006
  ].join('\n')
1063
1007
  : '';
1064
- const memoryText = formatMemories(results, hippoRoot);
1065
- return snapshotText ? `${snapshotText}\n${memoryText}` : memoryText;
1008
+ // The snapshot prints first, so it is paid first after the heading; context keeps no hit past the budget, even the top one.
1009
+ let left = budget - memoriesReserve(budget);
1010
+ if (left < 0)
1011
+ return ''; // not even the heading fits, so nothing prints, as at budget 0
1012
+ const snapshotPiece = snapshotText ? `${snapshotText}\n` : '';
1013
+ const showSnapshot = snapshotPiece !== '' && estimateTokens(snapshotPiece) <= left;
1014
+ if (showSnapshot)
1015
+ left -= estimateTokens(snapshotPiece);
1016
+ const usePhysicsCtx = config.physics?.enabled !== false;
1017
+ const fit = { budget: left, minResults: 0, cost: memoryCost, hippoRoot };
1018
+ const results = dropHeldCopies(usePhysicsCtx
1019
+ ? await physicsSearch(query, entries, { ...fit, physicsConfig: config.physics })
1020
+ : await hybridSearch(query, entries, fit), (r) => r.entry);
1021
+ const retrievedIds = results.map((r) => r.entry.id);
1022
+ strengthenRetrieved(hippoRoot, retrievedIds);
1023
+ lastRecalledIds.set(resolveClientKey(ctx), retrievedIds);
1024
+ return (showSnapshot ? snapshotPiece : '') + formatMemories(results);
1066
1025
  }
1067
1026
  case 'hippo_status': {
1068
1027
  const entries = loadAllEntries(hippoRoot, tenantId);
@@ -1108,8 +1067,9 @@ async function executeTool(name, args, ctx) {
1108
1067
  let added = 0;
1109
1068
  let skipped = 0;
1110
1069
  let rejected = 0;
1070
+ const keys = storedTextKeys(loadAllEntries(hippoRoot, tenantId));
1111
1071
  for (const lesson of lessons) {
1112
- if (deduplicateLesson(hippoRoot, lesson, 0.7, tenantId)) {
1072
+ if (keys.has(duplicateKey(lesson))) {
1113
1073
  skipped++;
1114
1074
  continue;
1115
1075
  }
@@ -1133,6 +1093,7 @@ async function executeTool(name, args, ctx) {
1133
1093
  }
1134
1094
  throw err;
1135
1095
  }
1096
+ keys.add(duplicateKey(lesson));
1136
1097
  added++;
1137
1098
  }
1138
1099
  const rejectedSuffix = rejected > 0 ? `, ${rejected} rejected values skipped` : '';
@@ -0,0 +1,6 @@
1
+ import { type MemoryEntry } from './memory.js';
2
+ /** The row that replaces a merged row once its retired texts leave: undefined when it holds none, null when nothing else is left. */
3
+ export declare function mergedSuccessor(row: MemoryEntry, retired: (text: string) => boolean, retiredIds: ReadonlySet<string>): MemoryEntry | null | undefined;
4
+ /** Sleep's check on a merged row: drops texts whose source was superseded since the merge, or that a rejection now covers. */
5
+ export declare function successorAfterRetirement(row: MemoryEntry, byId: ReadonlyMap<string, MemoryEntry>, rejected: (text: string) => boolean): MemoryEntry | null | undefined;
6
+ //# sourceMappingURL=merged-row.d.ts.map
@@ -0,0 +1,35 @@
1
+ // A sleep-merged row copies its sources' texts, so retiring a text (reject, or superseding its source) must reach the row too.
2
+ // SHORTCUT: reject rewrites merged rows at once, live and dormant; supersede and `resolve --reject-loser` wait for the next sleep's check, which sees a dormant row only once restored.
3
+ import { generateId } from './memory.js';
4
+ import { duplicateKey, heldTexts, mergedText } from './same-text.js';
5
+ /** The row that replaces a merged row once its retired texts leave: undefined when it holds none, null when nothing else is left. */
6
+ export function mergedSuccessor(row, retired, retiredIds) {
7
+ const texts = heldTexts(row);
8
+ const kept = texts.filter((t) => !retired(t));
9
+ if (kept.length === texts.length)
10
+ return undefined;
11
+ if (kept.length === 0)
12
+ return null;
13
+ const count = `${kept.length} related ${kept.length === 1 ? 'memory' : 'memories'}`;
14
+ const header = row.content.slice(0, row.content.indexOf('\n\n')).replace(/\d+ related memor(?:y|ies)/, count);
15
+ // A new id, as a fresh merge would get; age and half-life carry over so the rewrite buys the row no extra life.
16
+ return {
17
+ ...row,
18
+ id: generateId('sem'),
19
+ content: mergedText(header, kept),
20
+ parents: row.parents.filter((id) => !retiredIds.has(id)),
21
+ conflicts_with: [],
22
+ };
23
+ }
24
+ /** Sleep's check on a merged row: drops texts whose source was superseded since the merge, or that a rejection now covers. */
25
+ export function successorAfterRetirement(row, byId, rejected) {
26
+ if (row.source !== 'consolidation' || row.superseded_by)
27
+ return undefined;
28
+ const sources = row.parents.flatMap((id) => byId.get(id) ?? []);
29
+ const retiredIds = new Set(sources.filter((s) => Boolean(s.superseded_by) || rejected(s.content)).map((s) => s.id));
30
+ const live = new Set(sources.filter((s) => !retiredIds.has(s.id)).map((s) => duplicateKey(s.content)));
31
+ const gone = new Set(sources.filter((s) => retiredIds.has(s.id)).map((s) => duplicateKey(s.content)));
32
+ const retired = (text) => (gone.has(duplicateKey(text)) && !live.has(duplicateKey(text))) || rejected(text);
33
+ return mergedSuccessor(row, retired, retiredIds);
34
+ }
35
+ //# sourceMappingURL=merged-row.js.map
@@ -1,10 +1,11 @@
1
1
  import type { MemoryEntry } from './memory.js';
2
- import { type SearchResult } from './search.js';
2
+ import { type ResultCost, type SearchResult } from './search.js';
3
3
  export declare function multihopSearch(query: string, entries: MemoryEntry[], options?: {
4
4
  budget?: number;
5
5
  now?: Date;
6
6
  hippoRoot?: string;
7
7
  minResults?: number;
8
+ cost?: ResultCost;
8
9
  includeSuperseded?: boolean;
9
10
  asOf?: string;
10
11
  }): SearchResult[];
package/dist/multihop.js CHANGED
@@ -1,6 +1,9 @@
1
- import { search } from './search.js';
1
+ import { fitBudget, search } from './search.js';
2
2
  export function multihopSearch(query, entries, options = {}) {
3
- const pass1 = search(query, entries, { ...options, budget: (options.budget ?? 4000) * 2 });
3
+ const budget = options.budget ?? 4000;
4
+ // Pass 1 searches wide to find entities, so each return fits the caller's budget, as search() does.
5
+ const fit = (ordered) => fitBudget(ordered, budget, options.minResults ?? 1, options.cost);
6
+ const pass1 = search(query, entries, { ...options, budget: budget * 2 });
4
7
  const topK = pass1.slice(0, 10);
5
8
  if (topK.length === 0)
6
9
  return [];
@@ -17,7 +20,7 @@ export function multihopSearch(query, entries, options = {}) {
17
20
  .map((t) => t.split(':')[1])
18
21
  .filter((e) => !queryLower.includes(e.toLowerCase()));
19
22
  if (newEntities.length === 0)
20
- return pass1;
23
+ return fit(pass1);
21
24
  const followUpQuery = newEntities.join(' ') + ' ' + query;
22
25
  const pass2 = search(followUpQuery, entries, options);
23
26
  const merged = new Map();
@@ -30,6 +33,6 @@ export function multihopSearch(query, entries, options = {}) {
30
33
  // T2 note: PLAIN stable score sort on purpose -- pass1/pass2 inputs are
31
34
  // deterministically ordered (search() carries the content tail), stability
32
35
  // inherits that, and ties keep pass-1 results ahead of pass-2 follow-ups.
33
- return [...merged.values()].sort((a, b) => b.score - a.score);
36
+ return fit([...merged.values()].sort((a, b) => b.score - a.score));
34
37
  }
35
38
  //# sourceMappingURL=multihop.js.map
@@ -26,10 +26,6 @@ export declare function savePhysicsState(db: DatabaseSyncLike, particles: Physic
26
26
  * Returns the new particle (does not persist — caller must save).
27
27
  */
28
28
  export declare function initializeParticle(entry: MemoryEntry, embedding: number[], now?: Date): PhysicsParticle;
29
- /**
30
- * Delete physics state for a memory. (Also handled by CASCADE, but explicit for clarity.)
31
- */
32
- export declare function deletePhysicsState(db: DatabaseSyncLike, memoryId: string): void;
33
29
  /**
34
30
  * Reset all physics states from original embeddings.
35
31
  * Drops existing physics data and re-initializes from the embedding index.
@@ -129,12 +129,6 @@ export function initializeParticle(entry, embedding, now = evalNow()) {
129
129
  lastSimulation: now.toISOString(),
130
130
  };
131
131
  }
132
- /**
133
- * Delete physics state for a memory. (Also handled by CASCADE, but explicit for clarity.)
134
- */
135
- export function deletePhysicsState(db, memoryId) {
136
- db.prepare('DELETE FROM memory_physics WHERE memory_id = ?').run(memoryId);
137
- }
138
132
  /**
139
133
  * Reset all physics states from original embeddings.
140
134
  * Drops existing physics data and re-initializes from the embedding index.