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.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-memories/sync.js +3 -2
  3. package/dist/api/recall-record.d.ts +46 -0
  4. package/dist/api/recall-record.js +98 -0
  5. package/dist/api/recall-request.d.ts +32 -0
  6. package/dist/api/recall-request.js +121 -0
  7. package/dist/api/recall-types.d.ts +7 -1
  8. package/dist/api/recall.d.ts +3 -2
  9. package/dist/api/recall.js +174 -181
  10. package/dist/api/tokens.d.ts +1 -1
  11. package/dist/api/tokens.js +13 -17
  12. package/dist/cli/maintenance.d.ts +2 -1
  13. package/dist/cli/maintenance.js +17 -11
  14. package/dist/cli/recall.d.ts +1 -1
  15. package/dist/cli/recall.js +18 -49
  16. package/dist/cli/shared.js +2 -2
  17. package/dist/cli.d.ts +16 -0
  18. package/dist/cli.js +18 -1
  19. package/dist/config.d.ts +2 -0
  20. package/dist/config.js +4 -0
  21. package/dist/db/connect.d.ts +5 -0
  22. package/dist/db/connect.js +52 -0
  23. package/dist/db/open.d.ts +9 -11
  24. package/dist/db/open.js +23 -116
  25. package/dist/db/request-stores.d.ts +30 -0
  26. package/dist/db/request-stores.js +100 -0
  27. package/dist/db/sqlite-blocked.d.ts +6 -0
  28. package/dist/db/sqlite-blocked.js +11 -0
  29. package/dist/db.d.ts +3 -1
  30. package/dist/db.js +3 -1
  31. package/dist/embedding-provider.js +4 -3
  32. package/dist/embeddings.js +2 -1
  33. package/dist/goals.d.ts +18 -11
  34. package/dist/goals.js +38 -36
  35. package/dist/http-util.d.ts +2 -0
  36. package/dist/http-util.js +5 -0
  37. package/dist/local-embedding.d.ts +2 -0
  38. package/dist/local-embedding.js +20 -4
  39. package/dist/mcp/memory-tools.js +4 -1
  40. package/dist/mcp/recall-tools.js +28 -133
  41. package/dist/mcp/request.d.ts +7 -0
  42. package/dist/mcp/request.js +63 -41
  43. package/dist/mcp/session-state.d.ts +1 -3
  44. package/dist/mcp/session-state.js +3 -5
  45. package/dist/predictions/planning-fallacy.d.ts +52 -0
  46. package/dist/predictions/planning-fallacy.js +70 -55
  47. package/dist/recall-trace.d.ts +1 -1
  48. package/dist/recall-trace.js +5 -4
  49. package/dist/search/physics-search.js +4 -1
  50. package/dist/search/vector.js +2 -0
  51. package/dist/server/routes/recall.d.ts +1 -1
  52. package/dist/server/routes/recall.js +30 -218
  53. package/dist/server.d.ts +12 -1
  54. package/dist/server.js +33 -22
  55. package/dist/store/entry-writes.d.ts +2 -0
  56. package/dist/store/entry-writes.js +24 -16
  57. package/dist/store-port.d.ts +70 -1
  58. package/dist/store-port.js +87 -9
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  62. package/extensions/openclaw-plugin/package.json +1 -1
  63. package/openclaw.plugin.json +1 -1
  64. package/package.json +1 -1
@@ -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
- log.debug(`transformers import failed (${pkg}): ${err instanceof Error ? err.message : String(err)}`);
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
- log.debug(`embedding pipeline load failed (${model}): ${err instanceof Error ? err.message : String(err)}`);
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)
@@ -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
- consolidate(hippoRoot)
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
  })
@@ -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 { appendAuditEvent, auditQueryFields } from '../audit.js';
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, isJsonBoolean } from './protocol.js';
16
- import { sessionRecallHistoryMcp, lastRecalledIds, resolveClientKey } from './session-state.js';
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 { MAX_ID_LEN } from '../http-util.js';
20
- function parseRecallArgs(args, defaultBudget) {
21
- const query = String(args.query || '');
22
- const budget = Number(args.budget) || defaultBudget;
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
- const { query, budget, includeContinuity, explicitScope, sessionId, recallExtra } = parseRecallArgs(call.args, config.defaultBudget);
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 = biasHintEnabled('anchoring') && sessionId
208
- ? getOrCreateRing(sessionRecallHistoryMcp, buildSessionKey(tenantId, sessionId))
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
- query,
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
- ...recallExtra,
224
- showRanked: recallPresenter(budget, includeContinuity, anchorRing, queryHash, out),
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
- if (!out.rendered)
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), out.rendered.list.map((r) => r.entry.id));
229
- auditRecallHints(call, query, anchorRing, queryHash, out.rendered);
230
- return out.rendered.text;
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.budget === undefined
305
- ? config.defaultContextBudget
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(),
@@ -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
@@ -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 { openHippoDb, closeHippoDb } from '../db.js';
7
- import { estimateTokens, recordTokenUse } from '../token-ledger.js';
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
- const db = openHippoDb(hippoRoot);
47
- try {
48
- recordTokenUse(db, {
49
- tenantId: ctx?.tenantId ?? resolveTenantId({}),
50
- surface,
51
- event: 'inject',
52
- items: 0,
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
- jsonrpc: '2.0',
143
- id,
144
- result: { content: [{ type: 'text', text: `Invalid arguments for ${toolName}: ${problems.join('; ')}` }], isError: true },
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
- import type { RingBuffer } from '../recall-history.js';
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
- // Per-(tenant, session) recall-history rings for the MCP pipeline, kept apart from
3
- // the CLI/HTTP rings: each pipeline owns its rings, with no IPC between them.
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
- sessionRecallHistoryMcp.clear();
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