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
@@ -20,10 +20,10 @@ import { dropHeldCopies } from '../same-text.js';
20
20
  import { isGlobalStoreRoot } from '../project-identity.js';
21
21
  import { detectScope } from '../scope.js';
22
22
  import { getGlobalRoot } from '../shared.js';
23
- import { auditQueryFields } from '../audit.js';
24
23
  import * as api from '../api.js';
25
24
  import { computePlanningFallacyOutput } from '../predictions/planning-fallacy.js';
26
- import { detectAnchoring, hashQueryText, biasHintEnabled, buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, } from '../recall-history.js';
25
+ import { detectAnchoring, hashQueryText, biasHintEnabled, snapshotRing } from '../recall-history.js';
26
+ import { appendRecallAudit, recallAuditMetadata, recordShownRecall, resetSessionRings, sessionRing } from '../api/recall-record.js';
27
27
  import { detectAvailabilityBias } from '../availability.js';
28
28
  import { resolveTenantId } from '../tenant.js';
29
29
  import { MAX_HOPS, DEFAULT_MAX_NEIGHBORS } from '../graph-recall.js';
@@ -34,13 +34,12 @@ import { isClefModel } from '../rerankers/clef.js';
34
34
  import { handoffText, printedTokens, sessionTrailText, settleTokens, snapshotText } from '../context-render.js';
35
35
  import { withLedgerDb } from '../ledger-db.js';
36
36
  import { printError } from './output.js';
37
- import { parseLimitFlag, parseBudgetFlag, emitCliAudit, requireInit, recallEntryText, recallHeading, parseAsOfFlag, engineFlags, printActiveTaskSnapshot, printSessionEvents, printHandoff, hostSessionId, captureConsole, hookStoreRoot, } from './shared.js';
37
+ import { parseLimitFlag, parseBudgetFlag, requireInit, recallEntryText, recallHeading, parseAsOfFlag, engineFlags, printActiveTaskSnapshot, printSessionEvents, printHandoff, hostSessionId, captureConsole, hookStoreRoot, } from './shared.js';
38
38
  // Per-process rings: a single-shot `hippo recall` starts empty, so anchoring only accumulates in long-lived
39
39
  // hosts (in-process loops, `hippo serve`, the MCP server).
40
- const sessionRecallHistoryCli = new Map();
41
- /** Test-only: reset the module-level recall-history Map. Call from beforeEach. */
40
+ /** Test-only: reset the CLI recall rings. Call from beforeEach. */
42
41
  export function __resetSessionRecallHistoryCli() {
43
- sessionRecallHistoryCli.clear();
42
+ resetSessionRings('cli');
44
43
  }
45
44
  // JSON.stringify keeps quotes or parens in the matched phrase from blurring the line.
46
45
  function planningLine(p) {
@@ -185,7 +184,7 @@ export async function cmdRecall(hippoRoot, query, flags) {
185
184
  const o = parseRecallOptions(hippoRoot, flags);
186
185
  const ranked = await rankForRecall(hippoRoot, query, flags, o);
187
186
  const fit = fitRecallBlock(hippoRoot, query, o, ranked, loadRecallContinuity(hippoRoot, o));
188
- auditRecall(hippoRoot, o.globalRoot, query, fit);
187
+ auditRecall(hippoRoot, o, query, fit);
189
188
  writeRecallResult(hippoRoot, query, o, fit, ranked.localIndex);
190
189
  }
191
190
  /** Every flag recall reads, parsed in the order the single-body command checked them. */
@@ -281,9 +280,7 @@ function loadRecallContinuity(hippoRoot, o) {
281
280
  function recallHinter(query, o, rank) {
282
281
  const { tenantId, sessionId } = o;
283
282
  // HIPPO_ANCHORING=off and HIPPO_AVAILABILITY=off skip the work entirely.
284
- const anchorRing = biasHintEnabled('anchoring') && sessionId
285
- ? getOrCreateRing(sessionRecallHistoryCli, buildSessionKey(tenantId, sessionId))
286
- : null;
283
+ const anchorRing = sessionRing('cli', tenantId, sessionId);
287
284
  const queryHash = hashQueryText(query);
288
285
  const availabilityPool = biasHintEnabled('availability')
289
286
  ? [...rank.localEntries, ...rank.globalEntries].map((e) => ({ id: e.id, created: e.created }))
@@ -304,7 +301,7 @@ function recallHinter(query, o, rank) {
304
301
  });
305
302
  return { anchoring, availability, summary };
306
303
  };
307
- return { anchorRing, queryHash, hintsFor };
304
+ return { anchorRing, hintsFor };
308
305
  }
309
306
  function renderRecallBlock(list, h, v) {
310
307
  const { query, showWhy, showPlan, planText, hasContinuity, entryText } = v;
@@ -388,7 +385,7 @@ function fitRecallBlock(hippoRoot, query, o, ranked, loaded) {
388
385
  const shown = (n) => dropHeldCopies(fitted.slice(0, n), (r) => r.entry);
389
386
  let kept = fitted.length;
390
387
  let results = shown(kept);
391
- const { anchorRing, queryHash, hintsFor } = recallHinter(query, o, ranked.rank);
388
+ const { anchorRing, hintsFor } = recallHinter(query, o, ranked.rank);
392
389
  const view = { query, showWhy: o.showWhy, showPlan, planText, hasContinuity, continuity, entryText: ranked.entryText };
393
390
  let hints = hintsFor(results, kept - results.length);
394
391
  let recallText = renderRecallBlock(results, hints, view);
@@ -399,45 +396,17 @@ function fitRecallBlock(hippoRoot, query, o, ranked, loaded) {
399
396
  hints = hintsFor(results, kept - results.length);
400
397
  recallText = renderRecallBlock(results, hints, view);
401
398
  }
402
- return { results, hints, recallText, continuity, continuityTokens, cmdPlanningFallacyHint, cmdPlanningFallacyWatching, anchorRing, queryHash };
399
+ return { results, hints, recallText, continuity, continuityTokens, cmdPlanningFallacyHint, cmdPlanningFallacyWatching, anchorRing };
403
400
  }
404
- function auditRecall(hippoRoot, globalRoot, query, fit) {
405
- const { results, anchorRing, queryHash } = fit;
406
- const { anchoring: cmdAnchoringHint, availability: cmdAvailabilityHint } = fit.hints;
407
- if (anchorRing) {
408
- // Appended after every detect: anchoredOn feeds the cooldown for the next recall on this session.
409
- appendRecall(anchorRing, queryHash, results[0]?.entry.id ?? null, cmdAnchoringHint?.memoryId);
410
- }
411
- else if (biasHintEnabled('anchoring')) {
412
- // SHA-256/16 per the recall-audit convention; hashQueryText is FNV-1a and brute-forceable on short queries.
413
- emitCliAudit(hippoRoot, 'recall_anchor_skipped_no_session', undefined, auditQueryFields(query));
414
- }
415
- if (cmdAnchoringHint?.reason === 'memory_dominance') {
416
- emitCliAudit(hippoRoot, 'recall_anchor_detected_memory_dominance', cmdAnchoringHint.memoryId, {
417
- memory_id: cmdAnchoringHint.memoryId,
418
- query_count: cmdAnchoringHint.queryCount ?? null,
419
- });
420
- }
421
- else if (cmdAnchoringHint?.reason === 'query_repeat') {
422
- emitCliAudit(hippoRoot, 'recall_anchor_detected_query_repeat', cmdAnchoringHint.memoryId, {
423
- memory_id: cmdAnchoringHint.memoryId,
424
- });
425
- }
426
- if (cmdAvailabilityHint) {
427
- emitCliAudit(hippoRoot, 'recall_availability_detected', undefined, {
428
- recent_fraction: cmdAvailabilityHint.recentFraction,
429
- older_passed_over: cmdAvailabilityHint.olderCandidatesPassedOver,
430
- returned_count: cmdAvailabilityHint.returnedCount,
431
- });
432
- }
401
+ function auditRecall(hippoRoot, o, query, fit) {
402
+ const { results, hints } = fit;
403
+ const who = { hippoRoot, tenantId: o.tenantId, actor: 'cli', bestEffort: true };
404
+ recordShownRecall(who, { query, ring: fit.anchorRing, topId: results[0]?.entry.id ?? null, anchoring: hints.anchoring, availability: hints.availability });
433
405
  // One 'recall' event per query, before the early-empty return, in every participating store.
434
- const recallMetadata = {
435
- ...auditQueryFields(query),
436
- results: results.length,
437
- };
438
- emitCliAudit(hippoRoot, 'recall', undefined, recallMetadata);
439
- if (isInitialized(globalRoot) && globalRoot !== hippoRoot) {
440
- emitCliAudit(globalRoot, 'recall', undefined, recallMetadata);
406
+ const metadata = recallAuditMetadata(query, results.length);
407
+ appendRecallAudit(who, 'recall', undefined, metadata);
408
+ if (isInitialized(o.globalRoot) && o.globalRoot !== hippoRoot) {
409
+ appendRecallAudit({ ...who, hippoRoot: o.globalRoot }, 'recall', undefined, metadata);
441
410
  }
442
411
  }
443
412
  /** Traces the recall, books retrieval for a non-empty list, then prints JSON or the fitted block. */
@@ -10,7 +10,7 @@ import { confidenceLabel } from '../memory.js';
10
10
  import { isInitialized } from '../store/open.js';
11
11
  import { explainMatch } from '../search/explain.js';
12
12
  import { isSharedStore } from '../config.js';
13
- import { openHippoDb, closeHippoDb, isSqliteBusy, noteStoreBusy, withSharedStoreHandles, HOOK_DB_WAIT_MS } from '../db.js';
13
+ import { openHippoDb, closeHippoDb, isSqliteBusy, noteStoreBusy, runWithRequestStores, HOOK_DB_WAIT_MS } from '../db.js';
14
14
  import { withLedgerDb } from '../ledger-db.js';
15
15
  import { sessionPilotArm } from '../pilot-arm.js';
16
16
  import { hookPayloadSessionId, isSubagentPayload, recordTokenUse } from '../token-ledger.js';
@@ -474,7 +474,7 @@ export function resolveAuthRoot(hippoRoot, flags) {
474
474
  /** Hook commands share one handle per store and wait at most HOOK_DB_WAIT_MS for a lock; a store still busy after that skips the hook's work with one warning, exit 0. */
475
475
  export async function runHookWithStores(fn) {
476
476
  try {
477
- return await withSharedStoreHandles(fn, { busyWaitMs: HOOK_DB_WAIT_MS });
477
+ return await runWithRequestStores(fn, { busyWaitMs: HOOK_DB_WAIT_MS, failFastWhenBusy: true });
478
478
  }
479
479
  catch (error) {
480
480
  if (!isSqliteBusy(error))
package/dist/cli.d.ts CHANGED
@@ -54,14 +54,17 @@ export declare const COMMANDS: {
54
54
  };
55
55
  recall: {
56
56
  run: (c: CommandContext) => Promise<void>;
57
+ scoped: true;
57
58
  usage: string[];
58
59
  };
59
60
  drill: {
60
61
  run: (c: CommandContext) => Promise<void>;
62
+ scoped: true;
61
63
  usage: string[];
62
64
  };
63
65
  assemble: {
64
66
  run: (c: CommandContext) => Promise<void>;
67
+ scoped: true;
65
68
  usage: string[];
66
69
  };
67
70
  supersede: {
@@ -70,6 +73,7 @@ export declare const COMMANDS: {
70
73
  };
71
74
  explain: {
72
75
  run: (c: CommandContext) => Promise<void>;
76
+ scoped: true;
73
77
  usage: string[];
74
78
  };
75
79
  eval: {
@@ -86,6 +90,7 @@ export declare const COMMANDS: {
86
90
  };
87
91
  sleep: {
88
92
  run: ({ hippoRoot, flags }: CommandContext) => Promise<void>;
93
+ scoped: true;
89
94
  usage: string[];
90
95
  };
91
96
  'last-sleep': {
@@ -134,10 +139,12 @@ export declare const COMMANDS: {
134
139
  };
135
140
  auth: {
136
141
  run: ({ hippoRoot, args, flags }: CommandContext) => Promise<void>;
142
+ scoped: true;
137
143
  usage: string[];
138
144
  };
139
145
  goal: {
140
146
  run: ({ hippoRoot, args, flags }: CommandContext) => Promise<void>;
147
+ scoped: true;
141
148
  usage: string[];
142
149
  };
143
150
  slack: {
@@ -150,6 +157,7 @@ export declare const COMMANDS: {
150
157
  };
151
158
  audit: {
152
159
  run: (c: CommandContext) => Promise<void>;
160
+ scoped: true;
153
161
  usage: string[];
154
162
  };
155
163
  'correction-latency': {
@@ -166,6 +174,7 @@ export declare const COMMANDS: {
166
174
  };
167
175
  outcome: {
168
176
  run: ({ hippoRoot, flags }: CommandContext) => Promise<void>;
177
+ scoped: true;
169
178
  usage: string[];
170
179
  };
171
180
  conflicts: {
@@ -190,6 +199,7 @@ export declare const COMMANDS: {
190
199
  };
191
200
  dormant: {
192
201
  run: ({ hippoRoot, args, flags }: CommandContext) => Promise<void>;
202
+ scoped: true;
193
203
  usage: string[];
194
204
  };
195
205
  projects: {
@@ -198,14 +208,17 @@ export declare const COMMANDS: {
198
208
  };
199
209
  quarantine: {
200
210
  run: ({ hippoRoot, args, flags }: CommandContext) => Promise<void>;
211
+ scoped: true;
201
212
  usage: string[];
202
213
  };
203
214
  tokens: {
204
215
  run: ({ hippoRoot, flags }: CommandContext) => Promise<void>;
216
+ scoped: true;
205
217
  usage: string[];
206
218
  };
207
219
  failures: {
208
220
  run: ({ hippoRoot, flags }: CommandContext) => Promise<void>;
221
+ scoped: true;
209
222
  usage: string[];
210
223
  };
211
224
  doctor: {
@@ -242,6 +255,7 @@ export declare const COMMANDS: {
242
255
  };
243
256
  forget: {
244
257
  run: (c: CommandContext) => Promise<void>;
258
+ scoped: true;
245
259
  usage: string[];
246
260
  };
247
261
  inspect: {
@@ -274,10 +288,12 @@ export declare const COMMANDS: {
274
288
  };
275
289
  learn: {
276
290
  run: ({ hippoRoot, flags }: CommandContext) => Promise<void>;
291
+ scoped: true;
277
292
  usage: string[];
278
293
  };
279
294
  promote: {
280
295
  run: (c: CommandContext) => Promise<void>;
296
+ scoped: true;
281
297
  usage: string[];
282
298
  };
283
299
  sync: {
package/dist/cli.js CHANGED
@@ -209,14 +209,17 @@ export const COMMANDS = {
209
209
  },
210
210
  recall: {
211
211
  run: async (c) => { await (await import('./cli/recall.js')).handleRecall(c); },
212
+ scoped: true,
212
213
  usage: VERB_USAGE.recall,
213
214
  },
214
215
  drill: {
215
216
  run: async (c) => { await (await import('./cli/dag.js')).handleDrill(c); },
217
+ scoped: true,
216
218
  usage: VERB_USAGE.drill,
217
219
  },
218
220
  assemble: {
219
221
  run: async (c) => { await (await import('./cli/dag.js')).handleAssemble(c); },
222
+ scoped: true,
220
223
  usage: VERB_USAGE.assemble,
221
224
  },
222
225
  supersede: {
@@ -225,6 +228,7 @@ export const COMMANDS = {
225
228
  },
226
229
  explain: {
227
230
  run: async (c) => { await (await import('./cli/explain.js')).handleExplain(c); },
231
+ scoped: true,
228
232
  usage: VERB_USAGE.explain,
229
233
  },
230
234
  eval: {
@@ -241,6 +245,7 @@ export const COMMANDS = {
241
245
  },
242
246
  sleep: {
243
247
  run: async ({ hippoRoot, flags }) => { await (await import('./cli/sleep.js')).cmdSleep(hippoRoot, flags); },
248
+ scoped: true,
244
249
  usage: VERB_USAGE.sleep,
245
250
  },
246
251
  'last-sleep': {
@@ -289,10 +294,12 @@ export const COMMANDS = {
289
294
  },
290
295
  auth: {
291
296
  run: async ({ hippoRoot, args, flags }) => { (await import('./cli/auth.js')).cmdAuth(hippoRoot, args, flags); },
297
+ scoped: true,
292
298
  usage: VERB_USAGE.auth,
293
299
  },
294
300
  goal: {
295
301
  run: async ({ hippoRoot, args, flags }) => { (await import('./cli/goals.js')).cmdGoal(hippoRoot, args, flags); },
302
+ scoped: true,
296
303
  usage: VERB_USAGE.goal,
297
304
  },
298
305
  slack: {
@@ -305,6 +312,7 @@ export const COMMANDS = {
305
312
  },
306
313
  audit: {
307
314
  run: async (c) => { await (await import('./cli/audit.js')).handleAudit(c); },
315
+ scoped: true,
308
316
  usage: VERB_USAGE.audit,
309
317
  },
310
318
  'correction-latency': {
@@ -321,6 +329,7 @@ export const COMMANDS = {
321
329
  },
322
330
  outcome: {
323
331
  run: async ({ hippoRoot, flags }) => { (await import('./cli/curate.js')).cmdOutcome(hippoRoot, flags); },
332
+ scoped: true,
324
333
  usage: VERB_USAGE.outcome,
325
334
  },
326
335
  conflicts: {
@@ -345,6 +354,7 @@ export const COMMANDS = {
345
354
  },
346
355
  dormant: {
347
356
  run: async ({ hippoRoot, args, flags }) => { (await import('./cli/curate.js')).cmdDormant(hippoRoot, args, flags); },
357
+ scoped: true,
348
358
  usage: VERB_USAGE.dormant,
349
359
  },
350
360
  projects: {
@@ -353,14 +363,17 @@ export const COMMANDS = {
353
363
  },
354
364
  quarantine: {
355
365
  run: async ({ hippoRoot, args, flags }) => { (await import('./cli/curate.js')).cmdQuarantine(hippoRoot, args, flags); },
366
+ scoped: true,
356
367
  usage: VERB_USAGE.quarantine,
357
368
  },
358
369
  tokens: {
359
370
  run: async ({ hippoRoot, flags }) => { (await import('./cli/status.js')).cmdTokens(hippoRoot, flags); },
371
+ scoped: true,
360
372
  usage: VERB_USAGE.tokens,
361
373
  },
362
374
  failures: {
363
375
  run: async ({ hippoRoot, flags }) => { (await import('./cli/status.js')).cmdFailures(hippoRoot, flags); },
376
+ scoped: true,
364
377
  usage: VERB_USAGE.failures,
365
378
  },
366
379
  doctor: {
@@ -397,6 +410,7 @@ export const COMMANDS = {
397
410
  },
398
411
  forget: {
399
412
  run: async (c) => { await (await import('./cli/curate.js')).handleForget(c); },
413
+ scoped: true,
400
414
  usage: VERB_USAGE.forget,
401
415
  },
402
416
  inspect: {
@@ -429,10 +443,12 @@ export const COMMANDS = {
429
443
  },
430
444
  learn: {
431
445
  run: async ({ hippoRoot, flags }) => { (await import('./cli/transfer.js')).cmdLearn(hippoRoot, flags); },
446
+ scoped: true,
432
447
  usage: VERB_USAGE.learn,
433
448
  },
434
449
  promote: {
435
450
  run: async (c) => { await (await import('./cli/transfer.js')).handlePromote(c); },
451
+ scoped: true,
436
452
  usage: VERB_USAGE.promote,
437
453
  },
438
454
  sync: {
@@ -630,7 +646,8 @@ async function main(command, args, flags, hippoRoot) {
630
646
  printUsage();
631
647
  process.exit(1);
632
648
  }
633
- await spec.run({ hippoRoot, args, flags });
649
+ const run = () => spec.run({ hippoRoot, args, flags });
650
+ await (spec.scoped ? (await import('./db/request-stores.js')).runWithRequestStores(run) : run());
634
651
  }
635
652
  export async function runCli(argv = process.argv) {
636
653
  const { command, args, flags } = parseArgs(argv);
package/dist/config.d.ts CHANGED
@@ -159,6 +159,8 @@ export declare function loadConfig(hippoRoot: string): HippoConfig;
159
159
  /** True when the store's config.json sets `"sharedStore": true`. Once true, a root stays true for this process,
160
160
  * so a broken edit while a server runs cannot turn it off. Reads only this key: loadConfig has no cache. */
161
161
  export declare function isSharedStore(hippoRoot: string): boolean;
162
+ /** Reads the root as shared for the rest of this process, whatever its config.json says. */
163
+ export declare function markSharedStore(hippoRoot: string): void;
162
164
  /** Test seam: forget every root seen as shared. */
163
165
  export declare function _resetSharedStoreCacheForTests(): void;
164
166
  //# sourceMappingURL=config.d.ts.map
package/dist/config.js CHANGED
@@ -321,6 +321,10 @@ export function isSharedStore(hippoRoot) {
321
321
  sharedStoreRoots.add(key);
322
322
  return true;
323
323
  }
324
+ /** Reads the root as shared for the rest of this process, whatever its config.json says. */
325
+ export function markSharedStore(hippoRoot) {
326
+ sharedStoreRoots.add(sharedStoreKey(hippoRoot));
327
+ }
324
328
  /** Test seam: forget every root seen as shared. */
325
329
  export function _resetSharedStoreCacheForTests() {
326
330
  sharedStoreRoots.clear();
@@ -0,0 +1,5 @@
1
+ import { type DatabaseSyncLike } from './sqlite.js';
2
+ export declare function getHippoDbPath(hippoRoot: string): string;
3
+ /** A new connection with the store's pragmas, migrations and mirror cleanup applied; the caller owns and closes it. */
4
+ export declare function connectHippoDb(hippoRoot: string, busyWaitMs?: number): DatabaseSyncLike;
5
+ //# sourceMappingURL=connect.d.ts.map
@@ -0,0 +1,52 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { cleanupArchivedMirrors } from '../raw-archive-mirror-cleanup.js';
4
+ import { log } from '../log.js';
5
+ import { DatabaseSync } from './sqlite.js';
6
+ import { execWithBusyRetry } from './busy.js';
7
+ import { runMigrations } from './migrate.js';
8
+ export function getHippoDbPath(hippoRoot) {
9
+ return path.join(hippoRoot, 'hippo.db');
10
+ }
11
+ // Owner-only on create; SQLite gives the WAL and SHM files the db's mode. Existing paths keep theirs.
12
+ function createStoreFilesOwnerOnly(hippoRoot) {
13
+ fs.mkdirSync(hippoRoot, { recursive: true, mode: 0o700 });
14
+ try {
15
+ fs.closeSync(fs.openSync(getHippoDbPath(hippoRoot), 'wx', 0o600));
16
+ }
17
+ catch (err) {
18
+ if (!(err instanceof Error && 'code' in err && err.code === 'EEXIST'))
19
+ throw err;
20
+ }
21
+ }
22
+ /** A new connection with the store's pragmas, migrations and mirror cleanup applied; the caller owns and closes it. */
23
+ export function connectHippoDb(hippoRoot, busyWaitMs) {
24
+ createStoreFilesOwnerOnly(hippoRoot);
25
+ const db = new DatabaseSync(getHippoDbPath(hippoRoot));
26
+ try {
27
+ db.exec(`PRAGMA busy_timeout = ${busyWaitMs ?? 5000}`);
28
+ execWithBusyRetry(db, 'PRAGMA journal_mode = WAL', busyWaitMs);
29
+ db.exec('PRAGMA synchronous = NORMAL');
30
+ db.exec('PRAGMA wal_autocheckpoint = 100');
31
+ db.exec('PRAGMA foreign_keys = ON');
32
+ runMigrations(db, hippoRoot, busyWaitMs);
33
+ // Orphan mirrors of archived raw rows go here; a filesystem failure must not block the open.
34
+ try {
35
+ cleanupArchivedMirrors(hippoRoot, db);
36
+ }
37
+ catch (cleanupErr) {
38
+ log.error(`openHippoDb: cleanupArchivedMirrors failed (non-fatal): ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
39
+ }
40
+ return db;
41
+ }
42
+ catch (error) {
43
+ try {
44
+ db.close();
45
+ }
46
+ catch {
47
+ // Best effort only.
48
+ }
49
+ throw error;
50
+ }
51
+ }
52
+ //# sourceMappingURL=connect.js.map
package/dist/db/open.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { type DatabaseSyncLike } from './sqlite.js';
2
- export declare function getHippoDbPath(hippoRoot: string): string;
2
+ import { getHippoDbPath } from './connect.js';
3
+ export { getHippoDbPath };
3
4
  /** Lock wait for hook commands: under the 5 s prompt-hook budget even after a few skipped writes, and far above a normal write's hold. */
4
5
  export declare const HOOK_DB_WAIT_MS = 1000;
6
+ /** Lock wait inside an HTTP request: SQLite waits synchronously, so a long wait would stall every other request on the event loop. */
7
+ export declare const SERVER_DB_WAIT_MS = 250;
5
8
  /** A busy store made a command skip work: warn once per process (the holder is usually `hippo sleep`). */
6
9
  export declare function noteStoreBusy(skipped: string): void;
7
10
  /** Runs `fn` with one handle per store: openHippoDb reuses it and closeHippoDb leaves it open until `fn` settles or the process exits.
@@ -9,21 +12,16 @@ export declare function noteStoreBusy(skipped: string): void;
9
12
  export declare function withSharedStoreHandles<T>(fn: () => T | Promise<T>, opts?: {
10
13
  busyWaitMs?: number;
11
14
  }): Promise<T>;
12
- /** Lock wait inside an HTTP request: SQLite waits synchronously, so a long wait would stall every other request on the event loop. */
13
- export declare const SERVER_DB_WAIT_MS = 250;
14
15
  /** Lock wait for each of sleep's short write transactions, even inside a server request: a run stops only on a writer that holds the lock longer. */
15
16
  export declare const SLEEP_DB_WAIT_MS = 5000;
16
- /** Runs `fn` so that every store opened inside it, across awaits, waits at most `busyWaitMs` for a lock unless the open passes its own. */
17
- export declare function withBusyWait<T>(busyWaitMs: number, fn: () => T): T;
18
- /** The lock wait an open here would get without its own `busyWaitMs`, in openHippoDb's order; undefined outside both scopes. */
17
+ /** The lock wait an open here would get without its own `busyWaitMs`: the request scope's; undefined outside every scope. */
19
18
  export declare function scopedBusyWait(): number | undefined;
20
- /** Thrown by a hippo.db open inside a request served from another store: the code path is not ported to the store port yet. */
21
- export declare class SqliteBlockedError extends Error {
22
- readonly storeKind: string;
23
- constructor(storeKind: string);
24
- }
25
19
  /** Runs `fn` so that every hippo.db open inside it, across awaits, throws; otherwise a missed port would create and write a hippo.db nobody reads. */
26
20
  export declare function withSqliteBlocked<T>(storeKind: string, fn: () => T): T;
21
+ /** Runs `fn` with hippo.db opens allowed again, for a store whose own methods are backed by hippo.db. */
22
+ export declare function withSqliteAllowed<T>(fn: () => T): T;
23
+ /** First line of a best-effort catch around a hippo.db open: an unported path must fail closed, not fall back silently. */
24
+ export declare function rethrowIfSqliteBlocked<E>(err: E): void;
27
25
  /** `busyWaitMs` shortens every lock wait of this open, for a hook that must finish inside its own timeout. */
28
26
  export declare function openHippoDb(hippoRoot: string, opts?: {
29
27
  busyWaitMs?: number;
package/dist/db/open.js CHANGED
@@ -1,84 +1,46 @@
1
- import * as fs from 'fs';
2
- import * as path from 'path';
3
1
  import { AsyncLocalStorage } from 'node:async_hooks';
4
- import { cleanupArchivedMirrors } from '../raw-archive-mirror-cleanup.js';
5
2
  import { log } from '../log.js';
6
3
  import { DatabaseSync } from './sqlite.js';
7
- import { execWithBusyRetry } from './busy.js';
8
4
  import { tableExists } from './tables.js';
9
- import { assertBinaryCompatible, runMigrations } from './migrate.js';
10
- export function getHippoDbPath(hippoRoot) {
11
- return path.join(hippoRoot, 'hippo.db');
12
- }
13
- // Hook commands run on every prompt, so inside withSharedStoreHandles each store pays its pragmas, migration check and mirror cleanup once.
14
- const sharedHandles = new Map();
15
- const sharedSet = new WeakSet();
16
- let shareDepth = 0;
17
- let shareBusyWaitMs;
5
+ import { assertBinaryCompatible } from './migrate.js';
6
+ import { connectHippoDb, getHippoDbPath } from './connect.js';
7
+ import { currentRequestStores, isScopedHandle, runWithRequestStores } from './request-stores.js';
8
+ import { SqliteBlockedError } from './sqlite-blocked.js';
9
+ export { getHippoDbPath };
18
10
  /** Lock wait for hook commands: under the 5 s prompt-hook budget even after a few skipped writes, and far above a normal write's hold. */
19
11
  export const HOOK_DB_WAIT_MS = 1000;
12
+ /** Lock wait inside an HTTP request: SQLite waits synchronously, so a long wait would stall every other request on the event loop. */
13
+ export const SERVER_DB_WAIT_MS = 250;
20
14
  /** A busy store made a command skip work: warn once per process (the holder is usually `hippo sleep`). */
21
15
  export function noteStoreBusy(skipped) {
22
16
  log.once('store-busy', 'warn', `store busy (another hippo process holds the write lock); ${skipped}`);
23
- // A lock held past one full wait belongs to a long transaction, so the hook's later writes skip at once.
24
- for (const db of sharedHandles.values()) {
25
- if (db.isOpen !== false)
26
- db.exec('PRAGMA busy_timeout = 0');
27
- }
28
- }
29
- function closeSharedStoreHandles() {
30
- for (const db of sharedHandles.values()) {
31
- sharedSet.delete(db);
32
- if (db.isOpen !== false)
33
- db.close();
34
- }
35
- sharedHandles.clear();
17
+ currentRequestStores()?.noteBusy();
36
18
  }
37
19
  /** Runs `fn` with one handle per store: openHippoDb reuses it and closeHippoDb leaves it open until `fn` settles or the process exits.
38
20
  * `busyWaitMs` is the lock wait of every open inside `fn` that does not pass its own. */
39
21
  export async function withSharedStoreHandles(fn, opts) {
40
- if (shareDepth++ === 0) {
41
- process.once('exit', closeSharedStoreHandles);
42
- shareBusyWaitMs = opts?.busyWaitMs;
43
- }
44
- try {
45
- return await fn();
46
- }
47
- finally {
48
- if (--shareDepth === 0) {
49
- process.off('exit', closeSharedStoreHandles);
50
- closeSharedStoreHandles();
51
- shareBusyWaitMs = undefined;
52
- }
53
- }
22
+ return runWithRequestStores(fn, { busyWaitMs: opts?.busyWaitMs });
54
23
  }
55
- /** Lock wait inside an HTTP request: SQLite waits synchronously, so a long wait would stall every other request on the event loop. */
56
- export const SERVER_DB_WAIT_MS = 250;
57
24
  /** Lock wait for each of sleep's short write transactions, even inside a server request: a run stops only on a writer that holds the lock longer. */
58
25
  export const SLEEP_DB_WAIT_MS = 5000;
59
- const scopedBusyWaitMs = new AsyncLocalStorage();
60
- /** Runs `fn` so that every store opened inside it, across awaits, waits at most `busyWaitMs` for a lock unless the open passes its own. */
61
- export function withBusyWait(busyWaitMs, fn) {
62
- return scopedBusyWaitMs.run(busyWaitMs, fn);
63
- }
64
- /** The lock wait an open here would get without its own `busyWaitMs`, in openHippoDb's order; undefined outside both scopes. */
26
+ /** The lock wait an open here would get without its own `busyWaitMs`: the request scope's; undefined outside every scope. */
65
27
  export function scopedBusyWait() {
66
- return shareBusyWaitMs ?? scopedBusyWaitMs.getStore();
67
- }
68
- /** Thrown by a hippo.db open inside a request served from another store: the code path is not ported to the store port yet. */
69
- export class SqliteBlockedError extends Error {
70
- storeKind;
71
- constructor(storeKind) {
72
- super(`hippo.db is not opened while the '${storeKind}' store serves this request; this code path is not ported to the store yet`);
73
- this.storeKind = storeKind;
74
- this.name = 'SqliteBlockedError';
75
- }
28
+ return currentRequestStores()?.busyWaitMs;
76
29
  }
77
30
  const sqliteBlockedBy = new AsyncLocalStorage();
78
31
  /** Runs `fn` so that every hippo.db open inside it, across awaits, throws; otherwise a missed port would create and write a hippo.db nobody reads. */
79
32
  export function withSqliteBlocked(storeKind, fn) {
80
33
  return sqliteBlockedBy.run(storeKind, fn);
81
34
  }
35
+ /** Runs `fn` with hippo.db opens allowed again, for a store whose own methods are backed by hippo.db. */
36
+ export function withSqliteAllowed(fn) {
37
+ return sqliteBlockedBy.exit(fn);
38
+ }
39
+ /** First line of a best-effort catch around a hippo.db open: an unported path must fail closed, not fall back silently. */
40
+ export function rethrowIfSqliteBlocked(err) {
41
+ if (err instanceof SqliteBlockedError)
42
+ throw err;
43
+ }
82
44
  function assertSqliteAllowed() {
83
45
  const storeKind = sqliteBlockedBy.getStore();
84
46
  if (storeKind !== undefined)
@@ -87,63 +49,8 @@ function assertSqliteAllowed() {
87
49
  /** `busyWaitMs` shortens every lock wait of this open, for a hook that must finish inside its own timeout. */
88
50
  export function openHippoDb(hippoRoot, opts) {
89
51
  assertSqliteAllowed();
90
- if (shareDepth === 0)
91
- return openOwnHippoDb(hippoRoot, { busyWaitMs: opts?.busyWaitMs ?? scopedBusyWaitMs.getStore() });
92
- const busyWaitMs = opts?.busyWaitMs ?? shareBusyWaitMs ?? scopedBusyWaitMs.getStore();
93
- const key = `${path.resolve(getHippoDbPath(hippoRoot))}\0${busyWaitMs ?? ''}`;
94
- const shared = sharedHandles.get(key);
95
- if (shared?.isOpen && !shared.isTransaction)
96
- return shared;
97
- const db = openOwnHippoDb(hippoRoot, { busyWaitMs });
98
- // An open nested inside a transaction gets its own connection, as it did before sharing.
99
- if (!shared?.isOpen) {
100
- sharedHandles.set(key, db);
101
- sharedSet.add(db);
102
- }
103
- return db;
104
- }
105
- // Owner-only on create; SQLite gives the WAL and SHM files the db's mode. Existing paths keep theirs.
106
- function createStoreFilesOwnerOnly(hippoRoot) {
107
- fs.mkdirSync(hippoRoot, { recursive: true, mode: 0o700 });
108
- try {
109
- fs.closeSync(fs.openSync(getHippoDbPath(hippoRoot), 'wx', 0o600));
110
- }
111
- catch (err) {
112
- if (!(err instanceof Error && 'code' in err && err.code === 'EEXIST'))
113
- throw err;
114
- }
115
- }
116
- function openOwnHippoDb(hippoRoot, opts) {
117
- createStoreFilesOwnerOnly(hippoRoot);
118
- const db = new DatabaseSync(getHippoDbPath(hippoRoot));
119
- const busyWaitMs = opts?.busyWaitMs;
120
- try {
121
- db.exec(`PRAGMA busy_timeout = ${busyWaitMs ?? 5000}`);
122
- execWithBusyRetry(db, 'PRAGMA journal_mode = WAL', busyWaitMs);
123
- db.exec('PRAGMA synchronous = NORMAL');
124
- db.exec('PRAGMA wal_autocheckpoint = 100');
125
- db.exec('PRAGMA foreign_keys = ON');
126
- runMigrations(db, hippoRoot, busyWaitMs);
127
- // Path A backfill: delete any orphan markdown mirrors for already-archived
128
- // raw_archive rows. Idempotent via per-row raw_archive.mirror_cleaned_at
129
- // (v21). Wrapped in try/catch — a filesystem failure must not prevent DB open.
130
- try {
131
- cleanupArchivedMirrors(hippoRoot, db);
132
- }
133
- catch (cleanupErr) {
134
- log.error(`openHippoDb: cleanupArchivedMirrors failed (non-fatal): ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
135
- }
136
- return db;
137
- }
138
- catch (error) {
139
- try {
140
- db.close();
141
- }
142
- catch {
143
- // Best effort only.
144
- }
145
- throw error;
146
- }
52
+ const stores = currentRequestStores();
53
+ return stores ? stores.get(hippoRoot, opts) : connectHippoDb(hippoRoot, opts?.busyWaitMs);
147
54
  }
148
55
  /** Open an existing store without changing it: no mkdir, WAL switch, migration or mirror cleanup. Throws when hippo.db is missing. */
149
56
  export function openHippoDbReadOnly(hippoRoot) {
@@ -166,7 +73,7 @@ export function openHippoDbReadOnly(hippoRoot) {
166
73
  }
167
74
  }
168
75
  export function closeHippoDb(db) {
169
- if (sharedSet.has(db))
76
+ if (isScopedHandle(db))
170
77
  return;
171
78
  db.close();
172
79
  }