hippo-memory 1.52.8 → 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 (74) hide show
  1. package/README.md +158 -98
  2. package/dist/api.d.ts +51 -18
  3. package/dist/api.js +121 -76
  4. package/dist/audit.d.ts +2 -1
  5. package/dist/audit.js +63 -0
  6. package/dist/capture.d.ts +37 -0
  7. package/dist/capture.js +111 -81
  8. package/dist/cli.js +627 -654
  9. package/dist/codex-patch.d.ts +12 -0
  10. package/dist/codex-patch.js +71 -0
  11. package/dist/config.d.ts +0 -1
  12. package/dist/config.js +0 -4
  13. package/dist/connectors/slack/types.d.ts +0 -1
  14. package/dist/consolidate.js +85 -32
  15. package/dist/context-render.d.ts +36 -0
  16. package/dist/context-render.js +154 -0
  17. package/dist/dag.js +3 -2
  18. package/dist/db.js +6 -6
  19. package/dist/dedupe.d.ts +6 -6
  20. package/dist/dedupe.js +10 -9
  21. package/dist/doctor.d.ts +1 -1
  22. package/dist/doctor.js +35 -2
  23. package/dist/dormant.d.ts +4 -0
  24. package/dist/dormant.js +17 -2
  25. package/dist/embedding-provider.d.ts +2 -1
  26. package/dist/embedding-provider.js +2 -1
  27. package/dist/embeddings.js +23 -3
  28. package/dist/extract.js +5 -1
  29. package/dist/forward-claim-detector.d.ts +1 -1
  30. package/dist/forward-claim-detector.js +1 -1
  31. package/dist/graph-recall.d.ts +3 -1
  32. package/dist/graph-recall.js +5 -3
  33. package/dist/hooks.d.ts +15 -1
  34. package/dist/hooks.js +122 -27
  35. package/dist/importers.js +5 -12
  36. package/dist/judgment.d.ts +30 -0
  37. package/dist/judgment.js +122 -0
  38. package/dist/mcp/server.js +171 -210
  39. package/dist/merged-row.d.ts +6 -0
  40. package/dist/merged-row.js +35 -0
  41. package/dist/multihop.d.ts +2 -1
  42. package/dist/multihop.js +7 -4
  43. package/dist/physics-state.d.ts +0 -4
  44. package/dist/physics-state.js +0 -6
  45. package/dist/predictions.d.ts +2 -17
  46. package/dist/predictions.js +2 -15
  47. package/dist/reject-flow.d.ts +7 -5
  48. package/dist/reject-flow.js +41 -12
  49. package/dist/salience.js +12 -5
  50. package/dist/same-text.d.ts +17 -0
  51. package/dist/same-text.js +38 -0
  52. package/dist/scheduler.d.ts +4 -0
  53. package/dist/scheduler.js +8 -0
  54. package/dist/search.d.ts +7 -0
  55. package/dist/search.js +16 -32
  56. package/dist/secret-detect.d.ts +2 -0
  57. package/dist/secret-detect.js +6 -0
  58. package/dist/server-detect.js +9 -33
  59. package/dist/server.js +6 -62
  60. package/dist/session-digest.d.ts +79 -0
  61. package/dist/session-digest.js +528 -0
  62. package/dist/shared.d.ts +10 -2
  63. package/dist/shared.js +35 -30
  64. package/dist/store.d.ts +1 -0
  65. package/dist/store.js +4 -0
  66. package/dist/token-ledger.d.ts +46 -8
  67. package/dist/token-ledger.js +140 -21
  68. package/dist/version.d.ts +1 -1
  69. package/dist/version.js +1 -1
  70. package/extensions/openclaw-plugin/README.md +4 -4
  71. package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
  72. package/extensions/openclaw-plugin/package.json +1 -1
  73. package/openclaw.plugin.json +2 -2
  74. package/package.json +2 -2
@@ -0,0 +1,30 @@
1
+ /** Typed judgment over capture candidates via TypeSafe's Jev (System One).
2
+ * Regex picks WHAT is a candidate; it cannot say what is worth keeping, so
3
+ * every captured memory currently lands on a flat schema_fit of 0.5. */
4
+ import { ConfidenceLevel, EmotionalValence } from './memory.js';
5
+ export type JudgedKind = 'error' | 'decision' | 'convention' | 'preference' | 'trivia';
6
+ export interface Judgment {
7
+ /** Jev noul, 0..1. Maps to `schema_fit` on the written entry. */
8
+ durable: number;
9
+ kind: JudgedKind;
10
+ valence: EmotionalValence;
11
+ confidence: ConfidenceLevel;
12
+ /** Jev's calibrated confidence on the kind choice, 0..1. */
13
+ kindConfidence: number;
14
+ }
15
+ export interface JudgeOptions {
16
+ apiKey: string;
17
+ model?: string;
18
+ /** Injected for testing — defaults to the real fetch. */
19
+ fetcher?: typeof fetch;
20
+ /** Bounded parallelism for `judgeAll`. */
21
+ concurrency?: number;
22
+ }
23
+ /** Absent key means hippo keeps its pre-Jev behaviour and makes no HTTP call. */
24
+ export declare function judgmentApiKey(): string | undefined;
25
+ /** `null` on any failure, so a Jev outage degrades capture to today's
26
+ * behaviour instead of blocking the write. */
27
+ export declare function judge(content: string, opts: JudgeOptions): Promise<Judgment | null>;
28
+ /** Judge many candidates under a bounded concurrency pool, order preserved. */
29
+ export declare function judgeAll(contents: readonly string[], opts: JudgeOptions): Promise<(Judgment | null)[]>;
30
+ //# sourceMappingURL=judgment.d.ts.map
@@ -0,0 +1,122 @@
1
+ /** Typed judgment over capture candidates via TypeSafe's Jev (System One).
2
+ * Regex picks WHAT is a candidate; it cannot say what is worth keeping, so
3
+ * every captured memory currently lands on a flat schema_fit of 0.5. */
4
+ const ENDPOINT = 'https://api.typesafe.ai/v1/systemone';
5
+ const DEFAULT_MODEL = 'jev-1.13.0';
6
+ const MAX_CONCURRENCY = 8;
7
+ const RETRY_STATUS = new Set([429, 529]);
8
+ const QUESTIONS = {
9
+ durable: {
10
+ type: 'noul',
11
+ instructions: 'This text was extracted from an AI coding agent transcript as a candidate memory. It is worth storing long-term only if it would still be useful to a future agent working on this codebase weeks from now: a durable preference, a convention, a decision with a reason, or a gotcha that will recur. Transient chatter, one-off status, restatements of code already in the repo, and anything only true inside this one session are not worth storing.',
12
+ },
13
+ kind: {
14
+ type: 'choice',
15
+ instructions: 'Classify what kind of durable knowledge this is.',
16
+ criteria: {
17
+ error: 'A failure, gotcha, or thing that went wrong, and why.',
18
+ decision: 'A choice that was made, ideally with its reason.',
19
+ convention: 'A rule, standard, or way this project does things.',
20
+ preference: 'A stated preference of the user or team.',
21
+ trivia: 'None of the above; incidental detail with no reuse value.',
22
+ },
23
+ },
24
+ valence: {
25
+ type: 'choice',
26
+ instructions: 'Classify the emotional charge of this memory for replay priority.',
27
+ criteria: {
28
+ critical: 'A costly failure or a rule whose violation causes real damage.',
29
+ negative: 'Something that went wrong, or a warning.',
30
+ positive: 'Something that worked, or a confirmed good approach.',
31
+ neutral: 'Plain fact with no success or failure charge.',
32
+ },
33
+ },
34
+ };
35
+ const KINDS = ['error', 'decision', 'convention', 'preference', 'trivia'];
36
+ const VALENCES = ['critical', 'negative', 'positive', 'neutral'];
37
+ /** Absent key means hippo keeps its pre-Jev behaviour and makes no HTTP call. */
38
+ export function judgmentApiKey() {
39
+ const key = process.env.TYPESAFE_API_KEY?.trim();
40
+ return key ? key : undefined;
41
+ }
42
+ function oneOf(value, allowed) {
43
+ return allowed.find((option) => option === value) ?? null;
44
+ }
45
+ /** `verified` is unreachable: that tier means a human or a test confirmed it. */
46
+ function toConfidenceTier(kindConfidence) {
47
+ if (kindConfidence >= 0.8)
48
+ return 'observed';
49
+ return 'inferred';
50
+ }
51
+ async function postOnce(content, opts) {
52
+ const fetchFn = opts.fetcher ?? fetch;
53
+ let res;
54
+ try {
55
+ res = await fetchFn(ENDPOINT, {
56
+ method: 'POST',
57
+ headers: {
58
+ 'content-type': 'application/json',
59
+ authorization: `Bearer ${opts.apiKey}`,
60
+ },
61
+ body: JSON.stringify({
62
+ state: content,
63
+ model: opts.model ?? DEFAULT_MODEL,
64
+ questions: QUESTIONS,
65
+ }),
66
+ });
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ if (RETRY_STATUS.has(res.status))
72
+ return { retryable: true };
73
+ if (!res.ok)
74
+ return null;
75
+ return { res };
76
+ }
77
+ /** `null` on any failure, so a Jev outage degrades capture to today's
78
+ * behaviour instead of blocking the write. */
79
+ export async function judge(content, opts) {
80
+ const trimmed = content.trim();
81
+ if (trimmed.length < 3)
82
+ return null;
83
+ let attempt = await postOnce(trimmed, opts);
84
+ if (attempt && 'retryable' in attempt) {
85
+ await new Promise((resolve) => setTimeout(resolve, 500));
86
+ attempt = await postOnce(trimmed, opts);
87
+ }
88
+ if (!attempt || 'retryable' in attempt)
89
+ return null;
90
+ let data;
91
+ try {
92
+ // SAFETY: the documented Jev response is `{ answers: { <name>: Answer } }`
93
+ // keyed by the question names posted above; every field read below is
94
+ // optional-chained and range-checked before use, so a lie here returns null.
95
+ data = await attempt.res.json();
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ const durable = data.answers?.durable?.noul;
101
+ const kind = oneOf(data.answers?.kind?.choice, KINDS);
102
+ const valence = oneOf(data.answers?.valence?.choice, VALENCES);
103
+ if (durable === undefined || durable < 0 || durable > 1 || !kind || !valence)
104
+ return null;
105
+ const kindConfidence = data.answers?.kind?.confidence ?? 0;
106
+ return { durable, kind, valence, confidence: toConfidenceTier(kindConfidence), kindConfidence };
107
+ }
108
+ /** Judge many candidates under a bounded concurrency pool, order preserved. */
109
+ export async function judgeAll(contents, opts) {
110
+ const out = Array.from({ length: contents.length }, () => null);
111
+ const limit = Math.max(1, opts.concurrency ?? MAX_CONCURRENCY);
112
+ let cursor = 0;
113
+ const worker = async () => {
114
+ while (cursor < contents.length) {
115
+ const i = cursor++;
116
+ out[i] = await judge(contents[i], opts);
117
+ }
118
+ };
119
+ await Promise.all(Array.from({ length: Math.min(limit, contents.length) }, worker));
120
+ return out;
121
+ }
122
+ //# sourceMappingURL=judgment.js.map
@@ -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
@@ -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