hippo-memory 1.64.0 → 1.65.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 +1 -1
- package/dist/agent-memories/sync.js +3 -2
- package/dist/api/recall-record.d.ts +46 -0
- package/dist/api/recall-record.js +98 -0
- package/dist/api/recall-request.d.ts +32 -0
- package/dist/api/recall-request.js +121 -0
- package/dist/api/recall-types.d.ts +7 -1
- package/dist/api/recall.d.ts +3 -2
- package/dist/api/recall.js +174 -181
- package/dist/api/tokens.d.ts +1 -1
- package/dist/api/tokens.js +13 -17
- package/dist/cli/maintenance.d.ts +2 -1
- package/dist/cli/maintenance.js +17 -11
- package/dist/cli/recall.d.ts +1 -1
- package/dist/cli/recall.js +18 -49
- package/dist/cli/shared.js +2 -2
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +18 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +4 -0
- package/dist/db/connect.d.ts +5 -0
- package/dist/db/connect.js +52 -0
- package/dist/db/open.d.ts +9 -11
- package/dist/db/open.js +23 -116
- package/dist/db/request-stores.d.ts +30 -0
- package/dist/db/request-stores.js +100 -0
- package/dist/db/sqlite-blocked.d.ts +6 -0
- package/dist/db/sqlite-blocked.js +11 -0
- package/dist/db.d.ts +3 -1
- package/dist/db.js +3 -1
- package/dist/embedding-provider.js +4 -3
- package/dist/embeddings.js +2 -1
- package/dist/goals.d.ts +18 -11
- package/dist/goals.js +38 -36
- package/dist/http-util.d.ts +2 -0
- package/dist/http-util.js +5 -0
- package/dist/local-embedding.d.ts +2 -0
- package/dist/local-embedding.js +20 -4
- package/dist/mcp/memory-tools.js +4 -1
- package/dist/mcp/recall-tools.js +28 -133
- package/dist/mcp/request.d.ts +7 -0
- package/dist/mcp/request.js +63 -41
- package/dist/mcp/session-state.d.ts +1 -3
- package/dist/mcp/session-state.js +3 -5
- package/dist/predictions/planning-fallacy.d.ts +52 -0
- package/dist/predictions/planning-fallacy.js +70 -55
- package/dist/recall-trace.d.ts +1 -1
- package/dist/recall-trace.js +5 -4
- package/dist/search/physics-search.js +4 -1
- package/dist/search/vector.js +2 -0
- package/dist/server/routes/recall.d.ts +1 -1
- package/dist/server/routes/recall.js +30 -218
- package/dist/server.d.ts +12 -1
- package/dist/server.js +33 -22
- package/dist/store/entry-writes.d.ts +2 -0
- package/dist/store/entry-writes.js +24 -16
- package/dist/store-port.d.ts +70 -1
- package/dist/store-port.js +87 -9
- 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 +1 -1
package/dist/local-embedding.js
CHANGED
|
@@ -10,6 +10,7 @@ let _embeddingAvailable = null;
|
|
|
10
10
|
// A pipeline is expensive to load, so one instance per model is kept for the process.
|
|
11
11
|
const _pipelineInstances = new Map();
|
|
12
12
|
const _pipelineLoading = new Map();
|
|
13
|
+
const _pipelineErrors = new Map();
|
|
13
14
|
export const DEFAULT_EMBEDDING_MODEL = 'Xenova/all-MiniLM-L6-v2';
|
|
14
15
|
/** BGE was trained with CLS pooling; everything else gets mean, the sentence-transformers default, since CLS degrades mean-trained models. */
|
|
15
16
|
export function poolingFor(model) {
|
|
@@ -79,8 +80,10 @@ async function loadPipeline(model) {
|
|
|
79
80
|
const loading = (async () => {
|
|
80
81
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
81
82
|
const pkg = resolveTransformersPackage();
|
|
82
|
-
if (!pkg)
|
|
83
|
+
if (!pkg) {
|
|
84
|
+
_pipelineErrors.set(model, 'no transformers package is installed');
|
|
83
85
|
return null;
|
|
86
|
+
}
|
|
84
87
|
let pipelineFn = null;
|
|
85
88
|
try {
|
|
86
89
|
// SAFETY: the resolved module's shape is untyped by design (optional peer
|
|
@@ -98,11 +101,16 @@ async function loadPipeline(model) {
|
|
|
98
101
|
pipelineFn = mod.pipeline ?? mod.default?.pipeline;
|
|
99
102
|
}
|
|
100
103
|
catch (err) {
|
|
101
|
-
|
|
104
|
+
// String(err) keeps a Node error's [ERR_...] code, which callers match on.
|
|
105
|
+
const reason = `transformers import failed (${pkg}): ${String(err)}`;
|
|
106
|
+
log.debug(reason);
|
|
107
|
+
_pipelineErrors.set(model, reason);
|
|
102
108
|
return null;
|
|
103
109
|
}
|
|
104
|
-
if (!pipelineFn)
|
|
110
|
+
if (!pipelineFn) {
|
|
111
|
+
_pipelineErrors.set(model, `${pkg} exports no pipeline function`);
|
|
105
112
|
return null;
|
|
113
|
+
}
|
|
106
114
|
// The offline bundle used in egress-blocked sandboxes ships only the FP32 model, so use whichever file is on disk.
|
|
107
115
|
const cacheRoot = envModelCache();
|
|
108
116
|
const quantized = !cacheRoot
|
|
@@ -113,7 +121,9 @@ async function loadPipeline(model) {
|
|
|
113
121
|
return instance;
|
|
114
122
|
}
|
|
115
123
|
catch (err) {
|
|
116
|
-
|
|
124
|
+
const reason = `embedding pipeline load failed (${model}): ${String(err)}`;
|
|
125
|
+
log.debug(reason);
|
|
126
|
+
_pipelineErrors.set(model, reason);
|
|
117
127
|
return null;
|
|
118
128
|
}
|
|
119
129
|
finally {
|
|
@@ -123,6 +133,12 @@ async function loadPipeline(model) {
|
|
|
123
133
|
_pipelineLoading.set(model, loading);
|
|
124
134
|
return loading;
|
|
125
135
|
}
|
|
136
|
+
/** Throws, with the reason, when the model's pipeline cannot load: a provider-level failure, not a per-item skip. */
|
|
137
|
+
export async function requireLocalPipeline(model) {
|
|
138
|
+
if (await loadPipeline(model))
|
|
139
|
+
return;
|
|
140
|
+
throw new Error(`local embedding model did not load: ${_pipelineErrors.get(model) ?? 'unknown reason'}`);
|
|
141
|
+
}
|
|
126
142
|
export function resolveEmbeddingModel(hippoRoot, explicitModel) {
|
|
127
143
|
const direct = explicitModel?.trim();
|
|
128
144
|
if (direct)
|
package/dist/mcp/memory-tools.js
CHANGED
|
@@ -3,6 +3,7 @@ import { log } from '../log.js';
|
|
|
3
3
|
import { readEntry } from '../store/entry-reads.js';
|
|
4
4
|
import { countCreatedSinceLastSleep } from '../store/index-and-stats.js';
|
|
5
5
|
import { consolidate } from '../consolidate/sleep.js';
|
|
6
|
+
import { outsideRequestStores, runWithRequestStores, scopedBusyWait } from '../db.js';
|
|
6
7
|
import { resolveTenantId } from '../tenant.js';
|
|
7
8
|
import { remember as apiRemember, outcome as apiOutcome, learn as apiLearn, MCP_LEARN } from '../api.js';
|
|
8
9
|
import { mcpActor } from './protocol.js';
|
|
@@ -43,7 +44,9 @@ export function runRememberTool({ args, ctx, hippoRoot, config, tenantId }) {
|
|
|
43
44
|
countCreatedSinceLastSleep(hippoRoot, tenantId) >= config.autoSleep.threshold) {
|
|
44
45
|
autoSleepInFlight.add(hippoRoot);
|
|
45
46
|
// Fire-and-forget (never block the response); an unhandled rejection would kill the server, so log it.
|
|
46
|
-
|
|
47
|
+
// Its own scope: the call's closes a handle the sleep still holds across its pauses, but the caller's lock wait carries over.
|
|
48
|
+
const busyWaitMs = scopedBusyWait();
|
|
49
|
+
outsideRequestStores(() => runWithRequestStores(() => consolidate(hippoRoot), { busyWaitMs }))
|
|
47
50
|
.catch((err) => {
|
|
48
51
|
log.error(`auto-sleep consolidate failed (tenant ${tenantId}): ${err instanceof Error ? err.message : String(err)}`);
|
|
49
52
|
})
|
package/dist/mcp/recall-tools.js
CHANGED
|
@@ -6,58 +6,18 @@ import { retrieve as apiRetrieve, drillDown as apiDrillDown, assemble as apiAsse
|
|
|
6
6
|
import { autoDetectContext } from '../context-auto.js';
|
|
7
7
|
import { resolveProjectIdentity } from '../project-identity.js';
|
|
8
8
|
import { isSharedStore } from '../config.js';
|
|
9
|
-
import {
|
|
10
|
-
import { detectAnchoring, hashQueryText, biasHintEnabled, buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, } from '../recall-history.js';
|
|
9
|
+
import { detectAnchoring, hashQueryText, biasHintEnabled, snapshotRing } from '../recall-history.js';
|
|
11
10
|
import { detectAvailabilityBias } from '../availability.js';
|
|
12
|
-
import { openHippoDb, closeHippoDb } from '../db.js';
|
|
13
11
|
import { estimateTokens } from '../token-ledger.js';
|
|
14
12
|
import { assembleCost, assembleText, drillCost, drillText } from '../context-render.js';
|
|
15
|
-
import { mcpActor
|
|
16
|
-
import {
|
|
13
|
+
import { mcpActor } from './protocol.js';
|
|
14
|
+
import { lastRecalledIds, resolveClientKey } from './session-state.js';
|
|
17
15
|
import { formatContinuityBlock, formatMemories, memoryCost, memoriesReserve, snapshotPiece, handoffPiece, trailPiece, contextCost, tailSection, planningSection, } from './format.js';
|
|
18
16
|
import { isJsonString } from '../json.js';
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const includeContinuity = Boolean(args.include_continuity);
|
|
24
|
-
const explicitScope = isJsonString(args.scope) && args.scope.length > 0
|
|
25
|
-
? args.scope
|
|
26
|
-
: undefined;
|
|
27
|
-
const freshTailCountArg = Number(args.fresh_tail_count);
|
|
28
|
-
const freshTailCount = Number.isFinite(freshTailCountArg) && freshTailCountArg > 0
|
|
29
|
-
? freshTailCountArg
|
|
30
|
-
: undefined;
|
|
31
|
-
const freshTailSessionId = isJsonString(args.fresh_tail_session_id) && args.fresh_tail_session_id.length > 0
|
|
32
|
-
? args.fresh_tail_session_id
|
|
33
|
-
: undefined;
|
|
34
|
-
const summarizeOverflow = isJsonBoolean(args.summarize_overflow)
|
|
35
|
-
? args.summarize_overflow
|
|
36
|
-
: undefined;
|
|
37
|
-
// Number-coerce, never typeof-check: "abc" must reach api.retrieve and fail as invalid_scorer_window, the same code HTTP returns.
|
|
38
|
-
const scorerWindow = args.scorer_window === undefined
|
|
39
|
-
? undefined
|
|
40
|
-
: Number(args.scorer_window);
|
|
41
|
-
// session_id drives the goal-stack boost inside api.retrieve; same trim and MAX_ID_LEN cap as fresh_tail_session_id.
|
|
42
|
-
const sessionIdRaw = isJsonString(args.session_id) ? args.session_id.trim() : '';
|
|
43
|
-
const sessionId = sessionIdRaw.length > 0 && sessionIdRaw.length <= MAX_ID_LEN
|
|
44
|
-
? sessionIdRaw
|
|
45
|
-
: undefined;
|
|
46
|
-
const recallExtra = {};
|
|
47
|
-
if (freshTailCount !== undefined)
|
|
48
|
-
recallExtra.freshTailCount = freshTailCount;
|
|
49
|
-
if (freshTailSessionId !== undefined)
|
|
50
|
-
recallExtra.freshTailSessionId = freshTailSessionId;
|
|
51
|
-
if (summarizeOverflow !== undefined)
|
|
52
|
-
recallExtra.summarizeOverflow = summarizeOverflow;
|
|
53
|
-
if (scorerWindow !== undefined)
|
|
54
|
-
recallExtra.scorerWindow = scorerWindow;
|
|
55
|
-
if (sessionId !== undefined)
|
|
56
|
-
recallExtra.sessionId = sessionId;
|
|
57
|
-
return { query, budget, includeContinuity, explicitScope, sessionId, recallExtra };
|
|
58
|
-
}
|
|
59
|
-
/** Builds the showRanked callback that renders the list MCP shows and parks the render in `out`. */
|
|
60
|
-
function recallPresenter(budget, includeContinuity, anchorRing, queryHash, out) {
|
|
17
|
+
import { parseContextRequest, parseRecallRequest, toolParams } from '../api/recall-request.js';
|
|
18
|
+
import { noteRecall, sessionRing, shownRecallRows } from '../api/recall-record.js';
|
|
19
|
+
/** Builds the showRanked callback that renders the list MCP shows, parks the render in `out` and hands back its hint rows. */
|
|
20
|
+
function recallPresenter(budget, includeContinuity, anchorRing, queryHash, out, hintRows) {
|
|
61
21
|
return ({ ranked, pool, droppedByScope }, apiResult) => {
|
|
62
22
|
// Sections are paid in print order, ahead of the memories and after the heading; one that does not fit is dropped whole.
|
|
63
23
|
let left = budget - memoriesReserve(budget);
|
|
@@ -129,105 +89,46 @@ function recallPresenter(budget, includeContinuity, anchorRing, queryHash, out)
|
|
|
129
89
|
rendered = render(results);
|
|
130
90
|
}
|
|
131
91
|
out.rendered = rendered;
|
|
132
|
-
return rendered.list.map((r) => r.entry.id);
|
|
92
|
+
return { ids: rendered.list.map((r) => r.entry.id), audit: hintRows(rendered) };
|
|
133
93
|
};
|
|
134
94
|
}
|
|
135
|
-
function appendRecallAudit(hippoRoot, event) {
|
|
136
|
-
const dbForAudit = openHippoDb(hippoRoot);
|
|
137
|
-
try {
|
|
138
|
-
appendAuditEvent(dbForAudit, event);
|
|
139
|
-
}
|
|
140
|
-
finally {
|
|
141
|
-
closeHippoDb(dbForAudit);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
function auditRecallHints(call, query, anchorRing, queryHash, rendered) {
|
|
145
|
-
const { hippoRoot, tenantId, ctx } = call;
|
|
146
|
-
const { anchoring: mcpAnchoringHint, availability: mcpAvailabilityHint, list: shown } = rendered;
|
|
147
|
-
if (biasHintEnabled('anchoring')) {
|
|
148
|
-
if (anchorRing) {
|
|
149
|
-
// Appended after the final detect: anchoredOn feeds the cooldown for the next recall on this session.
|
|
150
|
-
appendRecall(anchorRing, queryHash, shown[0]?.entry.id ?? null, mcpAnchoringHint?.memoryId);
|
|
151
|
-
if (mcpAnchoringHint?.reason === 'memory_dominance') {
|
|
152
|
-
appendRecallAudit(hippoRoot, {
|
|
153
|
-
tenantId,
|
|
154
|
-
actor: ctx?.actor ?? 'mcp',
|
|
155
|
-
op: 'recall_anchor_detected_memory_dominance',
|
|
156
|
-
targetId: mcpAnchoringHint.memoryId,
|
|
157
|
-
metadata: {
|
|
158
|
-
memory_id: mcpAnchoringHint.memoryId,
|
|
159
|
-
query_count: mcpAnchoringHint.queryCount ?? null,
|
|
160
|
-
},
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
else if (mcpAnchoringHint?.reason === 'query_repeat') {
|
|
164
|
-
appendRecallAudit(hippoRoot, {
|
|
165
|
-
tenantId,
|
|
166
|
-
actor: ctx?.actor ?? 'mcp',
|
|
167
|
-
op: 'recall_anchor_detected_query_repeat',
|
|
168
|
-
targetId: mcpAnchoringHint.memoryId,
|
|
169
|
-
metadata: { memory_id: mcpAnchoringHint.memoryId },
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
else {
|
|
174
|
-
// No sessionId, so no ring. Hash the prompt with SHA-256/16 as the recall audit does (api.ts:854):
|
|
175
|
-
// hashQueryText is FNV-1a 32-bit, trivial to brute-force on low-entropy queries.
|
|
176
|
-
appendRecallAudit(hippoRoot, {
|
|
177
|
-
tenantId,
|
|
178
|
-
actor: ctx?.actor ?? 'mcp',
|
|
179
|
-
op: 'recall_anchor_skipped_no_session',
|
|
180
|
-
targetId: undefined,
|
|
181
|
-
metadata: auditQueryFields(query),
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
if (mcpAvailabilityHint) {
|
|
186
|
-
appendRecallAudit(hippoRoot, {
|
|
187
|
-
tenantId,
|
|
188
|
-
actor: ctx?.actor ?? 'mcp',
|
|
189
|
-
op: 'recall_availability_detected',
|
|
190
|
-
metadata: {
|
|
191
|
-
recent_fraction: mcpAvailabilityHint.recentFraction,
|
|
192
|
-
older_passed_over: mcpAvailabilityHint.olderCandidatesPassedOver,
|
|
193
|
-
returned_count: mcpAvailabilityHint.returnedCount,
|
|
194
|
-
},
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
95
|
export async function runRecallTool(call) {
|
|
199
|
-
const { ctx, hippoRoot, config, tenantId } = call;
|
|
200
|
-
|
|
96
|
+
const { ctx, hippoRoot, config, tenantId, args } = call;
|
|
97
|
+
// MCP keeps its own band size and search mode, so limit, mode and explain are checked but not passed on.
|
|
98
|
+
const { opts: recallOpts } = parseRecallRequest(toolParams(args));
|
|
99
|
+
const { query, includeContinuity, sessionId } = recallOpts;
|
|
100
|
+
const budget = Number(args.budget) || config.defaultBudget;
|
|
201
101
|
const apiCtx = {
|
|
202
102
|
hippoRoot,
|
|
203
103
|
tenantId,
|
|
204
104
|
actor: mcpActor(ctx),
|
|
205
105
|
store: ctx?.store,
|
|
206
106
|
};
|
|
207
|
-
const anchorRing =
|
|
208
|
-
|
|
209
|
-
: null;
|
|
107
|
+
const anchorRing = sessionRing('mcp', tenantId, sessionId);
|
|
108
|
+
const who = { tenantId, actor: ctx?.actor ?? 'mcp' };
|
|
210
109
|
const queryHash = hashQueryText(query);
|
|
211
110
|
const out = {};
|
|
212
111
|
// RecallContractError throws reach the MCP caller raw, as mcp-recall-fresh-tail-policy.test.ts pins.
|
|
213
112
|
await apiRetrieve(apiCtx, {
|
|
214
|
-
|
|
113
|
+
...recallOpts,
|
|
215
114
|
limit: 50,
|
|
216
|
-
scope: explicitScope,
|
|
217
|
-
includeContinuity,
|
|
218
115
|
mode: config.physics?.enabled !== false ? 'physics' : 'hybrid',
|
|
219
116
|
// The hint is computed below over the list MCP shows; the window band's copy would emit its audit row twice.
|
|
220
117
|
suppressAvailabilityHint: true,
|
|
221
118
|
keepHeldCopies: true,
|
|
222
119
|
project: ctx?.project,
|
|
223
|
-
|
|
224
|
-
|
|
120
|
+
showRanked: recallPresenter(budget, includeContinuity, anchorRing, queryHash, out, ({ list, anchoring, availability }) => shownRecallRows(who, {
|
|
121
|
+
query, ring: anchorRing, topId: list[0]?.entry.id ?? null, anchoring, availability,
|
|
122
|
+
})),
|
|
225
123
|
});
|
|
226
|
-
|
|
124
|
+
const { rendered } = out;
|
|
125
|
+
if (!rendered)
|
|
227
126
|
throw new Error('hippo_recall: api.retrieve returned without calling showRanked');
|
|
228
|
-
lastRecalledIds.set(resolveClientKey(ctx),
|
|
229
|
-
|
|
230
|
-
|
|
127
|
+
lastRecalledIds.set(resolveClientKey(ctx), rendered.list.map((r) => r.entry.id));
|
|
128
|
+
// Fed once the recall's rows are written, after the final detect: anchoredOn feeds the cooldown for the next recall on this session.
|
|
129
|
+
if (anchorRing)
|
|
130
|
+
noteRecall(anchorRing, query, rendered.list[0]?.entry.id ?? null, rendered.anchoring?.memoryId);
|
|
131
|
+
return rendered.text;
|
|
231
132
|
}
|
|
232
133
|
export function runAssembleTool({ args, ctx, hippoRoot, tenantId }) {
|
|
233
134
|
const sessionId = String(args.session_id || '');
|
|
@@ -301,18 +202,12 @@ export async function runContextTool({ args, ctx, hippoRoot, config, tenantId })
|
|
|
301
202
|
// Stdio names no project, and the store's folder or the launch cwd is no caller's; HTTP sends X-Hippo-Project.
|
|
302
203
|
if (isSharedStore(hippoRoot) && ctx?.project === undefined)
|
|
303
204
|
return 'hippo_context needs a project on a shared store; use hippo_recall';
|
|
304
|
-
const budget = args
|
|
305
|
-
|
|
306
|
-
: Number(args.budget);
|
|
307
|
-
if (!Number.isFinite(budget) || budget < 0)
|
|
308
|
-
return 'budget must be a non-negative number.';
|
|
205
|
+
const { budget: budgetArg, scope: exactScope } = parseContextRequest(toolParams(args));
|
|
206
|
+
const budget = budgetArg ?? config.defaultContextBudget;
|
|
309
207
|
if (budget === 0)
|
|
310
208
|
return '';
|
|
311
209
|
if (budget < memoriesReserve(budget))
|
|
312
210
|
return ''; // not even the heading fits, so nothing prints, as at budget 0
|
|
313
|
-
const exactScope = isJsonString(args.scope) && args.scope.length > 0
|
|
314
|
-
? args.scope
|
|
315
|
-
: undefined;
|
|
316
211
|
const result = await apiGetContext({ hippoRoot, tenantId, actor: mcpActor(ctx), store: ctx?.store }, {
|
|
317
212
|
// The server's git state is no caller's, so a shared-store caller gets its project's rows without a query.
|
|
318
213
|
q: ctx?.project ? undefined : autoDetectContext(),
|
package/dist/mcp/request.d.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { type McpContext, type McpRequest, type McpResponse } from './protocol.js';
|
|
2
|
+
/**
|
|
3
|
+
* Record the memory text a recall or context tool returned. Best-effort: a
|
|
4
|
+
* ledger failure never fails the tool call. Other tools are not recorded.
|
|
5
|
+
*/
|
|
6
|
+
export declare function recordMcpTokens(toolName: string, output: string, ctx?: McpContext): Promise<void>;
|
|
7
|
+
/** The tools listed and run under a store other than hippo.db. */
|
|
8
|
+
export declare const STORE_READY_TOOLS: ReadonlySet<string>;
|
|
2
9
|
/**
|
|
3
10
|
* Transport-agnostic MCP dispatcher. Both the stdio loop (below) and the
|
|
4
11
|
* HTTP/SSE transport in src/server.ts route every incoming JSON-RPC message
|
package/dist/mcp/request.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
// Transport-agnostic request handling: tool dispatch table, tool execution and the JSON-RPC method switch.
|
|
2
2
|
import { log } from '../log.js';
|
|
3
|
+
import { STORE_NOT_PORTED_MESSAGE } from '../http-util.js';
|
|
3
4
|
import { getGlobalRoot, initGlobal } from '../shared.js';
|
|
4
5
|
import { loadConfig } from '../config.js';
|
|
5
6
|
import { resolveTenantId } from '../tenant.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
7
|
+
import { rethrowIfSqliteBlocked, runWithRequestStores } from '../db.js';
|
|
8
|
+
import { storeFor } from '../store-port.js';
|
|
9
|
+
import { estimateTokens } from '../token-ledger.js';
|
|
8
10
|
import { PACKAGE_VERSION } from '../version.js';
|
|
9
11
|
import { validateToolArgs } from './tool-args.js';
|
|
12
|
+
import { RecallRequestError } from '../api/recall-request.js';
|
|
10
13
|
import { findHippoRoot, isJsonObjectRecord } from './protocol.js';
|
|
11
14
|
import { TOOLS, TOOLS_BY_NAME, ARGS_CHECKED_BY_API } from './tools.js';
|
|
12
15
|
import { runRecallTool, runAssembleTool, runDrillTool, runContextTool } from './recall-tools.js';
|
|
@@ -35,7 +38,7 @@ const MCP_TOKEN_SURFACES = new Map([
|
|
|
35
38
|
* Record the memory text a recall or context tool returned. Best-effort: a
|
|
36
39
|
* ledger failure never fails the tool call. Other tools are not recorded.
|
|
37
40
|
*/
|
|
38
|
-
function recordMcpTokens(toolName, output, ctx) {
|
|
41
|
+
export async function recordMcpTokens(toolName, output, ctx) {
|
|
39
42
|
const surface = MCP_TOKEN_SURFACES.get(toolName);
|
|
40
43
|
if (!surface || !output)
|
|
41
44
|
return;
|
|
@@ -43,40 +46,41 @@ function recordMcpTokens(toolName, output, ctx) {
|
|
|
43
46
|
const hippoRoot = ctx?.hippoRoot ?? findHippoRoot();
|
|
44
47
|
if (!hippoRoot)
|
|
45
48
|
return;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
tokens: estimateTokens(output),
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
finally {
|
|
57
|
-
closeHippoDb(db);
|
|
58
|
-
}
|
|
49
|
+
await storeFor({ hippoRoot, store: ctx?.store }).recordTokens({
|
|
50
|
+
tenantId: ctx?.tenantId ?? resolveTenantId({}),
|
|
51
|
+
surface,
|
|
52
|
+
event: 'inject',
|
|
53
|
+
items: 0,
|
|
54
|
+
tokens: estimateTokens(output),
|
|
55
|
+
});
|
|
59
56
|
}
|
|
60
57
|
catch (err) {
|
|
58
|
+
rethrowIfSqliteBlocked(err);
|
|
61
59
|
log.warnThenDebug('mcp-token-ledger', `token ledger write failed; the tool reply is unaffected: ${err instanceof Error ? err.message : String(err)}`);
|
|
62
60
|
}
|
|
63
61
|
}
|
|
64
|
-
// ── Tool execution ──
|
|
65
62
|
const TOOL_HANDLERS = new Map([
|
|
66
|
-
['hippo_recall', runRecallTool],
|
|
67
|
-
['hippo_assemble', runAssembleTool],
|
|
68
|
-
['hippo_drill', runDrillTool],
|
|
69
|
-
['hippo_predict_baserate', runPredictBaserateTool],
|
|
70
|
-
['hippo_remember', runRememberTool],
|
|
71
|
-
['hippo_outcome', runOutcomeTool],
|
|
72
|
-
['hippo_context', runContextTool],
|
|
73
|
-
['hippo_status', runStatusTool],
|
|
74
|
-
['hippo_learn', runLearnTool],
|
|
75
|
-
['hippo_conflicts', runConflictsTool],
|
|
76
|
-
['hippo_resolve', runResolveTool],
|
|
77
|
-
['hippo_share', runShareTool],
|
|
78
|
-
['hippo_peers', runPeersTool],
|
|
63
|
+
['hippo_recall', { handler: runRecallTool, storeReady: true }],
|
|
64
|
+
['hippo_assemble', { handler: runAssembleTool }],
|
|
65
|
+
['hippo_drill', { handler: runDrillTool }],
|
|
66
|
+
['hippo_predict_baserate', { handler: runPredictBaserateTool }],
|
|
67
|
+
['hippo_remember', { handler: runRememberTool }],
|
|
68
|
+
['hippo_outcome', { handler: runOutcomeTool }],
|
|
69
|
+
['hippo_context', { handler: runContextTool }],
|
|
70
|
+
['hippo_status', { handler: runStatusTool }],
|
|
71
|
+
['hippo_learn', { handler: runLearnTool }],
|
|
72
|
+
['hippo_conflicts', { handler: runConflictsTool }],
|
|
73
|
+
['hippo_resolve', { handler: runResolveTool }],
|
|
74
|
+
['hippo_share', { handler: runShareTool }],
|
|
75
|
+
['hippo_peers', { handler: runPeersTool }],
|
|
79
76
|
]);
|
|
77
|
+
/** The tools listed and run under a store other than hippo.db. */
|
|
78
|
+
export const STORE_READY_TOOLS = new Set([...TOOL_HANDLERS].filter(([, entry]) => entry.storeReady).map(([name]) => name));
|
|
79
|
+
/** The served store's kind when it is not hippo.db, else null. */
|
|
80
|
+
function otherStoreKind(ctx) {
|
|
81
|
+
const kind = ctx?.store?.kind;
|
|
82
|
+
return kind !== undefined && kind !== 'sqlite' ? kind : null;
|
|
83
|
+
}
|
|
80
84
|
async function executeTool(name, args, ctx) {
|
|
81
85
|
// When a transport hands us a context (HTTP path), trust it: the HTTP
|
|
82
86
|
// server already resolved hippoRoot from its bound opts and tenantId
|
|
@@ -89,13 +93,21 @@ async function executeTool(name, args, ctx) {
|
|
|
89
93
|
// tenant-isolated. Resolved once per tool call: prefer the transport's
|
|
90
94
|
// ctx.tenantId so an HTTP Bearer for tenant B doesn't drop to HIPPO_TENANT.
|
|
91
95
|
const tenantId = ctx?.tenantId ?? resolveTenantId({});
|
|
92
|
-
const handler = TOOL_HANDLERS.get(name);
|
|
96
|
+
const handler = TOOL_HANDLERS.get(name)?.handler;
|
|
93
97
|
// handleMcpRequest rejects names missing from TOOLS, so reaching here means TOOLS and this table drifted apart.
|
|
94
98
|
if (!handler)
|
|
95
99
|
throw new Error(`hippo-mcp: tool ${name} is declared but has no handler`);
|
|
96
100
|
return handler({ args, ctx, hippoRoot, config, tenantId });
|
|
97
101
|
}
|
|
98
102
|
// ── Request handling ──
|
|
103
|
+
// The MCP spec reports input validation as a tool result with isError, so the model can read it and retry.
|
|
104
|
+
function invalidArgs(id, toolName, problems) {
|
|
105
|
+
return {
|
|
106
|
+
jsonrpc: '2.0',
|
|
107
|
+
id,
|
|
108
|
+
result: { content: [{ type: 'text', text: `Invalid arguments for ${toolName}: ${problems.join('; ')}` }], isError: true },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
99
111
|
/**
|
|
100
112
|
* Transport-agnostic MCP dispatcher. Both the stdio loop (below) and the
|
|
101
113
|
* HTTP/SSE transport in src/server.ts route every incoming JSON-RPC message
|
|
@@ -119,7 +131,7 @@ export async function handleMcpRequest(req, ctx) {
|
|
|
119
131
|
case 'notifications/initialized':
|
|
120
132
|
return null;
|
|
121
133
|
case 'tools/list':
|
|
122
|
-
return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
|
|
134
|
+
return { jsonrpc: '2.0', id, result: { tools: otherStoreKind(ctx) ? TOOLS.filter((t) => STORE_READY_TOOLS.has(t.name)) : TOOLS } };
|
|
123
135
|
case 'tools/call': {
|
|
124
136
|
const nameValue = params?.name;
|
|
125
137
|
const toolName = isJsonString(nameValue) ? nameValue : '';
|
|
@@ -127,6 +139,10 @@ export async function handleMcpRequest(req, ctx) {
|
|
|
127
139
|
if (!tool) {
|
|
128
140
|
return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown tool: ${toolName.slice(0, 128)}` } };
|
|
129
141
|
}
|
|
142
|
+
// The same refusal a ported tool gives when it reaches hippo.db, so a client handles one shape.
|
|
143
|
+
if (otherStoreKind(ctx) && !STORE_READY_TOOLS.has(toolName)) {
|
|
144
|
+
return { jsonrpc: '2.0', id, error: { code: -32603, message: STORE_NOT_PORTED_MESSAGE } };
|
|
145
|
+
}
|
|
130
146
|
const refusal = sharedStoreRefusal(toolName, ctx);
|
|
131
147
|
if (refusal !== undefined)
|
|
132
148
|
return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: refusal }], isError: true } };
|
|
@@ -135,17 +151,23 @@ export async function handleMcpRequest(req, ctx) {
|
|
|
135
151
|
return { jsonrpc: '2.0', id, error: { code: -32602, message: `${toolName}: arguments must be an object` } };
|
|
136
152
|
}
|
|
137
153
|
const toolArgs = isJsonObjectRecord(argumentsValue) ? argumentsValue : {};
|
|
138
|
-
// The MCP spec reports input validation as a tool result with isError, so the model can read it and retry.
|
|
139
154
|
const problems = validateToolArgs(tool.inputSchema, toolArgs, ARGS_CHECKED_BY_API.get(toolName));
|
|
140
|
-
if (problems.length > 0)
|
|
141
|
-
return
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
155
|
+
if (problems.length > 0)
|
|
156
|
+
return invalidArgs(id, toolName, problems);
|
|
157
|
+
let output;
|
|
158
|
+
try {
|
|
159
|
+
// One handle per store for the tool and its ledger row; stdio interleaves calls, so each gets its own scope.
|
|
160
|
+
output = await runWithRequestStores(async () => {
|
|
161
|
+
const text = await executeTool(toolName, toolArgs, ctx);
|
|
162
|
+
await recordMcpTokens(toolName, text, ctx);
|
|
163
|
+
return text;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
if (!(err instanceof RecallRequestError))
|
|
168
|
+
throw err;
|
|
169
|
+
return invalidArgs(id, toolName, [err.message]);
|
|
146
170
|
}
|
|
147
|
-
const output = await executeTool(toolName, toolArgs, ctx);
|
|
148
|
-
recordMcpTokens(toolName, output, ctx);
|
|
149
171
|
return {
|
|
150
172
|
jsonrpc: '2.0',
|
|
151
173
|
id,
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
export declare const sessionRecallHistoryMcp: Map<string, RingBuffer>;
|
|
3
|
-
/** Test-only: reset the module-level recall-history Map. Call from beforeEach. */
|
|
1
|
+
/** Test-only: reset the MCP recall rings. Call from beforeEach. */
|
|
4
2
|
export declare function __resetSessionRecallHistoryMcp(): void;
|
|
5
3
|
export declare const MAX_RECALL_CLIENTS = 4096;
|
|
6
4
|
export declare const lastRecalledIds: Map<string, string[]>;
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
// Per-process state the MCP tools share across calls: recall rings, last recalled ids, auto-sleep runs.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
export const sessionRecallHistoryMcp = new Map();
|
|
5
|
-
/** Test-only: reset the module-level recall-history Map. Call from beforeEach. */
|
|
2
|
+
import { resetSessionRings } from '../api/recall-record.js';
|
|
3
|
+
/** Test-only: reset the MCP recall rings. Call from beforeEach. */
|
|
6
4
|
export function __resetSessionRecallHistoryMcp() {
|
|
7
|
-
|
|
5
|
+
resetSessionRings('mcp');
|
|
8
6
|
}
|
|
9
7
|
// ── Track last recalled IDs for outcome feedback ──
|
|
10
8
|
//
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { type AppendAuditOpts } from '../audit.js';
|
|
2
|
+
import { type ForwardClaimMatch } from '../forward-claim-detector.js';
|
|
3
|
+
import { type PredictionBaserate } from './store.js';
|
|
1
4
|
/**
|
|
2
5
|
* Surface delivered on `RecallResult.planningFallacyHint` when an
|
|
3
6
|
* agent's recall query carries a forward-prediction phrase AND the closest
|
|
@@ -66,6 +69,49 @@ export interface ComputePlanningFallacyHintOpts {
|
|
|
66
69
|
* carry the right attribution instead of the 'cli' default. */
|
|
67
70
|
actor?: string;
|
|
68
71
|
}
|
|
72
|
+
export interface ClassResolution {
|
|
73
|
+
classTag: string | null;
|
|
74
|
+
/** True when ≥2 classes tied at the best overlap score AND best ≥ 1.
|
|
75
|
+
* Caller emits `recall_autodebias_hint_tiebreak` audit and returns
|
|
76
|
+
* null hint (silent — prevents the "show wrong class half the time"
|
|
77
|
+
* failure mode the alphabetical-tiebreak alternative would create). */
|
|
78
|
+
tiebreak: boolean;
|
|
79
|
+
}
|
|
80
|
+
/** The prediction class a forward claim resolves to, and that class's closed-prediction stats when one resolved. */
|
|
81
|
+
export interface PlanningFallacyEvidence extends ClassResolution {
|
|
82
|
+
readonly baserate: PredictionBaserate | null;
|
|
83
|
+
}
|
|
84
|
+
/** The output a claim earns from its evidence, and the audit row that records the decision when there is one. */
|
|
85
|
+
export interface PlanningFallacyDecision {
|
|
86
|
+
output: PlanningFallacyOutput;
|
|
87
|
+
audit?: AppendAuditOpts;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Resolve a query-token set to a unique best-matching class_tag for the
|
|
91
|
+
* tenant. Scores by lower-cased token overlap; requires best score ≥ 1
|
|
92
|
+
* AND strictly greater than the 2nd-best score.
|
|
93
|
+
*
|
|
94
|
+
* Indexed via idx_predictions_tenant_class (db.ts:1015) → O(log n) seek
|
|
95
|
+
* plus a small DISTINCT scan over the per-tenant class-tag set.
|
|
96
|
+
*
|
|
97
|
+
* Scope behaviour (deliberate): class_tag selection is TENANT-GLOBAL,
|
|
98
|
+
* NOT scope-filtered against
|
|
99
|
+
* the recall's opts.scope. The class_tag is an aggregator label across
|
|
100
|
+
* historical predictions in the class, not a per-memory scope-bound
|
|
101
|
+
* property. A no-scope recall CAN surface a class_tag from a privately-
|
|
102
|
+
* scoped prediction's class in PlanningFallacyHint.classTag — by design,
|
|
103
|
+
* because base-rate reasoning needs the full historical sample.
|
|
104
|
+
* Implications:
|
|
105
|
+
* - The hint payload itself carries no memory content (only the aggregate
|
|
106
|
+
* summary string + numeric stats), so memory bodies do not leak.
|
|
107
|
+
* - The class_tag NAME is the side-channel. If sensitive labels are a
|
|
108
|
+
* concern, callers should either use opaque class names (e.g. hashes
|
|
109
|
+
* or numeric tokens) or set HIPPO_AUTODEBIAS=off.
|
|
110
|
+
* - tests/api-recall-autodebias.test.ts locks this with an explicit
|
|
111
|
+
* test asserting that scope-set predictions surface via no-scope
|
|
112
|
+
* recalls (so future "fix" attempts that scope-filter trip CI).
|
|
113
|
+
*/
|
|
114
|
+
export declare function resolveClassFromTokens(hippoRoot: string, tenantId: string, queryTokens: readonly string[]): ClassResolution;
|
|
69
115
|
/**
|
|
70
116
|
* Planning-fallacy orchestrator.
|
|
71
117
|
*
|
|
@@ -92,4 +138,10 @@ export interface ComputePlanningFallacyHintOpts {
|
|
|
92
138
|
* Latency budget: well under 50ms; a miss pays only the regex.
|
|
93
139
|
*/
|
|
94
140
|
export declare function computePlanningFallacyOutput(hippoRoot: string, tenantId: string, queryText: string, opts?: ComputePlanningFallacyHintOpts): PlanningFallacyOutput;
|
|
141
|
+
/** The forward claim in a recall query; null when HIPPO_AUTODEBIAS=off, the query is empty or no claim matches. */
|
|
142
|
+
export declare function detectPlanningClaim(queryText: string, opts?: ComputePlanningFallacyHintOpts): ForwardClaimMatch | null;
|
|
143
|
+
/** Resolves a claim's tokens to one class and reads its baserate, writing no audit row. */
|
|
144
|
+
export declare function planningFallacyEvidenceAt(hippoRoot: string, tenantId: string, classQueryTokens: readonly string[]): PlanningFallacyEvidence;
|
|
145
|
+
/** The hint, the watching variant or nothing for a claim and its class evidence; reads and writes nothing itself. */
|
|
146
|
+
export declare function decidePlanningFallacy(match: ForwardClaimMatch, evidence: PlanningFallacyEvidence, tenantId: string, actor: string): PlanningFallacyDecision;
|
|
95
147
|
//# sourceMappingURL=planning-fallacy.d.ts.map
|