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.
- package/README.md +11 -0
- package/dist/api.d.ts +19 -9
- package/dist/api.js +112 -35
- package/dist/card-detail.d.ts +1 -1
- package/dist/card-detail.js +1 -1
- package/dist/cli/shared.d.ts +137 -0
- package/dist/cli/shared.js +830 -0
- package/dist/cli/sleep.d.ts +10 -0
- package/dist/cli/sleep.js +171 -0
- package/dist/cli.d.ts +0 -7
- package/dist/cli.js +313 -1806
- package/dist/config.d.ts +5 -0
- package/dist/config.js +21 -0
- package/dist/connectors/github/webhook.d.ts +19 -0
- package/dist/connectors/github/webhook.js +313 -0
- package/dist/connectors/slack/webhook.d.ts +22 -0
- package/dist/connectors/slack/webhook.js +203 -0
- package/dist/consolidate.js +3 -2
- package/dist/context-auto.d.ts +3 -0
- package/dist/context-auto.js +34 -0
- package/dist/customer-notes.js +2 -1
- package/dist/dashboard.js +2 -1
- package/dist/db.js +67 -1
- package/dist/decisions.js +2 -1
- package/dist/delivery-recorder.d.ts +127 -0
- package/dist/delivery-recorder.js +218 -0
- package/dist/eval-stats.d.ts +58 -0
- package/dist/eval-stats.js +111 -0
- package/dist/goals.d.ts +49 -25
- package/dist/goals.js +39 -22
- package/dist/graph-extract.js +1 -1
- package/dist/graph-recall.d.ts +1 -1
- package/dist/graph-recall.js +1 -1
- package/dist/graph.js +1 -1
- package/dist/hooks.d.ts +1 -3
- package/dist/hooks.js +2 -4
- package/dist/http-util.d.ts +31 -0
- package/dist/http-util.js +46 -0
- package/dist/incidents.js +2 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -2
- package/dist/mcp/server.js +173 -285
- package/dist/memory.d.ts +19 -0
- package/dist/memory.js +38 -0
- package/dist/policies.js +2 -1
- package/dist/predictions.js +2 -1
- package/dist/processes.js +2 -1
- package/dist/project-briefs.js +3 -1
- package/dist/prompt-recall.js +1 -1
- package/dist/recall-history.d.ts +5 -0
- package/dist/recall-history.js +9 -0
- package/dist/recall-pipeline.d.ts +101 -0
- package/dist/recall-pipeline.js +313 -0
- package/dist/recall-scope.d.ts +22 -0
- package/dist/recall-scope.js +27 -1
- package/dist/recall-trace.d.ts +69 -0
- package/dist/recall-trace.js +136 -0
- package/dist/search.d.ts +0 -20
- package/dist/search.js +2 -49
- package/dist/server.js +1901 -2384
- package/dist/skills.js +2 -1
- package/dist/store-cards.d.ts +53 -0
- package/dist/store-cards.js +512 -0
- package/dist/store.d.ts +2 -89
- package/dist/store.js +6 -562
- package/dist/tenant.d.ts +22 -0
- package/dist/tenant.js +26 -0
- package/dist/token-ledger.d.ts +2 -0
- package/dist/token-ledger.js +5 -0
- package/dist/tokenize.d.ts +2 -0
- package/dist/tokenize.js +8 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
package/dist/mcp/server.js
CHANGED
|
@@ -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 {
|
|
13
|
+
import { fitBudget, estimateTokens } from '../search.js';
|
|
14
14
|
import { evalNow } from '../ablation.js';
|
|
15
|
-
import { loadAllEntries, writeEntry,
|
|
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 {
|
|
25
|
-
import {
|
|
26
|
-
import { resolveProjectIdentity,
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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: '
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
544
|
-
//
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|