hippo-memory 1.64.1 → 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 (58) 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 +7 -1
  7. package/dist/api/recall.d.ts +3 -2
  8. package/dist/api/recall.js +174 -181
  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 +9 -11
  21. package/dist/db/open.js +23 -116
  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 +6 -0
  25. package/dist/db/sqlite-blocked.js +11 -0
  26. package/dist/db.d.ts +3 -1
  27. package/dist/db.js +3 -1
  28. package/dist/embeddings.js +2 -1
  29. package/dist/goals.d.ts +18 -11
  30. package/dist/goals.js +38 -36
  31. package/dist/http-util.d.ts +2 -0
  32. package/dist/http-util.js +5 -0
  33. package/dist/mcp/memory-tools.js +4 -1
  34. package/dist/mcp/recall-tools.js +28 -133
  35. package/dist/mcp/request.d.ts +7 -0
  36. package/dist/mcp/request.js +63 -41
  37. package/dist/mcp/session-state.d.ts +1 -3
  38. package/dist/mcp/session-state.js +3 -5
  39. package/dist/predictions/planning-fallacy.d.ts +52 -0
  40. package/dist/predictions/planning-fallacy.js +70 -55
  41. package/dist/recall-trace.d.ts +1 -1
  42. package/dist/recall-trace.js +5 -4
  43. package/dist/search/physics-search.js +4 -1
  44. package/dist/search/vector.js +2 -0
  45. package/dist/server/routes/recall.d.ts +1 -1
  46. package/dist/server/routes/recall.js +30 -218
  47. package/dist/server.d.ts +12 -1
  48. package/dist/server.js +33 -22
  49. package/dist/store/entry-writes.d.ts +2 -0
  50. package/dist/store/entry-writes.js +24 -16
  51. package/dist/store-port.d.ts +70 -1
  52. package/dist/store-port.js +87 -9
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  56. package/extensions/openclaw-plugin/package.json +1 -1
  57. package/openclaw.plugin.json +1 -1
  58. package/package.json +1 -1
@@ -3,185 +3,47 @@ import { dirname, resolve } from 'node:path';
3
3
  import { assertCallerProject, resolveProjectIdentity } from '../../project-identity.js';
4
4
  import { isSharedStore } from '../../config.js';
5
5
  import { assembleCost, contextCost, drillCost } from '../../context-render.js';
6
- import { closeHippoDb, openHippoDb } from '../../db.js';
7
- import { updateStats } from '../../store/index-and-stats.js';
8
- import { appendRecall, biasHintEnabled, buildSessionKey, getOrCreateRing, hashQueryText, snapshotRing } from '../../recall-history.js';
9
- import { appendAuditEvent, auditQueryFields } from '../../audit.js';
6
+ import { storeFor } from '../../store-port.js';
7
+ import { biasHintEnabled } from '../../recall-history.js';
10
8
  import { assemble, drillDown, getContext, recordTokens, retrieve } from '../../api.js';
11
- import { HttpError, MAX_ID_LEN, sendJson } from '../../http-util.js';
9
+ import { httpParams, parseContextRequest, parseRecallRequest } from '../../api/recall-request.js';
10
+ import { anchorSkippedRows, noteRecall, peekSessionRing, resetSessionRings, sessionRing } from '../../api/recall-record.js';
11
+ import { HttpError, sendJson } from '../../http-util.js';
12
12
  import { buildContextWithAuth } from '../auth.js';
13
- import { parseListLimit, validateIdSegment } from '../validation.js';
14
- // Module-level per-(tenant, session) recall-history ring map
15
- // for the HTTP pipeline. Separate from CLI/MCP rings (per-
16
- // pipeline rings; no IPC). HTTP is the only caller that threads its
17
- // snapshot through opts.recallHistory to api.recall — api.recall's
18
- // anchoringHint on the returned RecallResult IS the user-visible hint
19
- // here (no separate compute needed).
20
- const sessionRecallHistoryHttp = new Map();
21
- /** Test-only: reset the module-level recall-history Map. Call from beforeEach. */
13
+ import { validateIdSegment } from '../validation.js';
14
+ /** Test-only: reset the HTTP recall rings. Call from beforeEach. */
22
15
  export function __resetSessionRecallHistoryHttp() {
23
- sessionRecallHistoryHttp.clear();
16
+ resetSessionRings('http');
24
17
  }
25
- function parseFreshTail(query) {
26
- // Surface the fresh-tail RecallOpts to HTTP callers so session-scoped
27
- // fresh-tail and summary substitution are not JS-only.
28
- const freshTailCountRaw = query.get('fresh_tail_count');
29
- const freshTailCount = freshTailCountRaw === null ? undefined : Number(freshTailCountRaw);
30
- if (freshTailCount !== undefined && (!Number.isFinite(freshTailCount) || freshTailCount < 0)) {
31
- throw new HttpError(400, 'fresh_tail_count must be a non-negative number');
32
- }
33
- // Cap session_id length consistent with the
34
- // rest of the API. Untrimmed strings round-trip through the SQL layer
35
- // and through any downstream metric/log; 256 is generous for a session
36
- // id and matches the rest of this file's id-shaped param parsers.
37
- const freshTailSessionIdRaw = query.get('fresh_tail_session_id');
38
- if (freshTailSessionIdRaw !== null && freshTailSessionIdRaw.length > MAX_ID_LEN) {
39
- throw new HttpError(400, `fresh_tail_session_id exceeds ${MAX_ID_LEN}-character cap`);
40
- }
41
- const freshTailSessionId = freshTailSessionIdRaw && freshTailSessionIdRaw.length > 0
42
- ? freshTailSessionIdRaw
43
- : undefined;
44
- return { freshTailCount, freshTailSessionId };
45
- }
46
- function parseSessionId(query) {
47
- // session_id for the dlPFC goal-stack boost. 256-char cap mirrors
48
- // fresh_tail_session_id (above). Trim then drop if empty so api.recall
49
- // sees undefined when the param is omitted or whitespace-only.
50
- const sessionIdRaw = query.get('session_id');
51
- if (sessionIdRaw !== null && sessionIdRaw.length > MAX_ID_LEN) {
52
- throw new HttpError(400, `session_id exceeds ${MAX_ID_LEN}-character cap`);
53
- }
54
- return sessionIdRaw && sessionIdRaw.trim().length > 0
55
- ? sessionIdRaw.trim()
56
- : undefined;
57
- }
58
- function parseRecallQuery(query) {
59
- const q = query.get('q');
60
- if (!q) {
61
- throw new HttpError(400, 'q is required');
62
- }
63
- const limitRaw = query.get('limit');
64
- const limit = limitRaw === null ? undefined : parseListLimit(limitRaw);
65
- const mode = query.get('mode');
66
- if (mode !== null && mode !== 'bm25' && mode !== 'hybrid' && mode !== 'physics') {
67
- throw new HttpError(400, "mode must be 'bm25', 'hybrid', or 'physics'");
68
- }
69
- const scope = query.get('scope');
70
- const includeContinuityRaw = query.get('include_continuity');
71
- const includeContinuity = includeContinuityRaw === '1'
72
- || includeContinuityRaw === 'true';
73
- const { freshTailCount, freshTailSessionId } = parseFreshTail(query);
74
- // Strict parse matching the includeContinuity convention, so values
75
- // like `?summarize_overflow=banana` or an empty value do not turn it on.
76
- const summarizeOverflowRaw = query.get('summarize_overflow');
77
- const summarizeOverflow = summarizeOverflowRaw === null
78
- ? undefined
79
- : (summarizeOverflowRaw === '1' || summarizeOverflowRaw === 'true');
80
- // recall() owns the shape rule (NaN, 0 and negatives throw invalid_scorer_window); the transport caps remote cost.
81
- const scorerWindowRaw = query.get('scorer_window');
82
- const scorerWindow = scorerWindowRaw === null ? undefined : Number(scorerWindowRaw);
83
- if (scorerWindow !== undefined && scorerWindow > 1000) {
84
- throw new HttpError(400, 'scorer_window must be <= 1000');
85
- }
86
- const sessionId = parseSessionId(query);
87
- // Recall-trace: opt-in explain flag. When set, api.recall attaches the
88
- // lifecycle re-ranking trace (goal-boost step on the api pipeline) +
89
- // rerankPipeline:'api' to each result item; the field then rides on the
90
- // serialized RecallResult. Mirrors the include_continuity convention.
91
- const explainRaw = query.get('explain');
92
- const explain = explainRaw === '1' || explainRaw === 'true';
93
- return { q, limit, mode, scope, includeContinuity, freshTailCount, freshTailSessionId, summarizeOverflow, scorerWindow, sessionId, explain };
94
- }
95
- // HTTP per-pipeline anchoring detector. HTTP threads its
96
- // ring snapshot via opts.recallHistory so api.recall's own
97
- // anchoringHint compute path activates. Unlike CLI (which computes
98
- // its own hint separately because cmdRecall runs its own physics/
99
- // hybrid pipeline outside api.recall), HTTP's /v1/memories response
100
- // body IS api.recall's result directly. So the api.recall-computed
101
- // hint flows through. HIPPO_ANCHORING=off short-circuits.
102
- function snapshotSessionRing(ctx, hippoRoot, q, sessionId) {
103
- let httpRecallHistory;
104
- let httpRingKey;
105
- if (biasHintEnabled('anchoring')) {
106
- if (sessionId) {
107
- // Do NOT mutate sessionRecallHistoryHttp before recall() preflight: a request
108
- // that 400s would otherwise create-or-touch a ring and LRU-evict valid sessions.
109
- httpRingKey = buildSessionKey(ctx.tenantId, sessionId);
110
- const existingRing = sessionRecallHistoryHttp.get(httpRingKey);
111
- httpRecallHistory = existingRing ? snapshotRing(existingRing) : [];
112
- }
113
- else {
114
- // Telemetry: caller had no session_id so ring tracking skipped.
115
- // Per the normal recall-audit convention (api.ts:854 stores
116
- // SHA-256/16 hash of the query, NOT raw text), avoid retaining
117
- // prompts in audit_log here too — query content can contain
118
- // secrets, PII, or RTBF-restricted material. hashQueryText is a 32-bit
119
- // FNV-1a, NOT a privacy hash, so use the recall audit's SHA-256/16 truncation.
120
- const dbForAudit = openHippoDb(hippoRoot);
121
- try {
122
- appendAuditEvent(dbForAudit, {
123
- tenantId: ctx.tenantId,
124
- actor: ctx.actor.subject,
125
- op: 'recall_anchor_skipped_no_session',
126
- targetId: undefined,
127
- metadata: auditQueryFields(q),
128
- });
129
- }
130
- finally {
131
- closeHippoDb(dbForAudit);
132
- }
133
- }
134
- }
135
- return { httpRecallHistory, httpRingKey };
136
- }
137
- function recallExtraOpts(parsed, httpRecallHistory) {
138
- const recallExtra = {};
139
- if (parsed.freshTailCount !== undefined)
140
- recallExtra.freshTailCount = parsed.freshTailCount;
141
- if (parsed.freshTailSessionId !== undefined)
142
- recallExtra.freshTailSessionId = parsed.freshTailSessionId;
143
- if (parsed.summarizeOverflow !== undefined)
144
- recallExtra.summarizeOverflow = parsed.summarizeOverflow;
145
- if (parsed.scorerWindow !== undefined)
146
- recallExtra.scorerWindow = parsed.scorerWindow;
147
- if (parsed.sessionId !== undefined)
148
- recallExtra.sessionId = parsed.sessionId;
149
- if (httpRecallHistory !== undefined)
150
- recallExtra.recallHistory = httpRecallHistory;
151
- if (parsed.explain)
152
- recallExtra.explain = parsed.explain;
153
- return recallExtra;
18
+ // HTTP threads the ring through opts.recallHistory, so the hint retrieve() returns is the one the caller sees.
19
+ async function recallHistoryFor(ctx, q, sessionId) {
20
+ if (!biasHintEnabled('anchoring'))
21
+ return undefined;
22
+ if (sessionId)
23
+ return peekSessionRing('http', ctx.tenantId, sessionId);
24
+ await storeFor(ctx).appendAuditEvents(anchorSkippedRows({ tenantId: ctx.tenantId, actor: ctx.actor.subject }, q));
25
+ return undefined;
154
26
  }
155
27
  // GET /v1/memories?q=...&limit=...&mode=...&scope=...&include_continuity=1
156
28
  export async function handleRecallMemories({ req, res, opts, query }) {
157
- const parsed = parseRecallQuery(query);
158
- const { q, includeContinuity, sessionId } = parsed;
29
+ const { opts: recallOpts, limit, mode, explain } = parseRecallRequest(httpParams(query));
30
+ const { query: q, includeContinuity, sessionId } = recallOpts;
159
31
  const ctx = await buildContextWithAuth(req, opts);
160
- const { httpRecallHistory, httpRingKey } = snapshotSessionRing(ctx, opts.hippoRoot, q, sessionId);
161
- const result = await retrieve(ctx, {
162
- query: q,
163
- limit: parsed.limit,
164
- mode: parsed.mode ?? undefined,
165
- scope: parsed.scope ?? undefined,
166
- includeContinuity,
167
- ...recallExtraOpts(parsed, httpRecallHistory),
168
- });
169
- // Append only after recall succeeds, so a 400 cannot LRU-evict valid sessions;
170
- // anchoredOn feeds the cooldown logic for the NEXT recall on this session.
171
- if (httpRingKey) {
172
- const httpRing = getOrCreateRing(sessionRecallHistoryHttp, httpRingKey);
173
- const topId = result.results[0]?.id ?? null;
174
- appendRecall(httpRing, hashQueryText(q), topId, result.anchoringHint?.memoryId);
175
- }
32
+ const recallHistory = await recallHistoryFor(ctx, q, sessionId);
33
+ const result = await retrieve(ctx, { ...recallOpts, limit, mode, explain, recallHistory });
34
+ // The ring is created only after recall succeeds, so a 400 cannot LRU-evict a live session.
35
+ const ring = sessionRing('http', ctx.tenantId, sessionId);
36
+ if (ring)
37
+ noteRecall(ring, q, result.results[0]?.id ?? null, result.anchoringHint?.memoryId);
176
38
  // Each recall surface counts its own hits; api.recall is no chokepoint,
177
39
  // since the CLI never calls it and MCP shows the user a different band.
178
- updateStats(opts.hippoRoot, { recalled: result.results.length });
40
+ await storeFor(ctx).bumpRecallStats(result.results.length);
179
41
  // Continuity payloads should never be cached. The caller is asking for
180
42
  // session-state-aware data; intermediaries must not reuse it across users.
181
43
  if (includeContinuity) {
182
44
  res.setHeader('Cache-Control', 'no-store');
183
45
  }
184
- recordTokens(ctx, 'http_recall', { items: result.results.length, tokens: result.tokens + (result.continuityTokens ?? 0), sessionId: sessionId ?? null });
46
+ await recordTokens(ctx, 'http_recall', { items: result.results.length, tokens: result.tokens + (result.continuityTokens ?? 0), sessionId: sessionId ?? null });
185
47
  sendJson(res, 200, result);
186
48
  return;
187
49
  }
@@ -220,7 +82,7 @@ export async function handleAssembleSession({ req, res, opts, query }, assembleM
220
82
  if (scope !== undefined)
221
83
  assembleExtra.scope = scope;
222
84
  const result = assemble(ctx, assembleMatch.id, { ...assembleExtra, cost: assembleCost(assembleMatch.id) });
223
- recordTokens(ctx, 'http_assemble', { items: result.items.length, tokens: result.tokens, sessionId: assembleMatch.id });
85
+ await recordTokens(ctx, 'http_assemble', { items: result.items.length, tokens: result.tokens, sessionId: assembleMatch.id });
224
86
  sendJson(res, 200, result);
225
87
  return;
226
88
  }
@@ -290,64 +152,14 @@ function contextReader(hippoRoot, query) {
290
152
  // via the Bearer. Pinned-only + '*' fallback skip the recall audit emit
291
153
  // (matches cmdContext); real-query hybrid search emits one 'recall' row.
292
154
  export async function handleGetContext({ req, res, opts, query }) {
293
- const q = query.get('q') ?? undefined;
294
- // DoS cap on q-param length. 1024 covers real multi-clause queries
295
- // (pasted error messages, multi-stem searches) while bounding BM25
296
- // tokenisation cost (~150 tokens worst case at 1024 chars).
297
- if (q !== undefined && q.length > 1024) {
298
- throw new HttpError(400, 'q exceeds 1024-character cap');
299
- }
300
- const budgetRaw = query.get('budget');
301
- let budget;
302
- if (budgetRaw !== null) {
303
- budget = Number(budgetRaw);
304
- if (!Number.isFinite(budget) || budget < 0) {
305
- throw new HttpError(400, 'budget must be a non-negative number');
306
- }
307
- }
308
- const limitRaw = query.get('limit');
309
- let limit;
310
- if (limitRaw !== null) {
311
- limit = Number(limitRaw);
312
- if (!Number.isFinite(limit) || limit <= 0) {
313
- throw new HttpError(400, 'limit must be a positive number');
314
- }
315
- }
316
- const pinnedOnlyRaw = query.get('pinned_only');
317
- const pinnedOnly = pinnedOnlyRaw === '1' || pinnedOnlyRaw === 'true';
318
- const scopeRaw = query.get('scope');
319
- if (scopeRaw !== null && scopeRaw.length > MAX_ID_LEN) {
320
- throw new HttpError(400, `scope exceeds ${MAX_ID_LEN}-character cap`);
321
- }
322
- const scope = scopeRaw === null ? undefined : scopeRaw;
323
- const includeRecentRaw = query.get('include_recent');
324
- let includeRecent;
325
- if (includeRecentRaw !== null) {
326
- includeRecent = Number(includeRecentRaw);
327
- if (!Number.isFinite(includeRecent) || includeRecent < 0) {
328
- throw new HttpError(400, 'include_recent must be a non-negative number');
329
- }
330
- }
331
- // v39 memory scope isolation: cross_project=1|true re-includes
332
- // other-project rows (tagged category 'cross-project' in the response).
333
- // The partition identity comes from the SERVED STORE's location, not the
334
- // daemon's process cwd - a daemon started from anywhere still isolates
335
- // the project it serves.
336
- const crossProjectRaw = query.get('cross_project');
337
- const crossProject = crossProjectRaw === '1' || crossProjectRaw === 'true';
155
+ const parsed = parseContextRequest(httpParams(query));
338
156
  const ctx = await buildContextWithAuth(req, opts);
339
157
  const result = await getContext(ctx, {
340
- q,
341
- budget,
342
- limit,
343
- pinnedOnly,
344
- scope,
345
- includeRecent,
346
- crossProject,
158
+ ...parsed,
347
159
  currentProject: contextReader(opts.hippoRoot, query),
348
160
  cost: contextCost('markdown', 'observe'), // clients render; the budget prices the block `hippo context` would print
349
161
  });
350
- recordTokens(ctx, 'http_context', { items: result.entries.length, tokens: result.tokens });
162
+ await recordTokens(ctx, 'http_context', { items: result.entries.length, tokens: result.tokens });
351
163
  sendJson(res, 200, result);
352
164
  return;
353
165
  }
package/dist/server.d.ts CHANGED
@@ -11,8 +11,19 @@ export { HttpError } from './http-util.js';
11
11
  export { promptHookContext, type CallerProject } from './prompt-hook.js';
12
12
  export type { JsonValue } from './json.js';
13
13
  export { captureSessionTexts, type SessionCaptureRequest, type SessionCaptureResult } from './capture/session-texts.js';
14
- export { sqliteStore, type HippoStore } from './store-port.js';
14
+ export { sqliteStore, type HippoStore, type RecallSearchArgs, type RecallWrites } from './store-port.js';
15
15
  export type { ApiKeyRecord } from './auth.js';
16
+ export type { AppendAuditOpts } from './audit.js';
17
+ export type { ContinuityBlock } from './api/recall-types.js';
18
+ export type { ActiveGoals, GetActiveGoalsOpts, Goal, GoalRecallLogRow, RetrievalPolicy } from './goals.js';
19
+ export type { MemoryEntry } from './memory.js';
20
+ export type { ClassResolution, PlanningFallacyEvidence } from './predictions/planning-fallacy.js';
21
+ export type { PredictionBaserate } from './predictions/store.js';
22
+ export type { RecallTraceInput } from './recall-trace.js';
23
+ export type { StrengthenOptions } from './store/entry-writes.js';
24
+ export type { OriginFilter } from './store/search-rows.js';
25
+ export type { ContinuityKey } from './store/sessions.js';
26
+ export type { TokenUse } from './token-ledger.js';
16
27
  export { StoreBusyError } from './db.js';
17
28
  export { authCreate, type AuthCreateOpts, type AuthCreateResult } from './api.js';
18
29
  export { isSharedStore } from './config.js';
package/dist/server.js CHANGED
@@ -2,8 +2,9 @@ import { envPort, envRequireAuth, envV1Rps } from './env.js';
2
2
  import { createServer } from 'node:http';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { detectServer, removePidfileIfOwned, writePidfile } from './server-detect.js';
5
- import { closeHippoDb, getHippoDbPath, isStoreBusy, openHippoDb, SERVER_DB_WAIT_MS, withBusyWait, withSqliteBlocked } from './db.js';
5
+ import { closeHippoDb, getHippoDbPath, isStoreBusy, openHippoDb, outsideRequestStores, runWithRequestStores, SERVER_DB_WAIT_MS, withSqliteBlocked } from './db.js';
6
6
  import { sqliteStore } from './store-port.js';
7
+ import { markSharedStore } from './config.js';
7
8
  import { auditWriteFailureCount } from './audit.js';
8
9
  import { PACKAGE_VERSION } from './version.js';
9
10
  import { errorFields, log } from './log.js';
@@ -11,7 +12,7 @@ import { createRateLimiter } from './rate-limit.js';
11
12
  import { authCreateSelf, authRevoke, RecallContractError } from './api.js';
12
13
  import { handleSlackEventsWebhook } from './connectors/slack/webhook.js';
13
14
  import { handleGitHubEventsWebhook } from './connectors/github/webhook.js';
14
- import { BodyTimeoutError, BodyTooLargeError, closeAfterReply, HttpError, JSON_HEADERS, sendJson } from './http-util.js';
15
+ import { BodyTimeoutError, BodyTooLargeError, closeAfterReply, HttpError, JSON_HEADERS, sendJson, STORE_NOT_PORTED_MESSAGE } from './http-util.js';
15
16
  import { ForbiddenError } from './api-errors.js';
16
17
  import { buildContextWithAuth, isLoopback, LIMITER_MAX_KEYS, requireAuth } from './server/auth.js';
17
18
  import { enforceRateLimit } from './server/client-ip.js';
@@ -92,7 +93,7 @@ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
92
93
  const V1_ROUTES = [
93
94
  { method: 'POST', path: '/v1/memories', handler: handleCreateMemory },
94
95
  { method: 'GET', path: '/v1/graph', handler: handleGetGraph },
95
- { method: 'GET', path: '/v1/memories', handler: handleRecallMemories },
96
+ { method: 'GET', path: '/v1/memories', storeReady: true, handler: handleRecallMemories },
96
97
  { method: 'GET', pattern: '/v1/sessions/:id/assemble', handler: handleAssembleSession },
97
98
  { method: 'GET', pattern: '/v1/recall/drill/:id', handler: handleDrillRecall },
98
99
  { method: 'POST', pattern: '/v1/memories/:id/archive', handler: handleArchiveMemory },
@@ -238,10 +239,9 @@ function dispatchPublicJson({ res, opts }, method, path) {
238
239
  res.end(text);
239
240
  return true;
240
241
  }
241
- const NOT_ON_STORE_MESSAGE = 'not available on this store';
242
242
  function assertSqliteStore(opts) {
243
243
  if (opts.store.kind !== 'sqlite')
244
- throw new HttpError(501, NOT_ON_STORE_MESSAGE);
244
+ throw new HttpError(501, STORE_NOT_PORTED_MESSAGE);
245
245
  }
246
246
  /** Under another store, a route not yet ported answers 501 without running; the caller is checked first, so a bad key is still a 401. */
247
247
  async function refuseUnportedRoute(req, opts) {
@@ -264,12 +264,25 @@ async function handleRequest(req, res, opts, startedAt, streamSlots, limiter) {
264
264
  }
265
265
  enforceRateLimit(req, path, limiter);
266
266
  const routeRequest = { req, res, opts, query };
267
- if (await dispatchV1Route(routeRequest, method, path))
267
+ if (await runWithRequestStores(() => dispatchScopedRoute(routeRequest, method, path), { busyWaitMs: SERVER_DB_WAIT_MS }))
268
268
  return;
269
- if (dispatchPublicJson(routeRequest, method, path))
270
- return;
271
- if (await dispatchAddonRoute(routeRequest, method, path))
269
+ // Outside the request scope: the heartbeat timer outlives the request. Store-ready: the stream only authenticates, through the port.
270
+ if (method === 'GET' && path === '/mcp/stream') {
271
+ await handleMcpStream(req, res, opts, streamSlots);
272
272
  return;
273
+ }
274
+ res.writeHead(404, JSON_HEADERS);
275
+ res.end(JSON.stringify({ error: 'not found' }));
276
+ }
277
+ /** Every route that runs inside a request scope, so it opens each store once: the /v1 table, public JSON, add-on routes, the webhooks and POST /mcp. */
278
+ async function dispatchScopedRoute(r, method, path) {
279
+ if (await dispatchV1Route(r, method, path))
280
+ return true;
281
+ if (dispatchPublicJson(r, method, path))
282
+ return true;
283
+ if (await dispatchAddonRoute(r, method, path))
284
+ return true;
285
+ const { req, res, opts } = r;
273
286
  if (method === 'POST' && path === '/v1/connectors/slack/events') {
274
287
  // Bearer auth deliberately skipped: this route is in PUBLIC_ROUTES and authenticates via the Slack HMAC signature.
275
288
  if (!isPublicRoute(method, path)) {
@@ -278,7 +291,7 @@ async function handleRequest(req, res, opts, startedAt, streamSlots, limiter) {
278
291
  }
279
292
  assertSqliteStore(opts);
280
293
  await handleSlackEventsWebhook({ req, res, opts });
281
- return;
294
+ return true;
282
295
  }
283
296
  if (method === 'POST' && path === '/v1/connectors/github/events') {
284
297
  if (!isPublicRoute(method, path)) {
@@ -286,20 +299,14 @@ async function handleRequest(req, res, opts, startedAt, streamSlots, limiter) {
286
299
  }
287
300
  assertSqliteStore(opts);
288
301
  await handleGitHubEventsWebhook({ req, res, opts });
289
- return;
302
+ return true;
290
303
  }
304
+ // Store-ready: under another store the MCP layer lists and runs only the tools ported to the port.
291
305
  if (method === 'POST' && path === '/mcp') {
292
- await refuseUnportedRoute(req, opts);
293
306
  await handleMcpPost(req, res, opts);
294
- return;
295
- }
296
- // Store-ready: the stream and its heartbeat only authenticate, and auth goes through the port.
297
- if (method === 'GET' && path === '/mcp/stream') {
298
- await handleMcpStream(req, res, opts, streamSlots);
299
- return;
307
+ return true;
300
308
  }
301
- res.writeHead(404, JSON_HEADERS);
302
- res.end(JSON.stringify({ error: 'not found' }));
309
+ return false;
303
310
  }
304
311
  function sendHealth(req, res, startedAt) {
305
312
  // Loopback callers (detectServer's stale-pidfile probe reads version and
@@ -387,7 +394,8 @@ function createStoreHolder(hippoRoot, store) {
387
394
  if (heldDb || stopHolding || !existsSync(getHippoDbPath(hippoRoot)))
388
395
  return;
389
396
  try {
390
- heldDb = openHippoDb(hippoRoot);
397
+ // The 'finish' listener can fire inside a request scope, which would close this connection with the request.
398
+ heldDb = outsideRequestStores(() => openHippoDb(hippoRoot));
391
399
  }
392
400
  catch (err) {
393
401
  stopHolding = true;
@@ -522,6 +530,9 @@ export async function serve(opts) {
522
530
  ...opts, routes, publicJsonBodies, store: opts.store ?? sqliteStore(opts.hippoRoot), callerLimiter, failedAuthLimiter,
523
531
  };
524
532
  const { kind } = served.store;
533
+ // A store other than hippo.db is a team's central server, so its folder's config.json must not decide shared-ness.
534
+ if (kind !== 'sqlite')
535
+ markSharedStore(opts.hippoRoot);
525
536
  const holder = createStoreHolder(opts.hippoRoot, served.store);
526
537
  const inflight = new Set();
527
538
  const server = createServer((req, res) => {
@@ -531,7 +542,7 @@ export async function serve(opts) {
531
542
  const requestId = resolveRequestId(req.headers['x-request-id']);
532
543
  requestIds.set(req, requestId);
533
544
  res.setHeader('X-Request-Id', requestId);
534
- const run = () => withBusyWait(SERVER_DB_WAIT_MS, () => handleRequest(req, res, served, startedAt, streamSlots, limiter));
545
+ const run = () => handleRequest(req, res, served, startedAt, streamSlots, limiter);
535
546
  // A missed port under another store would otherwise create and write a hippo.db that store never reads.
536
547
  (kind === 'sqlite' ? run() : withSqliteBlocked(kind, run)).catch((err) => replyOrClose(req, res, err, requestId));
537
548
  });
@@ -36,6 +36,8 @@ export interface StrengthenOptions {
36
36
  /** Strengthen what a read returned: update only the four retrieval columns on the live row, never a stale copy.
37
37
  * Best effort: a failure logs and never fails the read. Returns the ids found in this store. */
38
38
  export declare function strengthenRetrieved(hippoRoot: string, ids: readonly string[], opts: StrengthenOptions): Set<string>;
39
+ /** strengthenRetrieved on an open handle that holds no transaction, so a recall's last writes share one handle. */
40
+ export declare function strengthenRetrievedInOwnTx(db: DatabaseSyncLike, ids: readonly string[], opts: StrengthenOptions): Set<string>;
39
41
  /** strengthenRetrieved on the caller's handle, inside the caller's transaction. Throws; the caller decides. */
40
42
  export declare function strengthenRetrievedOn(db: DatabaseSyncLike, ids: readonly string[], opts: StrengthenOptions): Set<string>;
41
43
  /** Rewrites a live row's tags and its full-text row on the caller's transaction, with no audit row. */
@@ -1,5 +1,5 @@
1
1
  import { markRetrieved } from '../memory.js';
2
- import { closeHippoDb, openHippoDb, withWriteScope } from '../db.js';
2
+ import { closeHippoDb, openHippoDb, rethrowIfSqliteBlocked, withWriteScope } from '../db.js';
3
3
  import { RejectedValueError } from '../rejection.js';
4
4
  import { markSummaryDirtyInTx } from '../summary-dirty.js';
5
5
  import { log } from '../log.js';
@@ -73,30 +73,38 @@ export function writeEntryMirrors(hippoRoot, entry) {
73
73
  /** Strengthen what a read returned: update only the four retrieval columns on the live row, never a stale copy.
74
74
  * Best effort: a failure logs and never fails the read. Returns the ids found in this store. */
75
75
  export function strengthenRetrieved(hippoRoot, ids, opts) {
76
- const found = new Set();
77
76
  if (ids.length === 0 || opts.recallBoostAblated)
78
- return found;
77
+ return new Set();
79
78
  let db;
80
79
  try {
81
80
  db = openHippoDb(hippoRoot);
82
- db.exec('BEGIN IMMEDIATE');
83
- for (const id of strengthenRetrievedOn(db, ids, opts))
84
- found.add(id);
85
- db.exec('COMMIT');
86
81
  }
87
82
  catch (error) {
88
- try {
89
- db?.exec('ROLLBACK');
90
- }
91
- catch { /* already rolled back; keep the original error */ }
92
- log.warn(`retrieval stats not saved (${error instanceof Error ? error.message : String(error)})`);
93
- found.clear();
83
+ rethrowIfSqliteBlocked(error);
84
+ warnStrengthenFailed(error);
85
+ return new Set();
86
+ }
87
+ try {
88
+ return strengthenRetrievedInOwnTx(db, ids, opts);
94
89
  }
95
90
  finally {
96
- if (db)
97
- closeHippoDb(db);
91
+ closeHippoDb(db);
98
92
  }
99
- return found;
93
+ }
94
+ /** strengthenRetrieved on an open handle that holds no transaction, so a recall's last writes share one handle. */
95
+ export function strengthenRetrievedInOwnTx(db, ids, opts) {
96
+ if (ids.length === 0 || opts.recallBoostAblated)
97
+ return new Set();
98
+ try {
99
+ return withWriteScope(db, 'strengthen_retrieved', () => strengthenRetrievedOn(db, ids, opts));
100
+ }
101
+ catch (error) {
102
+ warnStrengthenFailed(error);
103
+ return new Set();
104
+ }
105
+ }
106
+ function warnStrengthenFailed(error) {
107
+ log.warn(`retrieval stats not saved (${error instanceof Error ? error.message : String(error)})`);
100
108
  }
101
109
  /** strengthenRetrieved on the caller's handle, inside the caller's transaction. Throws; the caller decides. */
102
110
  export function strengthenRetrievedOn(db, ids, opts) {
@@ -1,13 +1,82 @@
1
1
  import { type ApiKeyRecord } from './auth.js';
2
+ import { type AppendAuditOpts } from './audit.js';
3
+ import type { ContinuityBlock } from './api/recall-types.js';
4
+ import { type ActiveGoals, type GetActiveGoalsOpts, type GoalRecallLogRow } from './goals.js';
5
+ import type { MemoryEntry } from './memory.js';
6
+ import { type PlanningFallacyEvidence } from './predictions/planning-fallacy.js';
7
+ import { type RecallTraceInput } from './recall-trace.js';
8
+ import { type StrengthenOptions } from './store/entry-writes.js';
9
+ import { type OriginFilter } from './store/search-rows.js';
10
+ import { type ContinuityKey } from './store/sessions.js';
11
+ import { type TokenUse } from './token-ledger.js';
12
+ /** The arguments of `loadRecallSearchEntries` after the query, by name. */
13
+ export interface RecallSearchArgs {
14
+ readonly limit: number;
15
+ readonly tenantId?: string;
16
+ readonly requestedScope?: string;
17
+ readonly explicitScopeMode: 'exact' | 'additive';
18
+ readonly includeSuperseded: boolean;
19
+ readonly originProjects?: OriginFilter;
20
+ /** The caller's personal scope, which the default deny admits; undefined admits no personal row. */
21
+ readonly ownScope: string | undefined;
22
+ }
23
+ /** Everything one recall writes once its reply is decided, so a store writes it on one connection. */
24
+ export interface RecallWrites {
25
+ /** Insert or ignore on (memory_id, goal_id), so the first row wins, a duplicate inside this batch included. First drop a row
26
+ * whose memory_id is in no memories row of this store, whatever that row's tenant, as `localGoalRecallRows` does. */
27
+ readonly goalLog: readonly GoalRecallLogRow[];
28
+ /** In the order they happened. */
29
+ readonly audit: readonly AppendAuditOpts[];
30
+ readonly trace?: RecallTraceInput;
31
+ /** Nothing when `recallBoostAblated`. Each id found, in `tenantId` when set, gets the retrieval_count, last_retrieved,
32
+ * half_life_days and strength `markRetrieved` computes from the row read before any update, so a repeated id moves once. */
33
+ readonly strengthen?: {
34
+ readonly ids: readonly string[];
35
+ readonly opts: StrengthenOptions;
36
+ };
37
+ }
2
38
  /** What `serve()` reads and writes through. Each method is atomic and no transaction spans an await, since SQLite's lock wait blocks the event loop; a lock timeout throws `StoreBusyError`. */
3
39
  export interface HippoStore {
4
40
  /** 'sqlite' is hippo.db under the served root. Under any other kind, an unported route answers 501 and a hippo.db open inside a request throws. */
5
41
  readonly kind: string;
6
42
  /** The api_keys row for `keyId` with its scope grants, revoked or not; null when no row matches. */
7
43
  findApiKey(keyId: string): Promise<ApiKeyRecord | null>;
44
+ /** Recall candidates in `loadRecallSearchEntries` order: FTS, then LIKE, then every row in scope. */
45
+ searchRecallEntries(query: string, args: RecallSearchArgs): Promise<MemoryEntry[]>;
46
+ /** The rows among the first 500 ids, a tenant narrowing them, ordered by created, then content, then id, all ascending,
47
+ * as `loadEntriesByIds` does; the input order is ignored. */
48
+ entriesByIds(ids: readonly string[], tenantId?: string): Promise<MemoryEntry[]>;
49
+ /** The session's active goals and their policies, read together so a goal and its policy never disagree. */
50
+ activeGoals(opts: GetActiveGoalsOpts): Promise<ActiveGoals>;
51
+ /** The first `count` (at most 200) unsuperseded raw rows a tenant, a non-empty session id and non-null `origins` (those projects and
52
+ * user-global rows) must narrow, by created descending, then content and id ascending, as `loadFreshRawMemories` does. */
53
+ freshRawEntries(count: number, tenantId: string | undefined, sessionId: string | undefined, origins: readonly string[] | null): Promise<MemoryEntry[]>;
54
+ /** The newest active snapshot a non-null `key` must narrow to one owner and project (null only on an unshared hippo.db), then for its session
55
+ * the newest handoff (same key) and `eventLimit` newest events returned oldest first, each tie broken by the larger id; the caller applies scope. */
56
+ continuity(tenantId: string, eventLimit: number, key: ContinuityKey | null): Promise<ContinuityBlock>;
57
+ /** Resolves a forward claim's tokens to one class and reads its baserate, writing no audit row. */
58
+ planningFallacyEvidence(tenantId: string, classQueryTokens: readonly string[]): Promise<PlanningFallacyEvidence>;
59
+ /** Appends the rows in order, all or none. */
60
+ appendAuditEvents(events: readonly AppendAuditOpts[]): Promise<void>;
61
+ /** Writes the goal log and audit rows in one transaction and rejects with none written if it fails. Then the trace and
62
+ * the strengthen, each on its own: a failure there logs and still resolves, since the reply is already decided. */
63
+ finishRecall(writes: RecallWrites): Promise<void>;
64
+ /** Adds `recalled` to the one store-wide total_recalled counter, no tenant; the SQLite store also rewrites stats.json. */
65
+ bumpRecallStats(recalled: number): Promise<void>;
66
+ /** One token-ledger row. Throws, so the caller decides whether a ledger failure matters. */
67
+ recordTokens(use: TokenUse): Promise<void>;
8
68
  /** Releases the store's connections; `serve()` closes only a store it made itself. */
9
69
  close(): Promise<void>;
10
70
  }
11
- /** The built-in store: today's synchronous hippo.db functions behind the port, one handle per call, so close has nothing to release. */
71
+ /** The store a request runs on: the served one, else hippo.db under its root, as the CLI and SDK callers have it. */
72
+ export declare function storeFor(ctx: {
73
+ readonly hippoRoot: string;
74
+ readonly store?: HippoStore;
75
+ }): HippoStore;
76
+ /** The built-in store: today's synchronous hippo.db functions behind the port, each call on its own short-lived handles, so close has nothing to release. */
12
77
  export declare function sqliteStore(hippoRoot: string): HippoStore;
78
+ /** sqliteStore's continuity read, for the synchronous recall that cannot await the port. */
79
+ export declare function continuityAt(hippoRoot: string, tenantId: string, eventLimit: number, key: ContinuityKey | null): ContinuityBlock;
80
+ /** sqliteStore's finishRecall, for the synchronous recall that cannot await the port. */
81
+ export declare function finishRecallAt(hippoRoot: string, writes: RecallWrites): void;
13
82
  //# sourceMappingURL=store-port.d.ts.map