hippo-memory 1.64.1 → 1.66.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 (66) hide show
  1. package/dist/agent-memories/sync.js +3 -2
  2. package/dist/api/recall-record.d.ts +46 -0
  3. package/dist/api/recall-record.js +98 -0
  4. package/dist/api/recall-request.d.ts +32 -0
  5. package/dist/api/recall-request.js +121 -0
  6. package/dist/api/recall-types.d.ts +8 -1
  7. package/dist/api/recall.d.ts +3 -2
  8. package/dist/api/recall.js +176 -183
  9. package/dist/api/tokens.d.ts +1 -1
  10. package/dist/api/tokens.js +13 -17
  11. package/dist/cli/recall.d.ts +1 -1
  12. package/dist/cli/recall.js +18 -49
  13. package/dist/cli/shared.js +2 -2
  14. package/dist/cli.d.ts +16 -0
  15. package/dist/cli.js +18 -1
  16. package/dist/config.d.ts +2 -0
  17. package/dist/config.js +4 -0
  18. package/dist/db/connect.d.ts +5 -0
  19. package/dist/db/connect.js +52 -0
  20. package/dist/db/open.d.ts +13 -11
  21. package/dist/db/open.js +47 -119
  22. package/dist/db/request-stores.d.ts +30 -0
  23. package/dist/db/request-stores.js +100 -0
  24. package/dist/db/sqlite-blocked.d.ts +16 -0
  25. package/dist/db/sqlite-blocked.js +29 -0
  26. package/dist/db.d.ts +3 -1
  27. package/dist/db.js +3 -1
  28. package/dist/embeddings.d.ts +11 -2
  29. package/dist/embeddings.js +22 -12
  30. package/dist/goals.d.ts +18 -11
  31. package/dist/goals.js +38 -36
  32. package/dist/http-util.d.ts +2 -0
  33. package/dist/http-util.js +5 -0
  34. package/dist/mcp/memory-tools.js +4 -1
  35. package/dist/mcp/recall-tools.js +28 -133
  36. package/dist/mcp/request.d.ts +7 -0
  37. package/dist/mcp/request.js +63 -41
  38. package/dist/mcp/session-state.d.ts +1 -3
  39. package/dist/mcp/session-state.js +3 -5
  40. package/dist/predictions/planning-fallacy.d.ts +52 -0
  41. package/dist/predictions/planning-fallacy.js +70 -55
  42. package/dist/quality-repair.js +3 -0
  43. package/dist/recall-trace.d.ts +1 -1
  44. package/dist/recall-trace.js +5 -4
  45. package/dist/search/hybrid.d.ts +3 -0
  46. package/dist/search/physics-search.d.ts +3 -0
  47. package/dist/search/physics-search.js +21 -19
  48. package/dist/search/vector.d.ts +7 -2
  49. package/dist/search/vector.js +29 -10
  50. package/dist/server/routes/recall.d.ts +1 -1
  51. package/dist/server/routes/recall.js +27 -218
  52. package/dist/server.d.ts +17 -1
  53. package/dist/server.js +38 -22
  54. package/dist/store/entry-writes.d.ts +2 -0
  55. package/dist/store/entry-writes.js +24 -16
  56. package/dist/store/open.js +2 -1
  57. package/dist/store-port.d.ts +88 -2
  58. package/dist/store-port.js +104 -9
  59. package/dist/vector-store.d.ts +8 -0
  60. package/dist/vector-store.js +31 -19
  61. package/dist/version.d.ts +1 -1
  62. package/dist/version.js +1 -1
  63. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  64. package/extensions/openclaw-plugin/package.json +1 -1
  65. package/openclaw.plugin.json +1 -1
  66. package/package.json +1 -1
package/dist/goals.js CHANGED
@@ -129,6 +129,17 @@ export function getActiveGoals(hippoRoot, opts) {
129
129
  closeHippoDb(db);
130
130
  }
131
131
  }
132
+ /** The active goals and their policies on one handle, so a goal and its policy never disagree. */
133
+ export function activeGoalsWithPolicies(hippoRoot, opts) {
134
+ const db = openHippoDb(hippoRoot);
135
+ try {
136
+ const goals = getActiveGoalsWithDb(db, opts);
137
+ return { goals, policies: loadGoalPolicies(db, goals) };
138
+ }
139
+ finally {
140
+ closeHippoDb(db);
141
+ }
142
+ }
132
143
  /** Every goal of a (tenant, session), whatever its status, oldest first. */
133
144
  export function getSessionGoals(hippoRoot, opts) {
134
145
  const db = openHippoDb(hippoRoot);
@@ -163,7 +174,7 @@ export function getActiveGoalsWithDb(db, opts) {
163
174
  // can compose onto the base goal-tag boost. Composed result is hard-capped
164
175
  // at MAX_FINAL_MULTIPLIER (3.0x) BEFORE applying to score -- even an
165
176
  // `errorPriority: 9.0` policy cannot exceed 3.0x.
166
- function loadGoalPolicies(db, active) {
177
+ export function loadGoalPolicies(db, active) {
167
178
  const policiesByGoalId = new Map();
168
179
  for (const g of active) {
169
180
  if (!g.retrievalPolicyId)
@@ -220,28 +231,11 @@ function goalBoostMultiplier(entry, tags, matches, goalsByTag, policiesByGoalId)
220
231
  return Math.min(multiplier, MAX_FINAL_MULTIPLIER);
221
232
  }
222
233
  /** Log rows for the top `limit` boosted rows, one per matched goal. */
223
- function buildGoalRecallLog(db, boosted, matchesByEntryId, goalsByTag, opts) {
234
+ function buildGoalRecallLog(boosted, matchesByEntryId, goalsByTag, opts) {
224
235
  const { sessionId, tenantId, limit } = opts;
225
- // Filter to local memories only -- global memory IDs aren't in this DB's
226
- // memories table, so the FK on goal_recall_log.memory_id would fail.
227
- // dlPFC depth's outcome propagation is session-scoped to local; boost on
228
- // ranking still applies to global results, just no log row -> no
229
- // propagation.
230
- const topKIds = boosted.slice(0, limit).map((r) => r.entry.id);
231
- const localIds = new Set();
232
- if (topKIds.length > 0) {
233
- const placeholders = topKIds.map(() => '?').join(',');
234
- // SAFETY: rows come from the SELECT above, which projects exactly one
235
- // column, `id`, from memories.
236
- const localRows = db.prepare(`SELECT id FROM memories WHERE id IN (${placeholders})`).all(...topKIds);
237
- for (const row of localRows)
238
- localIds.add(row.id);
239
- }
240
236
  const recalledAt = new Date().toISOString();
241
237
  const log = [];
242
238
  for (const r of boosted.slice(0, limit)) {
243
- if (!localIds.has(r.entry.id))
244
- continue; // global -> skip log insert
245
239
  const matches = matchesByEntryId.get(r.entry.id);
246
240
  if (!matches || matches.length === 0)
247
241
  continue;
@@ -254,6 +248,17 @@ function buildGoalRecallLog(db, boosted, matchesByEntryId, goalsByTag, opts) {
254
248
  }
255
249
  return log;
256
250
  }
251
+ /** The rows whose memory lives in this store: goal_recall_log.memory_id references memories, so a global row would fail
252
+ * the insert. A global result keeps its boost; it only earns no log row, so no outcome propagation. */
253
+ export function localGoalRecallRows(db, rows) {
254
+ if (rows.length === 0)
255
+ return [];
256
+ const ids = [...new Set(rows.map((r) => r.memoryId))];
257
+ // SAFETY: the SELECT projects exactly one column, `id`, from memories.
258
+ const local = db.prepare(`SELECT id FROM memories WHERE id IN (${ids.map(() => '?').join(',')})`).all(...ids);
259
+ const localIds = new Set(local.map((r) => r.id));
260
+ return rows.filter((r) => localIds.has(r.memoryId));
261
+ }
257
262
  /**
258
263
  * dlPFC goal-stack boost. Applies the multi-goal boost to entry-backed scored
259
264
  * rows when (tenant, session) has active goals and returns the log rows to
@@ -274,12 +279,20 @@ function buildGoalRecallLog(db, boosted, matchesByEntryId, goalsByTag, opts) {
274
279
  * @internal Recall ranking helper. Subject to change.
275
280
  */
276
281
  export function computeGoalStackBoost(db, results, opts) {
277
- const { sessionId, tenantId, trace } = opts;
278
- const active = getActiveGoalsWithDb(db, { sessionId, tenantId });
279
- if (active.length === 0)
282
+ const goals = getActiveGoalsWithDb(db, { sessionId: opts.sessionId, tenantId: opts.tenantId });
283
+ if (goals.length === 0)
284
+ return { results, log: [] };
285
+ const boost = boostByGoals(results, { goals, policies: loadGoalPolicies(db, goals) }, opts);
286
+ return { results: boost.results, log: localGoalRecallRows(db, boost.log) };
287
+ }
288
+ /** {@link computeGoalStackBoost} over goals already read, touching no store. Its log keeps rows whose memory is global,
289
+ * since only the store can tell them apart; {@link localGoalRecallRows} drops those before the write. */
290
+ export function boostByGoals(results, active, opts) {
291
+ const { trace } = opts;
292
+ if (active.goals.length === 0)
280
293
  return { results, log: [] };
281
- const goalsByTag = new Map(active.map((g) => [g.goalName, g]));
282
- const policiesByGoalId = loadGoalPolicies(db, active);
294
+ const goalsByTag = new Map(active.goals.map((g) => [g.goalName, g]));
295
+ const policiesByGoalId = active.policies;
283
296
  // Goal-tag matches per boosted row, keyed by entry id. Kept as a side table
284
297
  // (rather than a spread-on `_goalMatches` marker property) so `boosted`
285
298
  // stays exactly R[] end to end, with no cast-tag-then-strip round trip.
@@ -313,7 +326,7 @@ export function computeGoalStackBoost(db, results, opts) {
313
326
  // inherits its determinism via sort stability, and ties preserve the
314
327
  // prior (meaningful) rank instead of reordering by content.
315
328
  .sort((a, b) => b.score - a.score);
316
- return { results: boosted, log: buildGoalRecallLog(db, boosted, matchesByEntryId, goalsByTag, opts) };
329
+ return { results: boosted, log: buildGoalRecallLog(boosted, matchesByEntryId, goalsByTag, opts) };
317
330
  }
318
331
  /**
319
332
  * Writes goal-boost log rows. INSERT OR IGNORE because UNIQUE(memory_id, goal_id)
@@ -331,17 +344,6 @@ export function writeGoalRecallLog(db, rows) {
331
344
  insertLog.run(row.goalId, row.memoryId, row.tenantId, row.sessionId, row.recalledAt, row.score);
332
345
  }
333
346
  }
334
- /**
335
- * {@link computeGoalStackBoost} plus {@link writeGoalRecallLog} in one call, for
336
- * pipelines that boost and log on the same handle.
337
- *
338
- * @internal Recall ranking helper. Subject to change.
339
- */
340
- export function applyGoalStackBoost(db, results, opts) {
341
- const boost = computeGoalStackBoost(db, results, opts);
342
- writeGoalRecallLog(db, boost.log);
343
- return boost.results;
344
- }
345
347
  const POSITIVE_OUTCOME_THRESHOLD = 0.7;
346
348
  const NEGATIVE_OUTCOME_THRESHOLD = 0.3;
347
349
  const STRENGTH_BOOST = 1.10;
@@ -23,6 +23,8 @@ export interface ApiErrorReply {
23
23
  message: string;
24
24
  }
25
25
  export declare const INTERNAL_ERROR_MESSAGE = "internal server error";
26
+ /** The reply when a request under another store reaches code that still opens hippo.db. */
27
+ export declare const STORE_NOT_PORTED_MESSAGE = "store_not_ported";
26
28
  /** Maps by class so rewording a message never moves a status; an untyped error is a 500 whose text stays in the server log. */
27
29
  export declare function mapApiError<E>(err: E): ApiErrorReply;
28
30
  /** Serialises before the head goes out, so a body that is not JSON still reaches the caller's error reply. */
package/dist/http-util.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ApiError } from './api-errors.js';
2
+ import { SqliteBlockedError } from './db/sqlite-blocked.js';
2
3
  // Leaf module shared by server.ts and the connector webhook receivers; it must not import either.
3
4
  export function isJsonObjectRecord(value) {
4
5
  return value !== undefined && value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -35,6 +36,8 @@ export class BodyTooLargeError extends Error {
35
36
  export class BodyTimeoutError extends Error {
36
37
  }
37
38
  export const INTERNAL_ERROR_MESSAGE = 'internal server error';
39
+ /** The reply when a request under another store reaches code that still opens hippo.db. */
40
+ export const STORE_NOT_PORTED_MESSAGE = 'store_not_ported';
38
41
  /** Maps by class so rewording a message never moves a status; an untyped error is a 500 whose text stays in the server log. */
39
42
  export function mapApiError(err) {
40
43
  // An add-on can build an HttpError from any number, and writeHead throws on one outside 100-999.
@@ -47,6 +50,8 @@ export function mapApiError(err) {
47
50
  return { status: 413, message: err.message };
48
51
  if (err instanceof BodyTimeoutError)
49
52
  return { status: 408, message: err.message };
53
+ if (err instanceof SqliteBlockedError)
54
+ return { status: 501, message: STORE_NOT_PORTED_MESSAGE };
50
55
  return { status: 500, message: INTERNAL_ERROR_MESSAGE };
51
56
  }
52
57
  /** Serialises before the head goes out, so a body that is not JSON still reaches the caller's error reply. */
@@ -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
  //