hippo-memory 1.55.0 → 1.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +11 -0
  2. package/dist/api.d.ts +19 -9
  3. package/dist/api.js +112 -35
  4. package/dist/card-detail.d.ts +1 -1
  5. package/dist/card-detail.js +1 -1
  6. package/dist/cli/shared.d.ts +137 -0
  7. package/dist/cli/shared.js +830 -0
  8. package/dist/cli/sleep.d.ts +10 -0
  9. package/dist/cli/sleep.js +171 -0
  10. package/dist/cli.d.ts +0 -7
  11. package/dist/cli.js +313 -1806
  12. package/dist/config.d.ts +5 -0
  13. package/dist/config.js +21 -0
  14. package/dist/connectors/github/webhook.d.ts +19 -0
  15. package/dist/connectors/github/webhook.js +313 -0
  16. package/dist/connectors/slack/webhook.d.ts +22 -0
  17. package/dist/connectors/slack/webhook.js +203 -0
  18. package/dist/consolidate.js +3 -2
  19. package/dist/context-auto.d.ts +3 -0
  20. package/dist/context-auto.js +34 -0
  21. package/dist/customer-notes.js +2 -1
  22. package/dist/dashboard.js +2 -1
  23. package/dist/db.js +67 -1
  24. package/dist/decisions.js +2 -1
  25. package/dist/delivery-recorder.d.ts +127 -0
  26. package/dist/delivery-recorder.js +218 -0
  27. package/dist/eval-stats.d.ts +58 -0
  28. package/dist/eval-stats.js +111 -0
  29. package/dist/goals.d.ts +49 -25
  30. package/dist/goals.js +39 -22
  31. package/dist/graph-extract.js +1 -1
  32. package/dist/graph-recall.d.ts +1 -1
  33. package/dist/graph-recall.js +1 -1
  34. package/dist/graph.js +1 -1
  35. package/dist/hooks.d.ts +1 -3
  36. package/dist/hooks.js +2 -4
  37. package/dist/http-util.d.ts +31 -0
  38. package/dist/http-util.js +46 -0
  39. package/dist/incidents.js +2 -1
  40. package/dist/index.d.ts +5 -2
  41. package/dist/index.js +5 -2
  42. package/dist/mcp/server.js +173 -285
  43. package/dist/memory.d.ts +19 -0
  44. package/dist/memory.js +38 -0
  45. package/dist/policies.js +2 -1
  46. package/dist/predictions.js +2 -1
  47. package/dist/processes.js +2 -1
  48. package/dist/project-briefs.js +3 -1
  49. package/dist/prompt-recall.js +1 -1
  50. package/dist/recall-history.d.ts +5 -0
  51. package/dist/recall-history.js +9 -0
  52. package/dist/recall-pipeline.d.ts +101 -0
  53. package/dist/recall-pipeline.js +313 -0
  54. package/dist/recall-scope.d.ts +22 -0
  55. package/dist/recall-scope.js +27 -1
  56. package/dist/recall-trace.d.ts +69 -0
  57. package/dist/recall-trace.js +136 -0
  58. package/dist/search.d.ts +0 -20
  59. package/dist/search.js +2 -49
  60. package/dist/server.js +1901 -2384
  61. package/dist/skills.js +2 -1
  62. package/dist/store-cards.d.ts +53 -0
  63. package/dist/store-cards.js +512 -0
  64. package/dist/store.d.ts +2 -89
  65. package/dist/store.js +6 -562
  66. package/dist/tenant.d.ts +22 -0
  67. package/dist/tenant.js +26 -0
  68. package/dist/token-ledger.d.ts +2 -0
  69. package/dist/token-ledger.js +5 -0
  70. package/dist/tokenize.d.ts +2 -0
  71. package/dist/tokenize.js +8 -0
  72. package/dist/version.d.ts +1 -1
  73. package/dist/version.js +1 -1
  74. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  75. package/extensions/openclaw-plugin/package.json +1 -1
  76. package/openclaw.plugin.json +1 -1
  77. package/package.json +2 -1
@@ -10,24 +10,23 @@
10
10
  import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import { createMemory, Layer, calculateStrength, } from '../memory.js';
13
- import { hybridSearch, physicsSearch, estimateTokens } from '../search.js';
13
+ import { fitBudget, estimateTokens } from '../search.js';
14
14
  import { evalNow } from '../ablation.js';
15
- import { loadAllEntries, writeEntry, strengthenRetrieved, readEntry, loadFreshActiveTaskSnapshot, listMemoryConflicts, resolveConflict, countCreatedSinceLastSleep } from '../store.js';
15
+ import { loadAllEntries, writeEntry, readEntry, listMemoryConflicts, resolveConflict, countCreatedSinceLastSleep } from '../store.js';
16
16
  import { shareMemory, listPeers, getGlobalRoot, initGlobal } from '../shared.js';
17
17
  import { consolidate } from '../consolidate.js';
18
- import { execSync } from 'child_process';
19
18
  import { fetchGitLog, extractLessons, partitionLessons, isGitRepo } from '../autolearn.js';
20
19
  import { dropHeldCopies, duplicateKey, storedTextKeys } from '../same-text.js';
21
20
  import { loadConfig } from '../config.js';
22
21
  import { confidenceLabel } from '../memory.js';
23
22
  import { resolveTenantId } from '../tenant.js';
24
- import { recall as apiRecall, remember as apiRemember, outcome as apiOutcome, drillDown as apiDrillDown, assemble as apiAssemble, passesScopeFilterForRecall, buildSuppressionSummary, ambientSecretAdmit } from '../api.js';
25
- import { assertScopeRequestAllowed } from '../recall-scope.js';
26
- import { resolveProjectIdentity, classifyOriginProject, findHippoStoreDir } from '../project-identity.js';
23
+ import { retrieve as apiRetrieve, remember as apiRemember, outcome as apiOutcome, drillDown as apiDrillDown, assemble as apiAssemble, getContext as apiGetContext, buildSuppressionSummary } from '../api.js';
24
+ import { autoDetectContext } from '../context-auto.js';
25
+ import { resolveProjectIdentity, findHippoStoreDir } from '../project-identity.js';
27
26
  import { computePredictionBaserate } from '../predictions.js';
28
27
  import { appendAuditEvent, auditQueryFields } from '../audit.js';
29
28
  import { RejectedValueError } from '../rejection.js';
30
- import { detectAnchoring, hashQueryText, buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, } from '../recall-history.js';
29
+ import { detectAnchoring, hashQueryText, biasHintEnabled, buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, } from '../recall-history.js';
31
30
  import { detectAvailabilityBias } from '../availability.js';
32
31
  // v0.33 / J1 — Module-level per-(tenant, session) recall-history ring map
33
32
  // for the MCP pipeline. Separate from CLI/HTTP rings per plan v3
@@ -37,7 +36,6 @@ const sessionRecallHistoryMcp = new Map();
37
36
  export function __resetSessionRecallHistoryMcp() {
38
37
  sessionRecallHistoryMcp.clear();
39
38
  }
40
- import { applyGoalStackBoost } from '../goals.js';
41
39
  import { openHippoDb, closeHippoDb } from '../db.js';
42
40
  import { recordTokenUse } from '../token-ledger.js';
43
41
  import { PACKAGE_VERSION } from '../version.js';
@@ -78,6 +76,30 @@ function isJsonObjectRecord(v) {
78
76
  }
79
77
  import { formatHandoffEvidenceLine } from '../handoff.js';
80
78
  import { assembleCost, assembleText, drillCost, drillText, printedTokens } from '../context-render.js';
79
+ function handoffLines(h) {
80
+ const lines = [`- Summary: ${h.summary}`];
81
+ if (h.nextAction)
82
+ lines.push(`- Next action: ${h.nextAction}`);
83
+ if ((h.artifacts ?? []).length > 0)
84
+ lines.push(`- Artifacts: ${(h.artifacts ?? []).join(', ')}`);
85
+ if (h.outcome)
86
+ lines.push(`- Outcome: ${h.outcome}`);
87
+ if (h.targetRuntime)
88
+ lines.push(`- Target runtime: ${h.targetRuntime}`);
89
+ if (h.cardId)
90
+ lines.push(`- Card: ${h.cardId}`);
91
+ if ((h.constraints ?? []).length > 0)
92
+ lines.push(`- Constraints: ${(h.constraints ?? []).join(', ')}`);
93
+ if (h.evidence)
94
+ lines.push(`- Evidence: ${formatHandoffEvidenceLine(h.evidence)}`);
95
+ return lines;
96
+ }
97
+ function trailLines(events) {
98
+ return events.map((e) => {
99
+ const preview = e.content.length > 200 ? e.content.slice(0, 200) + '…' : e.content;
100
+ return `- [${e.event_type}] ${preview}`;
101
+ });
102
+ }
81
103
  function formatContinuityBlock(block) {
82
104
  const lines = ['## Continuity'];
83
105
  if (block.activeSnapshot) {
@@ -90,36 +112,12 @@ function formatContinuityBlock(block) {
90
112
  if (block.sessionHandoff) {
91
113
  lines.push('');
92
114
  lines.push('### Session Handoff');
93
- lines.push(`- Summary: ${block.sessionHandoff.summary}`);
94
- if (block.sessionHandoff.nextAction) {
95
- lines.push(`- Next action: ${block.sessionHandoff.nextAction}`);
96
- }
97
- if ((block.sessionHandoff.artifacts ?? []).length > 0) {
98
- lines.push(`- Artifacts: ${(block.sessionHandoff.artifacts ?? []).join(', ')}`);
99
- }
100
- if (block.sessionHandoff.outcome) {
101
- lines.push(`- Outcome: ${block.sessionHandoff.outcome}`);
102
- }
103
- if (block.sessionHandoff.targetRuntime) {
104
- lines.push(`- Target runtime: ${block.sessionHandoff.targetRuntime}`);
105
- }
106
- if (block.sessionHandoff.cardId) {
107
- lines.push(`- Card: ${block.sessionHandoff.cardId}`);
108
- }
109
- if ((block.sessionHandoff.constraints ?? []).length > 0) {
110
- lines.push(`- Constraints: ${(block.sessionHandoff.constraints ?? []).join(', ')}`);
111
- }
112
- if (block.sessionHandoff.evidence) {
113
- lines.push(`- Evidence: ${formatHandoffEvidenceLine(block.sessionHandoff.evidence)}`);
114
- }
115
+ lines.push(...handoffLines(block.sessionHandoff));
115
116
  }
116
117
  if (block.recentSessionEvents.length > 0) {
117
118
  lines.push('');
118
119
  lines.push('### Recent Session Trail');
119
- for (const e of block.recentSessionEvents) {
120
- const preview = e.content.length > 200 ? e.content.slice(0, 200) + '…' : e.content;
121
- lines.push(`- [${e.event_type}] ${preview}`);
122
- }
120
+ lines.push(...trailLines(block.recentSessionEvents));
123
121
  }
124
122
  if (lines.length === 1) {
125
123
  lines.push('');
@@ -147,6 +145,36 @@ const memoryCost = (r) => printedTokens(formatMemory(r));
147
145
  function memoriesReserve(budget) {
148
146
  return Math.max(printedTokens(memoriesHeading(budget)), estimateTokens(NO_MEMORIES));
149
147
  }
148
+ function snapshotPiece(s) {
149
+ return [
150
+ '## Active Task Snapshot',
151
+ `- Task: ${s.task}`,
152
+ `- Status: ${s.status}`,
153
+ `- Updated: ${s.updated_at}`,
154
+ '',
155
+ '### Summary',
156
+ s.summary,
157
+ '',
158
+ '### Next step',
159
+ s.next_step,
160
+ '',
161
+ '',
162
+ ].join('\n');
163
+ }
164
+ function handoffPiece(h) {
165
+ return ['## Session Handoff', ...handoffLines(h), '', ''].join('\n');
166
+ }
167
+ function trailPiece(events) {
168
+ return ['## Recent Session Trail', ...trailLines(events), '', ''].join('\n');
169
+ }
170
+ // Sections print ahead of the memories in hippo_context, so getContext pays for each as printed before any memory.
171
+ const contextCost = {
172
+ entry: memoryCost,
173
+ fixed: (budget) => memoriesReserve(budget),
174
+ snapshot: (s) => estimateTokens(snapshotPiece(s)),
175
+ handoff: (h) => estimateTokens(handoffPiece(h)),
176
+ trail: (events) => estimateTokens(trailPiece(events)),
177
+ };
150
178
  // Rows the ranked list already shows drop out of this section, so pricing every row bounds what it prints.
151
179
  function tailSection(rows) {
152
180
  if (rows.length === 0)
@@ -164,7 +192,7 @@ function tailSection(rows) {
164
192
  }
165
193
  return '\n' + lines.join('\n');
166
194
  }
167
- // J3.2: the hint depends on the query alone, so api.recall's copy is the one shown; JSON.stringify fences the phrase.
195
+ // J3.2: the hint depends on the query alone, so api.retrieve's copy is the one shown; JSON.stringify fences the phrase.
168
196
  function planningSection(r) {
169
197
  if (r.planningFallacyHint) {
170
198
  const h = r.planningFallacyHint;
@@ -208,12 +236,12 @@ const TOOLS = [
208
236
  },
209
237
  scorer_window: {
210
238
  type: 'number',
211
- description: 'Candidate pool size that api.recall evaluates. Affects fresh-tail / summarize-overflow appendix paths and continuity hits. Note: the primary ranked block over MCP is driven by a separate physics/hybrid scorer over the full tenant store, so scorer_window does NOT narrow the main results — only the appendix. Default 200. Rejected as RecallContractError code=invalid_scorer_window if 0/negative/non-finite/non-numeric.',
239
+ description: 'How many of the top-ranked memories the fresh-tail and summarize-overflow appendix is worked out against. The main list ranks the whole tenant store, so scorer_window does not narrow it. Default 200. Rejected as RecallContractError code=invalid_scorer_window if 0/negative/non-finite/non-numeric.',
212
240
  },
213
241
  session_id: {
214
242
  type: 'string',
215
243
  maxLength: 256,
216
- description: 'Optional session id (v1.7.4). When set AND (tenant, session) has active goals, applies the dlPFC goal-stack boost to the primary physics/hybrid result band before formatting AND to api.recall\'s primary BM25 band (so the audit + appendix paths see the same session). Mirrors fresh_tail_session_id shape (256-char cap).',
244
+ description: 'Optional session id (v1.7.4). When set AND (tenant, session) has active goals, applies the dlPFC goal-stack boost to the ranked memories before formatting. Mirrors fresh_tail_session_id shape (256-char cap).',
217
245
  },
218
246
  },
219
247
  required: ['query'],
@@ -310,14 +338,14 @@ const TOOLS = [
310
338
  },
311
339
  {
312
340
  name: 'hippo_context',
313
- description: 'Smart context injection: auto-detects current task from git state and returns relevant memories plus the active task snapshot. Use at the start of any session. Memories and snapshot are scope-filtered: a no-scope caller does NOT see ANY <source>:private:* (slack, github, ...) or legacy-quarantine rows.',
341
+ description: 'Smart context injection: auto-detects current task from git state and returns relevant memories plus the active task snapshot, session handoff and recent session trail (the same bundle as GET /v1/context). Use at the start of any session. Memories and those sections are scope-filtered: a no-scope caller does NOT see ANY <source>:private:* (slack, github, ...) or legacy-quarantine rows.',
314
342
  inputSchema: {
315
343
  type: 'object',
316
344
  properties: {
317
345
  budget: { type: 'number', minimum: 0, description: 'Max tokens (default: config.defaultContextBudget, 3000)' },
318
346
  scope: {
319
347
  type: 'string',
320
- description: 'Restrict memories and snapshot to this scope exactly. When omitted, default-deny applies to ANY <source>:private:* (slack, github, ...) and unknown-legacy rows.',
348
+ description: 'Restrict memories, snapshot, handoff and trail to this scope exactly. When omitted, default-deny applies to ANY <source>:private:* (slack, github, ...) and unknown-legacy rows.',
321
349
  },
322
350
  },
323
351
  },
@@ -495,7 +523,7 @@ async function executeTool(name, args, ctx) {
495
523
  ? args.summarize_overflow
496
524
  : undefined;
497
525
  // v1.7.2 T4 — scorer_window: Number-coerce so non-numeric input
498
- // (string 'abc', boolean, etc.) reaches api.recall() and produces
526
+ // (string 'abc', boolean, etc.) reaches api.retrieve() and produces
499
527
  // the same typed RecallContractError(code='invalid_scorer_window')
500
528
  // as HTTP. Codex CRITICAL[2]: do NOT use `typeof === 'number'` — that
501
529
  // would silently default-200 on string `"5"` while HTTP 400s on the
@@ -503,13 +531,7 @@ async function executeTool(name, args, ctx) {
503
531
  const scorerWindow = args.scorer_window === undefined
504
532
  ? undefined
505
533
  : Number(args.scorer_window);
506
- // v1.7.4 -- session_id for the dlPFC goal-stack boost. Mirrors
507
- // fresh_tail_session_id shape: trim, 256-char cap. When set and the
508
- // (tenant, session) has active goals, the boost is applied (a) inside
509
- // api.recall on its primary BM25 band (so the audit + fresh-tail /
510
- // summary appendix paths see consistent ranking), and (b) below on the
511
- // physics/hybrid result list before formatMemories (since MCP's
512
- // user-visible primary ordering does NOT come from api.recall).
534
+ // session_id drives the goal-stack boost inside api.retrieve; same trim and 256-char cap as fresh_tail_session_id.
513
535
  const sessionIdRaw = isJsonString(args.session_id) ? args.session_id.trim() : '';
514
536
  const sessionId = sessionIdRaw.length > 0 && sessionIdRaw.length <= 256
515
537
  ? sessionIdRaw
@@ -519,11 +541,6 @@ async function executeTool(name, args, ctx) {
519
541
  tenantId,
520
542
  actor: mcpActor(ctx),
521
543
  };
522
- // Route through api.recall for audit + (when requested) continuity block.
523
- // api.recall already applies the same default-deny / exact-match rules
524
- // we want here, so its continuity output is the source of truth.
525
- // RecallContractError throws propagate raw to the MCP caller (per the
526
- // v1.6.5 F5 contract documented in mcp-recall-fresh-tail-policy.test.ts).
527
544
  const recallExtra = {};
528
545
  if (freshTailCount !== undefined)
529
546
  recallExtra.freshTailCount = freshTailCount;
@@ -535,161 +552,101 @@ async function executeTool(name, args, ctx) {
535
552
  recallExtra.scorerWindow = scorerWindow;
536
553
  if (sessionId !== undefined)
537
554
  recallExtra.sessionId = sessionId;
538
- const apiResult = apiRecall(apiCtx, {
555
+ const anchorRing = biasHintEnabled('anchoring') && sessionId
556
+ ? getOrCreateRing(sessionRecallHistoryMcp, buildSessionKey(tenantId, sessionId))
557
+ : null;
558
+ const queryHash = hashQueryText(query);
559
+ const out = {};
560
+ // RecallContractError throws reach the MCP caller raw, as mcp-recall-fresh-tail-policy.test.ts pins.
561
+ await apiRetrieve(apiCtx, {
539
562
  query,
540
563
  limit: 50,
541
564
  scope: explicitScope,
542
565
  includeContinuity,
543
- // v1.13.x / J2 — MCP computes its OWN availability hint over the
544
- // physics/hybrid result set below; suppress api.recall's BM25-band copy
545
- // so one MCP recall does not emit recall_availability_detected twice.
566
+ mode: config.physics?.enabled !== false ? 'physics' : 'hybrid',
567
+ // The hint is computed below over the list MCP shows; the window band's copy would emit its audit row twice.
546
568
  suppressAvailabilityHint: true,
547
- // LC1 F2 fix — MCP's user-visible primary ordering comes from the
548
- // physics/hybrid scorer below, NOT this api.recall call's BM25 band
549
- // (see the comment above apiRecall). Tracing this call as pipeline
550
- // 'api' would mislabel training data with ids/ranks/scores the user
551
- // never actually saw. Real MCP tracing is the reserved 'mcp'
552
- // pipeline value (schema v40) — a follow-up, not v1 scope.
553
- suppressRecallTrace: true,
554
569
  keepHeldCopies: true,
555
570
  ...recallExtra,
571
+ showRanked: ({ ranked, pool, droppedByScope }, apiResult) => {
572
+ // Sections are paid in print order, ahead of the memories and after the heading; one that does not fit is dropped whole.
573
+ let left = budget - memoriesReserve(budget);
574
+ const pays = (piece) => {
575
+ const tokens = estimateTokens(piece);
576
+ if (tokens > left)
577
+ return false;
578
+ left -= tokens;
579
+ return true;
580
+ };
581
+ const planPiece = planningSection(apiResult);
582
+ const showPlan = planPiece !== '' && pays(planPiece);
583
+ const tailRows = apiResult.results.filter((r) => r.isFreshTail || r.isSummary);
584
+ const showTail = tailRows.length > 0 && pays(tailSection(tailRows));
585
+ const continuityPiece = includeContinuity && apiResult.continuity ? `\n\n${formatContinuityBlock(apiResult.continuity)}` : '';
586
+ const showContinuity = continuityPiece !== '' && pays(continuityPiece);
587
+ // J1, J2 and C5: the hints and Cutoff block describe the list MCP shows, not the window band in apiResult.
588
+ const render = (cut) => {
589
+ const list = dropHeldCopies(cut, (r) => r.entry); // after every cut, so a merged row cut here never hides its sources
590
+ const anchoring = anchorRing ? detectAnchoring(snapshotRing(anchorRing), queryHash, list[0]?.entry.id ?? null) : null;
591
+ const availability = biasHintEnabled('availability')
592
+ ? detectAvailabilityBias({
593
+ topK: list.map((r) => ({ id: r.entry.id, created: r.entry.created })),
594
+ pool: pool.map((e) => ({ id: e.id, created: e.created })),
595
+ })
596
+ : null;
597
+ const shownIds = new Set(list.map((r) => r.entry.id));
598
+ const shownKeys = storedTextKeys(list.map((r) => r.entry));
599
+ const tail = showTail
600
+ ? dropHeldCopies(tailRows.filter((r) => !shownIds.has(r.id) && !shownKeys.has(duplicateKey(r.content))), (r) => r)
601
+ : [];
602
+ const s = buildSuppressionSummary({
603
+ totalCandidates: pool.length + droppedByScope,
604
+ droppedPreRank: droppedByScope + cut.length - list.length, // the bucket CLI and API recall put hidden copies in
605
+ droppedByBudget: Math.max(0, pool.length - cut.length), // an upper bound: rows that never matched count too
606
+ summarySubstitutionsAdded: tail.filter((r) => r.isSummary).length,
607
+ freshTailAdded: tail.filter((r) => r.isFreshTail && !r.isSummary).length,
608
+ suppressedByInterference: anchoring?.reason === 'memory_dominance' ? 1 : 0,
609
+ });
610
+ // Anchoring is the stronger pull, so it prints first; the Cutoff block sits above the list, where the agent reads it.
611
+ let text = anchoring ? `## Anchoring hint\n${anchoring.summary}\n[anchored_on: ${anchoring.memoryId}]\n\n---\n\n` : '';
612
+ if (availability)
613
+ text += `## Availability bias\n${availability.summary}\n\n---\n\n`;
614
+ if (showPlan)
615
+ text += planPiece;
616
+ const cutoffClauses = [];
617
+ if (s.droppedByBudget > 0)
618
+ cutoffClauses.push(`${s.droppedByBudget} dropped to fit limit`);
619
+ if (s.droppedPreRank > 0)
620
+ cutoffClauses.push(`${s.droppedPreRank} filtered pre-rank`);
621
+ if (s.summarySubstitutionsAdded > 0)
622
+ cutoffClauses.push(`${s.summarySubstitutionsAdded} summary substitutions added`);
623
+ if (s.freshTailAdded > 0)
624
+ cutoffClauses.push(`${s.freshTailAdded} fresh-tail added`);
625
+ if (s.suppressedByInterference > 0)
626
+ cutoffClauses.push(`${s.suppressedByInterference} suppressed by interference`);
627
+ if (cutoffClauses.length > 0) {
628
+ text += `## Cutoff\nShowing ${list.length} of ${s.totalCandidates} candidates; ${cutoffClauses.join('; ')}.\n\n---\n\n`;
629
+ }
630
+ // The window band's fresh-tail and summary rows follow the ranked list, or the MCP fields go unanswered.
631
+ text += formatMemories(list) + tailSection(tail) + (showContinuity ? continuityPiece : '');
632
+ return { anchoring, availability, text, list };
633
+ };
634
+ let results = fitBudget(ranked, Math.max(0, left), 1, memoryCost);
635
+ let rendered = render(results);
636
+ // The hints, Cutoff block and heading vary with the list, so the lowest-ranked entry goes until the whole response fits.
637
+ while (results.length > 1 && estimateTokens(rendered.text) > budget) {
638
+ results = results.slice(0, -1);
639
+ rendered = render(results);
640
+ }
641
+ out.rendered = rendered;
642
+ return rendered.list.map((r) => r.entry.id);
643
+ },
556
644
  });
557
- // Existing physics/hybrid scorer continues to drive user-visible
558
- // ordering and the strength bump on retrieval. Apply the same scope
559
- // rule as api.recall: explicit scope = exact match; no scope =
560
- // default-deny on ANY `<source>:private:*` AND 'unknown:legacy'.
561
- // EI2: one shared predicate (passesScopeFilterForRecall) so MCP never admits what SQL hides.
562
- const allEntries = loadAllEntries(hippoRoot, tenantId);
563
- // v1.12.13 / C5 — WYSIATI counters for the MCP physics/hybrid pipeline.
564
- // Per the plan-eng-critic round 1 CRIT resolution: MCP's user-visible
565
- // memory list comes from THIS pipeline (loadAllEntries -> scope filter
566
- // -> physicsSearch/hybridSearch), NOT from apiResult. The MCP
567
- // suppressionSummary must describe what the user actually sees, so we
568
- // track filter activity here and replace apiResult.suppressionSummary
569
- // in the user-facing response.
570
- const totalCandidatesCountMcp = allEntries.length;
571
- const entries = explicitScope
572
- ? allEntries.filter((e) => e.scope === explicitScope)
573
- : allEntries.filter((e) => passesScopeFilterForRecall(e.scope ?? null, undefined));
574
- const droppedPreRankCountMcp = allEntries.length - entries.length;
575
- // Sections are paid in print order, ahead of the memories and after the heading; one that does not fit is dropped whole.
576
- let left = budget - memoriesReserve(budget);
577
- const pays = (piece) => {
578
- const tokens = estimateTokens(piece);
579
- if (tokens > left)
580
- return false;
581
- left -= tokens;
582
- return true;
583
- };
584
- const planPiece = planningSection(apiResult);
585
- const showPlan = planPiece !== '' && pays(planPiece);
586
- const tailRows = apiResult.results.filter((r) => r.isFreshTail || r.isSummary);
587
- const showTail = tailRows.length > 0 && pays(tailSection(tailRows));
588
- const continuityPiece = includeContinuity && apiResult.continuity ? `\n\n${formatContinuityBlock(apiResult.continuity)}` : '';
589
- const showContinuity = continuityPiece !== '' && pays(continuityPiece);
590
- const usePhysics = config.physics?.enabled !== false;
591
- const fit = { budget: Math.max(0, left), cost: memoryCost, hippoRoot };
592
- let results = usePhysics
593
- ? await physicsSearch(query, entries, { ...fit, physicsConfig: config.physics })
594
- : await hybridSearch(query, entries, fit);
595
- // v1.12.13 / C5 — droppedByBudget for MCP is an UPPER BOUND. The
596
- // difference (entries.length - results.length) lumps three things
597
- // together: rows hybridSearch/physicsSearch internally dropped because
598
- // they scored zero (didn't match the query at all), rows the search
599
- // engine filtered internally (e.g. superseded when --include-
600
- // superseded isn't set), and rows that genuinely didn't fit the
601
- // `budget` token cap. The honest fix needs hybridSearch/physicsSearch
602
- // to expose their pre-budget-cut scored-count. Until then this is an
603
- // upper bound that conflates "not relevant" with "no budget" on
604
- // no-match / sparse-match queries. Plan-eng-critic round 1 MED and
605
- // codex-review-critic P2 both flagged this; documented + tracked as
606
- // a v1.12.14 follow-up. Independent-review-critic and code-review-
607
- // critic both graded as non-blocking for v1.12.13 ship.
608
- // TODO(c5.1): expose scoredCount from hybridSearch/physicsSearch and
609
- // compute droppedByBudget = scoredCount - results.length, with the
610
- // remainder (entries.length - scoredCount) attributed to
611
- // droppedPreRank or a new "noQueryMatch" counter.
612
- const droppedByBudgetFor = (shown) => Math.max(0, entries.length - shown);
613
- // v1.7.4 -- dlPFC goal-stack boost on the MCP physics/hybrid result
614
- // list BEFORE formatMemories. MCP's user-visible primary ordering does
615
- // NOT come from api.recall (apiResult above), so the boost has to run
616
- // here too. Helper signature accepts any { entry, score } shape; the
617
- // physics/hybrid result rows are already in that shape.
618
- if (sessionId !== undefined) {
619
- const dbForBoost = openHippoDb(hippoRoot);
620
- try {
621
- results = applyGoalStackBoost(dbForBoost, results, {
622
- sessionId,
623
- tenantId,
624
- limit: results.length,
625
- });
626
- }
627
- finally {
628
- closeHippoDb(dbForBoost);
629
- }
630
- }
631
- // 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.
632
- const anchorRing = process.env.HIPPO_ANCHORING !== 'off' && sessionId
633
- ? getOrCreateRing(sessionRecallHistoryMcp, buildSessionKey(tenantId, sessionId))
634
- : null;
635
- const queryHash = hashQueryText(query);
636
- const render = (cut) => {
637
- const list = dropHeldCopies(cut, (r) => r.entry); // after every cut, so a merged row cut here never hides its sources
638
- const anchoring = anchorRing ? detectAnchoring(snapshotRing(anchorRing), queryHash, list[0]?.entry.id ?? null) : null;
639
- const availability = process.env.HIPPO_AVAILABILITY !== 'off'
640
- ? detectAvailabilityBias({
641
- topK: list.map((r) => ({ id: r.entry.id, created: r.entry.created })),
642
- pool: entries.map((e) => ({ id: e.id, created: e.created })),
643
- })
644
- : null;
645
- const shownIds = new Set(list.map((r) => r.entry.id));
646
- const shownKeys = storedTextKeys(list.map((r) => r.entry));
647
- const tail = showTail
648
- ? dropHeldCopies(tailRows.filter((r) => !shownIds.has(r.id) && !shownKeys.has(duplicateKey(r.content))), (r) => r)
649
- : [];
650
- const s = buildSuppressionSummary({
651
- totalCandidates: totalCandidatesCountMcp,
652
- droppedPreRank: droppedPreRankCountMcp + cut.length - list.length, // the bucket CLI and API recall put hidden copies in
653
- droppedByBudget: droppedByBudgetFor(cut.length),
654
- summarySubstitutionsAdded: tail.filter((r) => r.isSummary).length,
655
- freshTailAdded: tail.filter((r) => r.isFreshTail && !r.isSummary).length,
656
- suppressedByInterference: anchoring?.reason === 'memory_dominance' ? 1 : 0,
657
- });
658
- // Anchoring is the stronger pull, so it prints first; the Cutoff block sits above the list, where the agent reads it.
659
- let text = anchoring ? `## Anchoring hint\n${anchoring.summary}\n[anchored_on: ${anchoring.memoryId}]\n\n---\n\n` : '';
660
- if (availability)
661
- text += `## Availability bias\n${availability.summary}\n\n---\n\n`;
662
- if (showPlan)
663
- text += planPiece;
664
- const cutoffClauses = [];
665
- if (s.droppedByBudget > 0)
666
- cutoffClauses.push(`${s.droppedByBudget} dropped to fit limit`);
667
- if (s.droppedPreRank > 0)
668
- cutoffClauses.push(`${s.droppedPreRank} filtered pre-rank`);
669
- if (s.summarySubstitutionsAdded > 0)
670
- cutoffClauses.push(`${s.summarySubstitutionsAdded} summary substitutions added`);
671
- if (s.freshTailAdded > 0)
672
- cutoffClauses.push(`${s.freshTailAdded} fresh-tail added`);
673
- if (s.suppressedByInterference > 0)
674
- cutoffClauses.push(`${s.suppressedByInterference} suppressed by interference`);
675
- if (cutoffClauses.length > 0) {
676
- text += `## Cutoff\nShowing ${list.length} of ${s.totalCandidates} candidates; ${cutoffClauses.join('; ')}.\n\n---\n\n`;
677
- }
678
- // v1.6.3: the fresh-tail and summary rows api.recall produced follow the ranked list, or the MCP fields go unanswered.
679
- text += formatMemories(list) + tailSection(tail) + (showContinuity ? continuityPiece : '');
680
- return { anchoring, availability, text, list };
681
- };
682
- let rendered = render(results);
683
- // The hints, Cutoff block and heading vary with the list, so the lowest-ranked entry goes until the whole response fits.
684
- while (results.length > 1 && estimateTokens(rendered.text) > budget) {
685
- results = results.slice(0, -1);
686
- rendered = render(results);
687
- }
688
- const { anchoring: mcpAnchoringHint, availability: mcpAvailabilityHint, list: shown } = rendered;
689
- const retrievedIds = shown.map((r) => r.entry.id);
690
- strengthenRetrieved(hippoRoot, retrievedIds);
691
- lastRecalledIds.set(resolveClientKey(ctx), retrievedIds);
692
- if (process.env.HIPPO_ANCHORING !== 'off') {
645
+ if (!out.rendered)
646
+ throw new Error('hippo_recall: api.retrieve returned without calling showRanked');
647
+ const { anchoring: mcpAnchoringHint, availability: mcpAvailabilityHint, list: shown, text: recallText } = out.rendered;
648
+ lastRecalledIds.set(resolveClientKey(ctx), shown.map((r) => r.entry.id));
649
+ if (biasHintEnabled('anchoring')) {
693
650
  if (anchorRing) {
694
651
  // Appended after the final detect: anchoredOn feeds the cooldown for the next recall on this session.
695
652
  appendRecall(anchorRing, queryHash, shown[0]?.entry.id ?? null, mcpAnchoringHint?.memoryId);
@@ -766,7 +723,7 @@ async function executeTool(name, args, ctx) {
766
723
  closeHippoDb(dbForAudit);
767
724
  }
768
725
  }
769
- return rendered.text;
726
+ return recallText;
770
727
  }
771
728
  case 'hippo_assemble': {
772
729
  const sessionId = String(args.session_id || '');
@@ -933,94 +890,25 @@ async function executeTool(name, args, ctx) {
933
890
  return 'budget must be a non-negative number.';
934
891
  if (budget === 0)
935
892
  return '';
936
- const explicitScope = isJsonString(args.scope) && args.scope.length > 0
893
+ if (budget < memoriesReserve(budget))
894
+ return ''; // not even the heading fits, so nothing prints, as at budget 0
895
+ const exactScope = isJsonString(args.scope) && args.scope.length > 0
937
896
  ? args.scope
938
897
  : undefined;
939
- // Auto-detect query from git
940
- let query = '';
941
- try {
942
- const branch = execSync('git rev-parse --abbrev-ref HEAD 2>/dev/null', { encoding: 'utf-8', windowsHide: true }).trim();
943
- const diff = execSync('git diff --cached --stat 2>/dev/null', { encoding: 'utf-8', windowsHide: true }).trim();
944
- const log = execSync('git log -1 --pretty=format:"%s" 2>/dev/null', { encoding: 'utf-8', windowsHide: true }).trim();
945
- query = [branch, log, diff].filter(Boolean).join(' ');
946
- }
947
- catch { /* not a git repo */ }
948
- if (!query)
949
- query = 'project context general';
950
- // v1.2 codex audit: same scope filter as hippo_recall on BOTH the memory
951
- // results and the snapshot. Pre-v1.2 this surface returned all memories
952
- // and the snapshot unfiltered, which would have leaked private-channel
953
- // content to no-scope MCP callers once scope writers shipped.
954
- assertScopeRequestAllowed(mcpActor(ctx), explicitScope);
955
- const allEntries = loadAllEntries(hippoRoot, tenantId);
956
- // v39 memory scope isolation: this surface reads the LOCAL store only,
957
- // but synced-down or legacy rows can still carry another project's
958
- // origin, and secrets must never ambient-inject outside their owner.
959
- // Same policy as api.getContext; the scope filter above keeps this
960
- // surface's own explicit-scope exact-match semantics.
961
- //
962
- // Identity resolution handles both transports (codex rounds 4+5):
963
- // - The SERVED store is authoritative when it is a project store -
964
- // an HTTP /mcp daemon launched from anywhere still isolates the
965
- // project it serves.
966
- // - When the served store is the global root (stdio in a git repo
967
- // with no local .hippo falls back to it), dirname(store) is home
968
- // ('' would admit everything), so fall back to the launch cwd -
969
- // stdio servers launch in the project they serve.
970
- const mcpStoreIdentity = resolveProjectIdentity(path.dirname(path.resolve(hippoRoot)));
971
- const mcpProjectName = mcpStoreIdentity.name !== ''
972
- ? mcpStoreIdentity.name
973
- : resolveProjectIdentity(process.cwd()).name;
974
- const isolationOff = config.contextProjectIsolation === false;
975
- const entries = allEntries.filter((e) => {
976
- if (!passesScopeFilterForRecall(e.scope ?? null, explicitScope))
977
- return false;
978
- if (!ambientSecretAdmit(e, mcpProjectName))
979
- return false;
980
- if (isolationOff)
981
- return true;
982
- return classifyOriginProject(e.origin_project, mcpProjectName) !== 'cross-project';
898
+ // The served store names the project (an HTTP daemon runs from anywhere); the global root names none, so stdio falls back to its launch cwd.
899
+ const storeProject = resolveProjectIdentity(path.dirname(path.resolve(hippoRoot))).name;
900
+ const result = await apiGetContext({ hippoRoot, tenantId, actor: mcpActor(ctx) }, {
901
+ q: autoDetectContext(),
902
+ budget,
903
+ exactScope,
904
+ currentProject: storeProject !== '' ? storeProject : resolveProjectIdentity(process.cwd()).name,
905
+ cost: contextCost,
983
906
  });
984
- // DF1 (docs/plans/2026-08-23-df1-snapshot-lifecycle.md, T2): bounded
985
- // read, no session id available on this surface (freshness bound
986
- // only) — an orphaned snapshot must age out here too, not just on the
987
- // UserPromptSubmit path.
988
- const rawSnapshot = loadFreshActiveTaskSnapshot(hippoRoot, tenantId);
989
- const snapshot = rawSnapshot && passesScopeFilterForRecall(rawSnapshot.scope, explicitScope)
990
- ? rawSnapshot
991
- : null;
992
- const snapshotText = snapshot
993
- ? [
994
- '## Active Task Snapshot',
995
- `- Task: ${snapshot.task}`,
996
- `- Status: ${snapshot.status}`,
997
- `- Updated: ${snapshot.updated_at}`,
998
- '',
999
- '### Summary',
1000
- snapshot.summary,
1001
- '',
1002
- '### Next step',
1003
- snapshot.next_step,
1004
- '',
1005
- ].join('\n')
1006
- : '';
1007
- // The snapshot prints first, so it is paid first after the heading; context keeps no hit past the budget, even the top one.
1008
- let left = budget - memoriesReserve(budget);
1009
- if (left < 0)
1010
- return ''; // not even the heading fits, so nothing prints, as at budget 0
1011
- const snapshotPiece = snapshotText ? `${snapshotText}\n` : '';
1012
- const showSnapshot = snapshotPiece !== '' && estimateTokens(snapshotPiece) <= left;
1013
- if (showSnapshot)
1014
- left -= estimateTokens(snapshotPiece);
1015
- const usePhysicsCtx = config.physics?.enabled !== false;
1016
- const fit = { budget: left, minResults: 0, cost: memoryCost, hippoRoot };
1017
- const results = dropHeldCopies(usePhysicsCtx
1018
- ? await physicsSearch(query, entries, { ...fit, physicsConfig: config.physics })
1019
- : await hybridSearch(query, entries, fit), (r) => r.entry);
1020
- const retrievedIds = results.map((r) => r.entry.id);
1021
- strengthenRetrieved(hippoRoot, retrievedIds);
1022
- lastRecalledIds.set(resolveClientKey(ctx), retrievedIds);
1023
- return (showSnapshot ? snapshotPiece : '') + formatMemories(results);
907
+ lastRecalledIds.set(resolveClientKey(ctx), result.entries.map((r) => r.entry.id));
908
+ return (result.activeSnapshot ? snapshotPiece(result.activeSnapshot) : '')
909
+ + (result.sessionHandoff ? handoffPiece(result.sessionHandoff) : '')
910
+ + (result.recentEvents ? trailPiece(result.recentEvents) : '')
911
+ + formatMemories(result.entries);
1024
912
  }
1025
913
  case 'hippo_status': {
1026
914
  const entries = loadAllEntries(hippoRoot, tenantId);
package/dist/memory.d.ts CHANGED
@@ -267,4 +267,23 @@ export declare function createSuccessor(old: MemoryEntry, content: string, opts:
267
267
  * Rare shared tags signal stronger schema fit than common ones.
268
268
  */
269
269
  export declare function computeSchemaFit(content: string, tags: string[], existingEntries: MemoryEntry[]): number;
270
+ /**
271
+ * Update retrieval metadata on entries that were returned by a search.
272
+ * Returns the mutated copies (caller must persist to disk).
273
+ *
274
+ * EVAL-ONLY ablation (see ablation.ts): with HIPPO_ABLATE_RECALL_BOOST set,
275
+ * this returns the entries UNMUTATED - neutralizing all three strengthening
276
+ * sub-effects (clock reset, retrieval_count, half-life increment) at the
277
+ * single shared write site. The entries (not an empty array) must be
278
+ * returned because callers derive `last_retrieval_ids` from the return
279
+ * value, and a later `hippo outcome --good/--bad` targets those ids - an
280
+ * empty return would silently co-ablate the outcome channel in the
281
+ * strengthen-off arm. PERSISTENCE is gated separately at
282
+ * each persisting caller (CLI recall, api context, MCP recall/context,
283
+ * consolidation replay): writeEntry on identical rows still refreshes
284
+ * updated_at, rewrites mirrors, and marks DAG parents dirty,
285
+ * so those write loops skip under the flag.
286
+ * The default `now` honors HIPPO_FAKE_NOW (simulated-time protocols).
287
+ */
288
+ export declare function markRetrieved(entries: MemoryEntry[], now?: Date): MemoryEntry[];
270
289
  //# sourceMappingURL=memory.d.ts.map