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
@@ -3,185 +3,44 @@ 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
+ function recallHistoryFor(ctx, sessionId) {
20
+ return sessionId && biasHintEnabled('anchoring') ? peekSessionRing('http', ctx.tenantId, sessionId) : undefined;
154
21
  }
155
22
  // GET /v1/memories?q=...&limit=...&mode=...&scope=...&include_continuity=1
156
23
  export async function handleRecallMemories({ req, res, opts, query }) {
157
- const parsed = parseRecallQuery(query);
158
- const { q, includeContinuity, sessionId } = parsed;
24
+ const { opts: recallOpts, limit, mode, explain } = parseRecallRequest(httpParams(query));
25
+ const { query: q, includeContinuity, sessionId } = recallOpts;
159
26
  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
- }
27
+ const recallHistory = recallHistoryFor(ctx, sessionId);
28
+ // Written first in the recall's own write, so a recall that fails leaves no row.
29
+ const leadingAudit = sessionId ? [] : anchorSkippedRows({ tenantId: ctx.tenantId, actor: ctx.actor.subject }, q);
30
+ const result = await retrieve(ctx, { ...recallOpts, limit, mode, explain, recallHistory, leadingAudit });
31
+ // The ring is created only after recall succeeds, so a 400 cannot LRU-evict a live session.
32
+ const ring = sessionRing('http', ctx.tenantId, sessionId);
33
+ if (ring)
34
+ noteRecall(ring, q, result.results[0]?.id ?? null, result.anchoringHint?.memoryId);
176
35
  // Each recall surface counts its own hits; api.recall is no chokepoint,
177
36
  // since the CLI never calls it and MCP shows the user a different band.
178
- updateStats(opts.hippoRoot, { recalled: result.results.length });
37
+ await storeFor(ctx).bumpRecallStats(result.results.length);
179
38
  // Continuity payloads should never be cached. The caller is asking for
180
39
  // session-state-aware data; intermediaries must not reuse it across users.
181
40
  if (includeContinuity) {
182
41
  res.setHeader('Cache-Control', 'no-store');
183
42
  }
184
- recordTokens(ctx, 'http_recall', { items: result.results.length, tokens: result.tokens + (result.continuityTokens ?? 0), sessionId: sessionId ?? null });
43
+ await recordTokens(ctx, 'http_recall', { items: result.results.length, tokens: result.tokens + (result.continuityTokens ?? 0), sessionId: sessionId ?? null });
185
44
  sendJson(res, 200, result);
186
45
  return;
187
46
  }
@@ -220,7 +79,7 @@ export async function handleAssembleSession({ req, res, opts, query }, assembleM
220
79
  if (scope !== undefined)
221
80
  assembleExtra.scope = scope;
222
81
  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 });
82
+ await recordTokens(ctx, 'http_assemble', { items: result.items.length, tokens: result.tokens, sessionId: assembleMatch.id });
224
83
  sendJson(res, 200, result);
225
84
  return;
226
85
  }
@@ -290,64 +149,14 @@ function contextReader(hippoRoot, query) {
290
149
  // via the Bearer. Pinned-only + '*' fallback skip the recall audit emit
291
150
  // (matches cmdContext); real-query hybrid search emits one 'recall' row.
292
151
  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';
152
+ const parsed = parseContextRequest(httpParams(query));
338
153
  const ctx = await buildContextWithAuth(req, opts);
339
154
  const result = await getContext(ctx, {
340
- q,
341
- budget,
342
- limit,
343
- pinnedOnly,
344
- scope,
345
- includeRecent,
346
- crossProject,
155
+ ...parsed,
347
156
  currentProject: contextReader(opts.hippoRoot, query),
348
157
  cost: contextCost('markdown', 'observe'), // clients render; the budget prices the block `hippo context` would print
349
158
  });
350
- recordTokens(ctx, 'http_context', { items: result.entries.length, tokens: result.tokens });
159
+ await recordTokens(ctx, 'http_context', { items: result.entries.length, tokens: result.tokens });
351
160
  sendJson(res, 200, result);
352
161
  return;
353
162
  }
package/dist/server.d.ts CHANGED
@@ -11,9 +11,25 @@ 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, type VectorReads } 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, RecallScopeFilter, VectorCandidateSpec } from './store/search-rows.js';
25
+ export type { ContinuityKey } from './store/sessions.js';
26
+ export type { TokenUse } from './token-ledger.js';
27
+ export type { EmbeddingIndexState } from './embeddings.js';
28
+ export type { PhysicsParticle } from './physics.js';
16
29
  export { StoreBusyError } from './db.js';
30
+ export { decodeVector, EMBEDDING_MODEL_META_KEY, rankVectorRows, type VectorMatch, type VectorRow } from './vector-store.js';
31
+ export { bufferToFloat32 } from './physics-state.js';
32
+ export { OTHER_STORE_MARKER, OtherStoreFolderError, withSqliteAllowed } from './db.js';
17
33
  export { authCreate, type AuthCreateOpts, type AuthCreateResult } from './api.js';
18
34
  export { isSharedStore } from './config.js';
19
35
  export { ownerOrSubject } from './api.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';
@@ -47,6 +48,11 @@ export { captureSessionTexts } from './capture/session-texts.js';
47
48
  // An add-on serves from another database by passing serve() its own HippoStore.
48
49
  export { sqliteStore } from './store-port.js';
49
50
  export { StoreBusyError } from './db.js';
51
+ // An add-on store decodes and ranks with hippo.db's own code, so both return the same ids in the same order.
52
+ export { decodeVector, EMBEDDING_MODEL_META_KEY, rankVectorRows } from './vector-store.js';
53
+ export { bufferToFloat32 } from './physics-state.js';
54
+ // store copy --db writes the marker and reads the old hippo.db under the waiver.
55
+ export { OTHER_STORE_MARKER, OtherStoreFolderError, withSqliteAllowed } from './db.js';
50
56
  // An add-on's install step mints the first admin key into a store folder it names, which `hippo auth create` cannot reach.
51
57
  export { authCreate } from './api.js';
52
58
  // An add-on that serves a team store checks the flag before it starts.
@@ -92,7 +98,7 @@ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
92
98
  const V1_ROUTES = [
93
99
  { method: 'POST', path: '/v1/memories', handler: handleCreateMemory },
94
100
  { method: 'GET', path: '/v1/graph', handler: handleGetGraph },
95
- { method: 'GET', path: '/v1/memories', handler: handleRecallMemories },
101
+ { method: 'GET', path: '/v1/memories', storeReady: true, handler: handleRecallMemories },
96
102
  { method: 'GET', pattern: '/v1/sessions/:id/assemble', handler: handleAssembleSession },
97
103
  { method: 'GET', pattern: '/v1/recall/drill/:id', handler: handleDrillRecall },
98
104
  { method: 'POST', pattern: '/v1/memories/:id/archive', handler: handleArchiveMemory },
@@ -238,10 +244,9 @@ function dispatchPublicJson({ res, opts }, method, path) {
238
244
  res.end(text);
239
245
  return true;
240
246
  }
241
- const NOT_ON_STORE_MESSAGE = 'not available on this store';
242
247
  function assertSqliteStore(opts) {
243
248
  if (opts.store.kind !== 'sqlite')
244
- throw new HttpError(501, NOT_ON_STORE_MESSAGE);
249
+ throw new HttpError(501, STORE_NOT_PORTED_MESSAGE);
245
250
  }
246
251
  /** 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
252
  async function refuseUnportedRoute(req, opts) {
@@ -264,12 +269,25 @@ async function handleRequest(req, res, opts, startedAt, streamSlots, limiter) {
264
269
  }
265
270
  enforceRateLimit(req, path, limiter);
266
271
  const routeRequest = { req, res, opts, query };
267
- if (await dispatchV1Route(routeRequest, method, path))
272
+ if (await runWithRequestStores(() => dispatchScopedRoute(routeRequest, method, path), { busyWaitMs: SERVER_DB_WAIT_MS }))
268
273
  return;
269
- if (dispatchPublicJson(routeRequest, method, path))
270
- return;
271
- if (await dispatchAddonRoute(routeRequest, method, path))
274
+ // Outside the request scope: the heartbeat timer outlives the request. Store-ready: the stream only authenticates, through the port.
275
+ if (method === 'GET' && path === '/mcp/stream') {
276
+ await handleMcpStream(req, res, opts, streamSlots);
272
277
  return;
278
+ }
279
+ res.writeHead(404, JSON_HEADERS);
280
+ res.end(JSON.stringify({ error: 'not found' }));
281
+ }
282
+ /** 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. */
283
+ async function dispatchScopedRoute(r, method, path) {
284
+ if (await dispatchV1Route(r, method, path))
285
+ return true;
286
+ if (dispatchPublicJson(r, method, path))
287
+ return true;
288
+ if (await dispatchAddonRoute(r, method, path))
289
+ return true;
290
+ const { req, res, opts } = r;
273
291
  if (method === 'POST' && path === '/v1/connectors/slack/events') {
274
292
  // Bearer auth deliberately skipped: this route is in PUBLIC_ROUTES and authenticates via the Slack HMAC signature.
275
293
  if (!isPublicRoute(method, path)) {
@@ -278,7 +296,7 @@ async function handleRequest(req, res, opts, startedAt, streamSlots, limiter) {
278
296
  }
279
297
  assertSqliteStore(opts);
280
298
  await handleSlackEventsWebhook({ req, res, opts });
281
- return;
299
+ return true;
282
300
  }
283
301
  if (method === 'POST' && path === '/v1/connectors/github/events') {
284
302
  if (!isPublicRoute(method, path)) {
@@ -286,20 +304,14 @@ async function handleRequest(req, res, opts, startedAt, streamSlots, limiter) {
286
304
  }
287
305
  assertSqliteStore(opts);
288
306
  await handleGitHubEventsWebhook({ req, res, opts });
289
- return;
307
+ return true;
290
308
  }
309
+ // Store-ready: under another store the MCP layer lists and runs only the tools ported to the port.
291
310
  if (method === 'POST' && path === '/mcp') {
292
- await refuseUnportedRoute(req, opts);
293
311
  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;
312
+ return true;
300
313
  }
301
- res.writeHead(404, JSON_HEADERS);
302
- res.end(JSON.stringify({ error: 'not found' }));
314
+ return false;
303
315
  }
304
316
  function sendHealth(req, res, startedAt) {
305
317
  // Loopback callers (detectServer's stale-pidfile probe reads version and
@@ -387,7 +399,8 @@ function createStoreHolder(hippoRoot, store) {
387
399
  if (heldDb || stopHolding || !existsSync(getHippoDbPath(hippoRoot)))
388
400
  return;
389
401
  try {
390
- heldDb = openHippoDb(hippoRoot);
402
+ // The 'finish' listener can fire inside a request scope, which would close this connection with the request.
403
+ heldDb = outsideRequestStores(() => openHippoDb(hippoRoot));
391
404
  }
392
405
  catch (err) {
393
406
  stopHolding = true;
@@ -522,6 +535,9 @@ export async function serve(opts) {
522
535
  ...opts, routes, publicJsonBodies, store: opts.store ?? sqliteStore(opts.hippoRoot), callerLimiter, failedAuthLimiter,
523
536
  };
524
537
  const { kind } = served.store;
538
+ // A store other than hippo.db is a team's central server, so its folder's config.json must not decide shared-ness.
539
+ if (kind !== 'sqlite')
540
+ markSharedStore(opts.hippoRoot);
525
541
  const holder = createStoreHolder(opts.hippoRoot, served.store);
526
542
  const inflight = new Set();
527
543
  const server = createServer((req, res) => {
@@ -531,7 +547,7 @@ export async function serve(opts) {
531
547
  const requestId = resolveRequestId(req.headers['x-request-id']);
532
548
  requestIds.set(req, requestId);
533
549
  res.setHeader('X-Request-Id', requestId);
534
- const run = () => withBusyWait(SERVER_DB_WAIT_MS, () => handleRequest(req, res, served, startedAt, streamSlots, limiter));
550
+ const run = () => handleRequest(req, res, served, startedAt, streamSlots, limiter);
535
551
  // A missed port under another store would otherwise create and write a hippo.db that store never reads.
536
552
  (kind === 'sqlite' ? run() : withSqliteBlocked(kind, run)).catch((err) => replyOrClose(req, res, err, requestId));
537
553
  });
@@ -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) {
@@ -25,9 +25,10 @@ export function initStore(hippoRoot) {
25
25
  }
26
26
  /** One open connection with init done on it, for callers who used to pay for `initStore` + a second `openHippoDb`. */
27
27
  export function openStore(hippoRoot, opts) {
28
- ensureMirrorDirectories(hippoRoot);
28
+ // Open first: a folder marked for another store must refuse before any mirror folder appears.
29
29
  const db = openHippoDb(hippoRoot, opts);
30
30
  try {
31
+ ensureMirrorDirectories(hippoRoot);
31
32
  const bootstrapped = bootstrapLegacyStore(db, hippoRoot);
32
33
  if (bootstrapped) {
33
34
  syncMirrorFiles(hippoRoot, db);
@@ -1,13 +1,99 @@
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 EmbeddingIndexState } from './embeddings.js';
5
+ import { type ActiveGoals, type GetActiveGoalsOpts, type GoalRecallLogRow } from './goals.js';
6
+ import type { MemoryEntry } from './memory.js';
7
+ import type { PhysicsParticle } from './physics.js';
8
+ import { type PlanningFallacyEvidence } from './predictions/planning-fallacy.js';
9
+ import { type RecallTraceInput } from './recall-trace.js';
10
+ import { type StrengthenOptions } from './store/entry-writes.js';
11
+ import { type OriginFilter, type VectorCandidateSpec } from './store/search-rows.js';
12
+ import { type ContinuityKey } from './store/sessions.js';
13
+ import { type TokenUse } from './token-ledger.js';
14
+ /** The arguments of `loadRecallSearchEntries` after the query, by name. */
15
+ export interface RecallSearchArgs {
16
+ readonly limit: number;
17
+ readonly tenantId?: string;
18
+ readonly requestedScope?: string;
19
+ readonly explicitScopeMode: 'exact' | 'additive';
20
+ readonly includeSuperseded: boolean;
21
+ readonly originProjects?: OriginFilter;
22
+ /** The caller's personal scope, which the default deny admits; undefined admits no personal row. */
23
+ readonly ownScope: string | undefined;
24
+ }
25
+ /** Everything one recall writes once its reply is decided, so a store writes it on one connection. */
26
+ export interface RecallWrites {
27
+ /** Insert or ignore on (memory_id, goal_id), so the first row wins, a duplicate inside this batch included. First drop a row
28
+ * whose memory_id is in no memories row of this store, whatever that row's tenant, as `localGoalRecallRows` does. */
29
+ readonly goalLog: readonly GoalRecallLogRow[];
30
+ /** In the order they happened. */
31
+ readonly audit: readonly AppendAuditOpts[];
32
+ readonly trace?: RecallTraceInput;
33
+ /** Nothing when `recallBoostAblated`. Each id found, in `tenantId` when set, gets the retrieval_count, last_retrieved,
34
+ * half_life_days and strength `markRetrieved` computes from the row read before any update, so a repeated id moves once. */
35
+ readonly strengthen?: {
36
+ readonly ids: readonly string[];
37
+ readonly opts: StrengthenOptions;
38
+ };
39
+ }
40
+ /** The reads behind recall's vector arm, one group a store sets in full as `HippoStore.vectors` or leaves unset. */
41
+ export interface VectorReads {
42
+ /** The stored model and whether any vector exists, in one snapshot. */
43
+ embeddingIndexState(): Promise<EmbeddingIndexState>;
44
+ storedVectors(ids: readonly string[]): Promise<Map<string, number[]>>;
45
+ /** The `spec.limit ?? 50` rows nearest `queryVector` by `rankVectorRows` among those where the tenant matches (when set), kind is not 'archived', a superseded row
46
+ * shows only with `includeSuperseded`, scope follows `RecallScopeFilter`, and origin is '' or listed (when `spec.origin` is set). */
47
+ nearestEntries(queryVector: readonly number[], spec: VectorCandidateSpec): Promise<MemoryEntry[]>;
48
+ /** The particles of `ids` only; an empty list reads nothing. */
49
+ physicsParticles(ids: readonly string[]): Promise<Map<string, PhysicsParticle>>;
50
+ }
2
51
  /** 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
52
  export interface HippoStore {
4
53
  /** '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
54
  readonly kind: string;
6
55
  /** The api_keys row for `keyId` with its scope grants, revoked or not; null when no row matches. */
7
56
  findApiKey(keyId: string): Promise<ApiKeyRecord | null>;
57
+ /** Recall candidates in `loadRecallSearchEntries` order: FTS, then LIKE, then every row in scope. */
58
+ searchRecallEntries(query: string, args: RecallSearchArgs): Promise<MemoryEntry[]>;
59
+ /** The rows among the first 500 ids, a tenant narrowing them, ordered by created, then content, then id, all ascending,
60
+ * as `loadEntriesByIds` does; the input order is ignored. */
61
+ entriesByIds(ids: readonly string[], tenantId?: string): Promise<MemoryEntry[]>;
62
+ /** The session's active goals and their policies, read together so a goal and its policy never disagree. */
63
+ activeGoals(opts: GetActiveGoalsOpts): Promise<ActiveGoals>;
64
+ /** The first `count` (at most 200) unsuperseded raw rows a tenant, a non-empty session id and non-null `origins` (those projects and
65
+ * user-global rows) must narrow, by created descending, then content and id ascending, as `loadFreshRawMemories` does. */
66
+ freshRawEntries(count: number, tenantId: string | undefined, sessionId: string | undefined, origins: readonly string[] | null): Promise<MemoryEntry[]>;
67
+ /** 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
68
+ * the newest handoff (same key) and `eventLimit` newest events returned oldest first, each tie broken by the larger id; the caller applies scope. */
69
+ continuity(tenantId: string, eventLimit: number, key: ContinuityKey | null): Promise<ContinuityBlock>;
70
+ /** Resolves a forward claim's tokens to one class and reads its baserate, writing no audit row. */
71
+ planningFallacyEvidence(tenantId: string, classQueryTokens: readonly string[]): Promise<PlanningFallacyEvidence>;
72
+ /** Appends the rows in order, all or none. */
73
+ appendAuditEvents(events: readonly AppendAuditOpts[]): Promise<void>;
74
+ /** Writes the goal log and audit rows in one transaction and rejects with none written if it fails. Then the trace and
75
+ * the strengthen, each on its own: a failure there logs and still resolves, since the reply is already decided. */
76
+ finishRecall(writes: RecallWrites): Promise<void>;
77
+ /** Adds `recalled` to the one store-wide total_recalled counter, no tenant; the SQLite store also rewrites stats.json. */
78
+ bumpRecallStats(recalled: number): Promise<void>;
79
+ /** One token-ledger row. Throws, so the caller decides whether a ledger failure matters. */
80
+ recordTokens(use: TokenUse): Promise<void>;
81
+ /** Unset on a store built before them, where hybrid and physics recall under an embedding provider answer 501. */
82
+ readonly vectors?: VectorReads;
8
83
  /** Releases the store's connections; `serve()` closes only a store it made itself. */
9
84
  close(): Promise<void>;
10
85
  }
11
- /** The built-in store: today's synchronous hippo.db functions behind the port, one handle per call, so close has nothing to release. */
12
- export declare function sqliteStore(hippoRoot: string): HippoStore;
86
+ /** The store a request runs on: the served one, else hippo.db under its root, as the CLI and SDK callers have it. */
87
+ export declare function storeFor(ctx: {
88
+ readonly hippoRoot: string;
89
+ readonly store?: HippoStore;
90
+ }): HippoStore;
91
+ /** 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. */
92
+ export declare function sqliteStore(hippoRoot: string): HippoStore & {
93
+ readonly vectors: VectorReads;
94
+ };
95
+ /** sqliteStore's continuity read, for the synchronous recall that cannot await the port. */
96
+ export declare function continuityAt(hippoRoot: string, tenantId: string, eventLimit: number, key: ContinuityKey | null): ContinuityBlock;
97
+ /** sqliteStore's finishRecall, for the synchronous recall that cannot await the port. */
98
+ export declare function finishRecallAt(hippoRoot: string, writes: RecallWrites): void;
13
99
  //# sourceMappingURL=store-port.d.ts.map