hippo-memory 1.56.0 → 1.58.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 (129) hide show
  1. package/README.md +11 -0
  2. package/dist/agent-memories/claude-code.js +1 -1
  3. package/dist/agent-memories/gemini.js +1 -1
  4. package/dist/api-errors.d.ts +27 -0
  5. package/dist/api-errors.js +37 -0
  6. package/dist/api.d.ts +21 -14
  7. package/dist/api.js +97 -71
  8. package/dist/audit.d.ts +4 -0
  9. package/dist/audit.js +11 -0
  10. package/dist/autolearn.d.ts +1 -1
  11. package/dist/autolearn.js +7 -5
  12. package/dist/capture-contract.d.ts +47 -0
  13. package/dist/capture-contract.js +49 -0
  14. package/dist/capture-error.js +2 -1
  15. package/dist/capture.d.ts +0 -13
  16. package/dist/capture.js +5 -66
  17. package/dist/card-detail.d.ts +1 -1
  18. package/dist/card-detail.js +1 -1
  19. package/dist/cli/shared.d.ts +137 -0
  20. package/dist/cli/shared.js +834 -0
  21. package/dist/cli/sleep.d.ts +10 -0
  22. package/dist/cli/sleep.js +171 -0
  23. package/dist/cli.d.ts +0 -7
  24. package/dist/cli.js +322 -1827
  25. package/dist/client.js +9 -0
  26. package/dist/codex-patch.js +1 -1
  27. package/dist/compaction-record.d.ts +1 -1
  28. package/dist/compaction-record.js +3 -2
  29. package/dist/config.d.ts +5 -0
  30. package/dist/config.js +17 -0
  31. package/dist/connectors/github/dlq.js +5 -2
  32. package/dist/connectors/github/octokit-client.js +4 -2
  33. package/dist/connectors/github/webhook.d.ts +19 -0
  34. package/dist/connectors/github/webhook.js +313 -0
  35. package/dist/connectors/slack/dlq.js +6 -2
  36. package/dist/connectors/slack/web-client.js +7 -5
  37. package/dist/connectors/slack/webhook.d.ts +22 -0
  38. package/dist/connectors/slack/webhook.js +203 -0
  39. package/dist/consolidate.d.ts +10 -0
  40. package/dist/consolidate.js +38 -35
  41. package/dist/context-auto.d.ts +3 -0
  42. package/dist/context-auto.js +34 -0
  43. package/dist/customer-notes.js +16 -14
  44. package/dist/dag.js +3 -2
  45. package/dist/dashboard.js +3 -2
  46. package/dist/db.d.ts +12 -0
  47. package/dist/db.js +62 -1
  48. package/dist/decisions.js +11 -9
  49. package/dist/doctor.js +5 -0
  50. package/dist/embedding-provider.js +3 -3
  51. package/dist/embeddings.d.ts +4 -4
  52. package/dist/embeddings.js +72 -16
  53. package/dist/eval-stats.d.ts +58 -0
  54. package/dist/eval-stats.js +111 -0
  55. package/dist/extract.js +3 -2
  56. package/dist/goals.d.ts +49 -25
  57. package/dist/goals.js +39 -22
  58. package/dist/graph-extract.js +1 -1
  59. package/dist/graph-recall.d.ts +1 -1
  60. package/dist/graph-recall.js +1 -1
  61. package/dist/graph.js +1 -1
  62. package/dist/hooks.d.ts +1 -3
  63. package/dist/hooks.js +2 -4
  64. package/dist/http-retry.d.ts +21 -0
  65. package/dist/http-retry.js +50 -0
  66. package/dist/http-util.d.ts +39 -0
  67. package/dist/http-util.js +56 -0
  68. package/dist/importers.d.ts +2 -0
  69. package/dist/importers.js +16 -5
  70. package/dist/incidents.js +13 -11
  71. package/dist/index.d.ts +5 -2
  72. package/dist/index.js +5 -2
  73. package/dist/judgment.js +10 -17
  74. package/dist/log.d.ts +25 -0
  75. package/dist/log.js +48 -0
  76. package/dist/mcp/server.js +224 -308
  77. package/dist/mcp/tool-args.d.ts +21 -0
  78. package/dist/mcp/tool-args.js +80 -0
  79. package/dist/memory.d.ts +19 -0
  80. package/dist/memory.js +41 -2
  81. package/dist/overlap-index.d.ts +7 -0
  82. package/dist/overlap-index.js +38 -0
  83. package/dist/pilot-arm.d.ts +9 -0
  84. package/dist/pilot-arm.js +47 -0
  85. package/dist/policies.js +14 -12
  86. package/dist/predictions.js +11 -9
  87. package/dist/processes.js +16 -14
  88. package/dist/project-briefs.js +19 -16
  89. package/dist/project-identity.d.ts +1 -1
  90. package/dist/project-identity.js +25 -1
  91. package/dist/prompt-recall.js +1 -1
  92. package/dist/raw-archive.js +7 -6
  93. package/dist/recall-history.d.ts +5 -0
  94. package/dist/recall-history.js +9 -0
  95. package/dist/recall-pipeline.d.ts +101 -0
  96. package/dist/recall-pipeline.js +313 -0
  97. package/dist/recall-scope.d.ts +24 -1
  98. package/dist/recall-scope.js +29 -2
  99. package/dist/refine-llm.js +3 -2
  100. package/dist/reject-flow.js +6 -9
  101. package/dist/rejection.d.ts +2 -1
  102. package/dist/rejection.js +2 -1
  103. package/dist/search.d.ts +0 -20
  104. package/dist/search.js +16 -51
  105. package/dist/secret-detect.d.ts +13 -1
  106. package/dist/secret-detect.js +33 -1
  107. package/dist/server.d.ts +3 -1
  108. package/dist/server.js +1854 -2566
  109. package/dist/session-digest.js +2 -1
  110. package/dist/shared.js +7 -6
  111. package/dist/skills.js +17 -15
  112. package/dist/store-cards.d.ts +53 -0
  113. package/dist/store-cards.js +512 -0
  114. package/dist/store.d.ts +2 -89
  115. package/dist/store.js +10 -566
  116. package/dist/tenant.d.ts +22 -0
  117. package/dist/tenant.js +26 -0
  118. package/dist/token-ledger.d.ts +4 -2
  119. package/dist/token-ledger.js +2 -2
  120. package/dist/tokenize.d.ts +2 -0
  121. package/dist/tokenize.js +8 -0
  122. package/dist/version.d.ts +1 -1
  123. package/dist/version.js +1 -1
  124. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  125. package/extensions/openclaw-plugin/package.json +1 -1
  126. package/openclaw.plugin.json +1 -1
  127. package/package.json +1 -1
  128. package/dist/connectors/slack/ratelimit.d.ts +0 -9
  129. package/dist/connectors/slack/ratelimit.js +0 -18
package/dist/db.js CHANGED
@@ -6,6 +6,7 @@ import { createPhysicsTable } from './physics-state.js';
6
6
  import { cleanupArchivedMirrors } from './raw-archive-mirror-cleanup.js';
7
7
  import { PACKAGE_VERSION, compareSemver } from './version.js';
8
8
  import { deriveOriginProject, originFromSource, isGlobalStoreRoot } from './project-identity.js';
9
+ import { log } from './log.js';
9
10
  const require = createRequire(import.meta.url);
10
11
  // SAFETY: node:sqlite's DatabaseSync constructor genuinely has this shape at
11
12
  // runtime (Node's built-in synchronous SQLite module); there are no bundled
@@ -2607,7 +2608,7 @@ export function isSqliteBusy(error) {
2607
2608
  // busy_timeout covers neither of this file's two contended statements: SQLite
2608
2609
  // skips the busy handler for `PRAGMA journal_mode` and for a write that upgrades
2609
2610
  // a deferred read snapshot. Both need an explicit wait instead.
2610
- function execWithBusyRetry(db, sql, timeoutMs = 30000) {
2611
+ export function execWithBusyRetry(db, sql, timeoutMs = 30000) {
2611
2612
  const deadline = Date.now() + timeoutMs;
2612
2613
  const idle = new Int32Array(new SharedArrayBuffer(4));
2613
2614
  for (;;) {
@@ -2622,8 +2623,66 @@ function execWithBusyRetry(db, sql, timeoutMs = 30000) {
2622
2623
  }
2623
2624
  }
2624
2625
  }
2626
+ // Hook commands run on every prompt, so inside withSharedStoreHandles each store pays its pragmas, migration check and mirror cleanup once.
2627
+ const sharedHandles = new Map();
2628
+ const sharedSet = new WeakSet();
2629
+ let shareDepth = 0;
2630
+ let shareBusyWaitMs;
2631
+ /** 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. */
2632
+ export const HOOK_DB_WAIT_MS = 1000;
2633
+ /** A busy store made a command skip work: warn once per process (the holder is usually `hippo sleep`). */
2634
+ export function noteStoreBusy(skipped) {
2635
+ log.once('store-busy', 'warn', `store busy (another hippo process holds the write lock); ${skipped}`);
2636
+ // A lock held past one full wait belongs to a long transaction, so the hook's later writes skip at once.
2637
+ for (const db of sharedHandles.values()) {
2638
+ if (db.isOpen !== false)
2639
+ db.exec('PRAGMA busy_timeout = 0');
2640
+ }
2641
+ }
2642
+ function closeSharedStoreHandles() {
2643
+ for (const db of sharedHandles.values()) {
2644
+ sharedSet.delete(db);
2645
+ if (db.isOpen !== false)
2646
+ db.close();
2647
+ }
2648
+ sharedHandles.clear();
2649
+ }
2650
+ /** Runs `fn` with one handle per store: openHippoDb reuses it and closeHippoDb leaves it open until `fn` settles or the process exits.
2651
+ * `busyWaitMs` is the lock wait of every open inside `fn` that does not pass its own. */
2652
+ export async function withSharedStoreHandles(fn, opts) {
2653
+ if (shareDepth++ === 0) {
2654
+ process.once('exit', closeSharedStoreHandles);
2655
+ shareBusyWaitMs = opts?.busyWaitMs;
2656
+ }
2657
+ try {
2658
+ return await fn();
2659
+ }
2660
+ finally {
2661
+ if (--shareDepth === 0) {
2662
+ process.off('exit', closeSharedStoreHandles);
2663
+ closeSharedStoreHandles();
2664
+ shareBusyWaitMs = undefined;
2665
+ }
2666
+ }
2667
+ }
2625
2668
  /** `busyWaitMs` shortens every lock wait of this open, for a hook that must finish inside its own timeout. */
2626
2669
  export function openHippoDb(hippoRoot, opts) {
2670
+ if (shareDepth === 0)
2671
+ return openOwnHippoDb(hippoRoot, opts);
2672
+ const busyWaitMs = opts?.busyWaitMs ?? shareBusyWaitMs;
2673
+ const key = `${path.resolve(getHippoDbPath(hippoRoot))}\0${busyWaitMs ?? ''}`;
2674
+ const shared = sharedHandles.get(key);
2675
+ if (shared?.isOpen && !shared.isTransaction)
2676
+ return shared;
2677
+ const db = openOwnHippoDb(hippoRoot, { busyWaitMs });
2678
+ // An open nested inside a transaction gets its own connection, as it did before sharing.
2679
+ if (!shared?.isOpen) {
2680
+ sharedHandles.set(key, db);
2681
+ sharedSet.add(db);
2682
+ }
2683
+ return db;
2684
+ }
2685
+ function openOwnHippoDb(hippoRoot, opts) {
2627
2686
  fs.mkdirSync(hippoRoot, { recursive: true });
2628
2687
  const db = new DatabaseSync(getHippoDbPath(hippoRoot));
2629
2688
  const busyWaitMs = opts?.busyWaitMs;
@@ -2937,6 +2996,8 @@ function backfillFtsIndex(db) {
2937
2996
  `);
2938
2997
  }
2939
2998
  export function closeHippoDb(db) {
2999
+ if (sharedSet.has(db))
3000
+ return;
2940
3001
  db.close();
2941
3002
  }
2942
3003
  export function getMeta(db, key, fallback = '') {
package/dist/decisions.js CHANGED
@@ -23,8 +23,10 @@
23
23
  * 'write_entry' (store.ts:1196) via the afterWrite hook, so a failure in any
24
24
  * step rolls all of them back. Pattern matches savePrediction (predictions.ts).
25
25
  */
26
+ import { BadRequestError, ConflictError, NotFoundError } from './api-errors.js';
26
27
  import { openHippoDb, closeHippoDb } from './db.js';
27
- import { writeEntry, assertTenantId } from './store.js';
28
+ import { writeEntry } from './store.js';
29
+ import { assertTenantId } from './tenant.js';
28
30
  import { markGraphDirty, removeGraphEntitiesForObject } from './graph.js';
29
31
  import { createMemory, Layer } from './memory.js';
30
32
  import { appendAuditEvent } from './audit.js';
@@ -72,7 +74,7 @@ const DECISION_COLS = `
72
74
  export function saveDecision(hippoRoot, tenantId, opts, actor = 'cli') {
73
75
  assertTenantId('saveDecision', tenantId);
74
76
  if (!opts.decisionText)
75
- throw new Error('saveDecision: decisionText is required');
77
+ throw new BadRequestError('saveDecision: decisionText is required');
76
78
  const now = new Date().toISOString();
77
79
  const content = opts.context
78
80
  ? `${opts.decisionText}\n\nContext: ${opts.context}`
@@ -102,10 +104,10 @@ export function saveDecision(hippoRoot, tenantId, opts, actor = 'cli') {
102
104
  // SAFETY: row shape matches the single `status` column named in the SELECT below.
103
105
  const pred = db.prepare(`SELECT status FROM decisions WHERE id = ? AND tenant_id = ?`).get(opts.supersedesDecisionId, tenantId);
104
106
  if (!pred) {
105
- throw new Error(`saveDecision: decision ${opts.supersedesDecisionId} to supersede not found for tenant ${tenantId}`);
107
+ throw new NotFoundError(`saveDecision: decision ${opts.supersedesDecisionId} to supersede not found for tenant ${tenantId}`);
106
108
  }
107
109
  if (pred.status !== 'active') {
108
- throw new Error(`saveDecision: decision ${opts.supersedesDecisionId} is not active (status='${pred.status}'); only active decisions can be superseded.`);
110
+ throw new ConflictError(`saveDecision: decision ${opts.supersedesDecisionId} is not active (status='${pred.status}'); only active decisions can be superseded.`);
109
111
  }
110
112
  }
111
113
  const result = db.prepare(`
@@ -126,7 +128,7 @@ export function saveDecision(hippoRoot, tenantId, opts, actor = 'cli') {
126
128
  WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
127
129
  `).run(decisionId, now, opts.supersedesDecisionId, tenantId, decisionId);
128
130
  if (sup.changes === 0) {
129
- throw new Error(`saveDecision: decision ${opts.supersedesDecisionId} could not be superseded (no longer active or self-reference).`);
131
+ throw new BadRequestError(`saveDecision: decision ${opts.supersedesDecisionId} could not be superseded (no longer active or self-reference).`);
130
132
  }
131
133
  appendAuditEvent(db, {
132
134
  tenantId,
@@ -190,15 +192,15 @@ export function closeDecision(hippoRoot, tenantId, id, actor = 'cli') {
190
192
  // SAFETY: row shape matches the single `status` column named in the SELECT above.
191
193
  const existing = db.prepare(`SELECT status FROM decisions WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
192
194
  if (!existing) {
193
- throw new Error(`closeDecision: decision ${id} not found for tenant ${tenantId}`);
195
+ throw new NotFoundError(`closeDecision: decision ${id} not found for tenant ${tenantId}`);
194
196
  }
195
- throw new Error(`closeDecision: decision ${id} is not active (status='${existing.status}'); only active decisions can be closed.`);
197
+ throw new ConflictError(`closeDecision: decision ${id} is not active (status='${existing.status}'); only active decisions can be closed.`);
196
198
  }
197
199
  // SAFETY: row's shape matches the columns named in DECISION_COLS above.
198
200
  const row = db.prepare(`SELECT ${DECISION_COLS} FROM decisions WHERE id = ? AND tenant_id = ?`)
199
201
  .get(id, tenantId);
200
202
  if (!row)
201
- throw new Error(`closeDecision: decision ${id} not found after UPDATE`);
203
+ throw new NotFoundError(`closeDecision: decision ${id} not found after UPDATE`);
202
204
  appendAuditEvent(db, {
203
205
  tenantId,
204
206
  actor,
@@ -255,7 +257,7 @@ export function loadDecisions(hippoRoot, tenantId, opts = {}) {
255
257
  let rows;
256
258
  if (opts.status) {
257
259
  if (!VALID_DECISION_STATES.has(opts.status)) {
258
- throw new Error(`loadDecisions: status must be one of ${Array.from(VALID_DECISION_STATES).join('|')}; got ${opts.status}`);
260
+ throw new BadRequestError(`loadDecisions: status must be one of ${Array.from(VALID_DECISION_STATES).join('|')}; got ${opts.status}`);
259
261
  }
260
262
  // SAFETY: rows' shape matches the columns named in DECISION_COLS above.
261
263
  rows = db.prepare(`
package/dist/doctor.js CHANGED
@@ -10,6 +10,7 @@ import * as path from 'node:path';
10
10
  import { findHippoStoreDir } from './project-identity.js';
11
11
  import { getGlobalRoot } from './shared.js';
12
12
  import { isInitialized } from './store.js';
13
+ import { loadConfig } from './config.js';
13
14
  import { openHippoDbReadOnly, closeHippoDb, getSchemaVersion, getCurrentSchemaVersion, countTableRows, IncompatibleBinaryError } from './db.js';
14
15
  import { REPLAY_AFTER_MS, TRANSCRIPT_FILL_WINDOW_MS } from './compaction-record.js';
15
16
  import { isEmbeddingAvailable } from './embeddings.js';
@@ -208,6 +209,10 @@ export function runDoctor(opts) {
208
209
  closeHippoDb(db);
209
210
  }
210
211
  }
212
+ const holdoutRateBp = store === null ? 0 : loadConfig(store).pilot.holdoutRateBp;
213
+ if (holdoutRateBp > 0) {
214
+ checks.push({ id: 'pilot', status: 'info', detail: `pilot holdout on: about ${holdoutRateBp / 100}% of sessions get no memories pushed by hippo (pilot.holdoutRateBp=${holdoutRateBp})` });
215
+ }
211
216
  const claudeDir = path.join(home, '.claude');
212
217
  if (fs.existsSync(claudeDir)) {
213
218
  const settings = readJson(path.join(claudeDir, 'settings.json'));
@@ -33,6 +33,7 @@
33
33
  import { getEmbedding, isEmbeddingAvailable, resolveEmbeddingModel, DEFAULT_EMBEDDING_MODEL, } from './embeddings.js';
34
34
  import { loadConfig } from './config.js';
35
35
  import { redactSecrets } from './secret-detect.js';
36
+ import { fetchWithRetry } from './http-retry.js';
36
37
  export const API_PROVIDER_KINDS = ['openai', 'voyage', 'cohere'];
37
38
  function isApiProviderKind(x) {
38
39
  return x === 'openai' || x === 'voyage' || x === 'cohere';
@@ -184,15 +185,14 @@ class ApiEmbeddingProvider {
184
185
  const url = `${this.baseUrl.replace(/\/$/, '')}/${spec.path}`;
185
186
  let resp;
186
187
  try {
187
- resp = await fetch(url, {
188
+ resp = await fetchWithRetry(url, {
188
189
  method: 'POST',
189
190
  headers: {
190
191
  'content-type': 'application/json',
191
192
  authorization: `Bearer ${key}`,
192
193
  },
193
194
  body: JSON.stringify(spec.buildBody(this.model, chunk.map(redactSecrets), role)),
194
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
195
- });
195
+ }, { timeoutMs: REQUEST_TIMEOUT_MS });
196
196
  }
197
197
  catch (err) {
198
198
  const msg = err instanceof Error ? err.message : String(err);
@@ -4,6 +4,7 @@
4
4
  * Falls back silently if the library is not installed.
5
5
  */
6
6
  import { MemoryEntry } from './memory.js';
7
+ import { type EmbeddingProvider } from './embedding-provider.js';
7
8
  export declare const DEFAULT_EMBEDDING_MODEL = "Xenova/all-MiniLM-L6-v2";
8
9
  export declare const EMBEDDING_MODEL_META_KEY = "embedding_model";
9
10
  /**
@@ -114,8 +115,7 @@ export declare function getEmbedding(text: string, model?: string, role?: Embedd
114
115
  */
115
116
  export declare function cosineSimilarity(a: number[], b: number[]): number;
116
117
  /**
117
- * Load the cached embedding index from disk.
118
- * Returns an empty object if the file doesn't exist or is corrupt.
118
+ * Load the cached embedding index; `{}` when the file is missing. A corrupt file is moved aside and rebuilt on the next embed; any other read error throws, so nothing saves over an index it could not read.
119
119
  */
120
120
  export declare function loadEmbeddingIndex(hippoRoot: string): Record<string, number[]>;
121
121
  /**
@@ -129,7 +129,7 @@ export declare function embedMemory(hippoRoot: string, entry: MemoryEntry, model
129
129
  /**
130
130
  * Embed all entries in hippoRoot that don't already have cached vectors.
131
131
  * Prunes orphaned embeddings for memories that no longer exist.
132
- * Returns the count of newly embedded entries.
132
+ * Returns the count of newly embedded entries. `provider` defaults to the store's configured one.
133
133
  */
134
- export declare function embedAll(hippoRoot: string, model?: string): Promise<number>;
134
+ export declare function embedAll(hippoRoot: string, model?: string, provider?: EmbeddingProvider): Promise<number>;
135
135
  //# sourceMappingURL=embeddings.d.ts.map
@@ -13,6 +13,7 @@ import { initializeParticle, savePhysicsState, loadPhysicsState, resetAllPhysics
13
13
  import { loadConfig } from './config.js';
14
14
  import { resolveEmbeddingProvider } from './embedding-provider.js';
15
15
  import { redactSecretsStrict } from './secret-detect.js';
16
+ import { log } from './log.js';
16
17
  // Use createRequire for synchronous module resolution check in ESM
17
18
  const _require = createRequire(import.meta.url);
18
19
  // Cached availability check
@@ -270,6 +271,9 @@ async function rebuildEmbeddingIndex(entries, provider) {
270
271
  if (vec && vec.length > 0) {
271
272
  rebuilt[entries[i].id] = vec;
272
273
  }
274
+ else {
275
+ noteSkippedEmbedding(entries[i].id);
276
+ }
273
277
  }
274
278
  return rebuilt;
275
279
  }
@@ -283,10 +287,15 @@ function resetPhysicsFromIndex(hippoRoot, entries, index) {
283
287
  closeHippoDb(db);
284
288
  }
285
289
  }
286
- catch {
287
- // Physics reset is best-effort; retrieval will still fall back gracefully.
290
+ catch (err) {
291
+ // Best effort: retrieval still falls back without physics state.
292
+ log.warn(`physics reset after reindex failed: ${err instanceof Error ? err.message : String(err)}`);
288
293
  }
289
294
  }
295
+ /** A provider's `[]` row is a swallowed per-item failure; name the memory so the gap can be traced. */
296
+ function noteSkippedEmbedding(id) {
297
+ log.warn('memory not embedded; the next embed run retries it', { id });
298
+ }
290
299
  /**
291
300
  * Get an embedding vector for a piece of text.
292
301
  * Returns an empty array if transformers is not available or fails.
@@ -314,7 +323,9 @@ export async function getEmbedding(text, model = DEFAULT_EMBEDDING_MODEL, role)
314
323
  // pipeline's documented tensor output shape.
315
324
  return Array.from(output.data);
316
325
  }
317
- catch {
326
+ catch (err) {
327
+ // The caller sees `[]` and names the memory; the reason only shows at debug.
328
+ log.debug(`local embedding failed: ${err instanceof Error ? err.message : String(err)}`);
318
329
  return [];
319
330
  }
320
331
  }
@@ -340,23 +351,66 @@ export function cosineSimilarity(a, b) {
340
351
  return Math.min(1, Math.max(-1, dot / denom));
341
352
  }
342
353
  const EMBEDDINGS_FILE = 'embeddings.json';
354
+ // Never equal to a real index identity, so the next embed run treats it as a model change and rebuilds every vector.
355
+ const QUARANTINED_INDEX_IDENTITY = 'quarantined-corrupt-index';
356
+ function isErrnoCode(err, code) {
357
+ return err instanceof Error && 'code' in err && err.code === code;
358
+ }
359
+ function parseEmbeddingIndex(raw) {
360
+ try {
361
+ const parsed = JSON.parse(raw);
362
+ // SAFETY: saveEmbeddingIndex is the only writer and always writes this shape; anything that is not an object is corrupt.
363
+ return parsed instanceof Object && !Array.isArray(parsed) ? parsed : null;
364
+ }
365
+ catch {
366
+ return null;
367
+ }
368
+ }
369
+ /** Move a corrupt index aside and flag a full rebuild, so no later save can write over the only copy of those bytes. */
370
+ function quarantineCorruptIndex(hippoRoot, fp) {
371
+ const aside = `${fp}.corrupt-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`;
372
+ try {
373
+ fs.renameSync(fp, aside);
374
+ }
375
+ catch (err) {
376
+ if (isErrnoCode(err, 'ENOENT'))
377
+ return;
378
+ // A rename blocked by an open handle (Windows) still gets a copy kept; if the copy fails too, the throw stops the save.
379
+ fs.copyFileSync(fp, aside, fs.constants.COPYFILE_EXCL);
380
+ }
381
+ log.error(`${EMBEDDINGS_FILE} could not be parsed; kept it as ${path.basename(aside)} and the next embed rebuilds the index`, { hippoRoot });
382
+ try {
383
+ const db = openHippoDb(hippoRoot);
384
+ try {
385
+ setMeta(db, EMBEDDING_MODEL_META_KEY, QUARANTINED_INDEX_IDENTITY);
386
+ }
387
+ finally {
388
+ closeHippoDb(db);
389
+ }
390
+ }
391
+ catch (err) {
392
+ log.warn(`could not flag the embedding index for rebuild; run 'hippo embed' to restore vectors (${err instanceof Error ? err.message : String(err)})`, { hippoRoot });
393
+ }
394
+ }
343
395
  /**
344
- * Load the cached embedding index from disk.
345
- * Returns an empty object if the file doesn't exist or is corrupt.
396
+ * Load the cached embedding index; `{}` when the file is missing. A corrupt file is moved aside and rebuilt on the next embed; any other read error throws, so nothing saves over an index it could not read.
346
397
  */
347
398
  export function loadEmbeddingIndex(hippoRoot) {
348
399
  const fp = path.join(hippoRoot, EMBEDDINGS_FILE);
349
- if (!fs.existsSync(fp))
350
- return {};
400
+ let raw;
351
401
  try {
352
- // SAFETY: embeddings.json is written exclusively by saveEmbeddingIndex with
353
- // this exact shape; a corrupt or foreign file is caught by the try/catch
354
- // below and treated as an empty index.
355
- return JSON.parse(fs.readFileSync(fp, 'utf8'));
402
+ raw = fs.readFileSync(fp, 'utf8');
356
403
  }
357
- catch {
358
- return {};
404
+ catch (err) {
405
+ if (isErrnoCode(err, 'ENOENT'))
406
+ return {};
407
+ throw err;
359
408
  }
409
+ const index = parseEmbeddingIndex(raw);
410
+ if (index)
411
+ return index;
412
+ quarantineCorruptIndex(hippoRoot, fp);
413
+ return {};
360
414
  }
361
415
  /**
362
416
  * Save the embedding index to disk.
@@ -543,10 +597,9 @@ export async function embedMemory(hippoRoot, entry, model) {
543
597
  /**
544
598
  * Embed all entries in hippoRoot that don't already have cached vectors.
545
599
  * Prunes orphaned embeddings for memories that no longer exist.
546
- * Returns the count of newly embedded entries.
600
+ * Returns the count of newly embedded entries. `provider` defaults to the store's configured one.
547
601
  */
548
- export async function embedAll(hippoRoot, model) {
549
- const provider = resolveEmbeddingProvider(hippoRoot, { model });
602
+ export async function embedAll(hippoRoot, model, provider = resolveEmbeddingProvider(hippoRoot, { model })) {
550
603
  if (!provider.isAvailable()) {
551
604
  // A configured (non-disabled) API provider with a missing key is a
552
605
  // misconfiguration, not a no-op: surface it so programmatic callers of the
@@ -618,6 +671,9 @@ export async function embedAll(hippoRoot, model) {
618
671
  dirty = true;
619
672
  chunkDirty = true;
620
673
  }
674
+ else {
675
+ noteSkippedEmbedding(chunk[j].id);
676
+ }
621
677
  }
622
678
  if (chunkDirty)
623
679
  saveEmbeddingIndex(hippoRoot, index);
@@ -120,4 +120,62 @@ export declare function passAtK(runsByTask: boolean[][], k: number): number;
120
120
  * NaN when no task has k runs.
121
121
  */
122
122
  export declare function passHatK(runsByTask: boolean[][], k: number): number;
123
+ /** All units of one family (tasks by seeds), resampled as one block so seeds stay together. */
124
+ export type Family<T> = readonly T[];
125
+ /** One repository's families; a task with no lesson is a family of one. */
126
+ export type Repository<T> = readonly Family<T>[];
127
+ /** An {@link Estimate} with a two-sided p-value from the same resamples. */
128
+ export interface TestedEstimate extends Estimate {
129
+ /** 2 x min(share <= null, share >= null), capped at 1; NaN with fewer than two repositories. */
130
+ readonly p: number;
131
+ /** Non-finite resamples, dropped; `iterations + dropped` is the number requested. */
132
+ readonly dropped: number;
133
+ /** The null the p-value tested against; {@link verdict} reads its sides from here. */
134
+ readonly nullValue: number;
135
+ }
136
+ /** Options for {@link twoLevelBootstrap}. */
137
+ export interface TwoLevelOpts extends BootstrapOpts {
138
+ /** Resamples. Default 10,000. */
139
+ readonly iterations?: number;
140
+ /** Value the p-value tests against: 0 for differences, 1 for ratios. Default 0. */
141
+ readonly nullValue?: number;
142
+ }
143
+ /** Resamples repositories, then families inside each; only the repository draw carries a shared-store fault.
144
+ * The statistic never uses the PRNG, so a second call on one seed draws the same resamples. */
145
+ export declare function twoLevelBootstrap<T>(repos: readonly Repository<T>[], statistic: (units: readonly T[]) => number, opts?: TwoLevelOpts): TestedEstimate;
146
+ /** Holm step-down in input order; the family size is `ps.length`, as preregistered.
147
+ * A NaN stays NaN but ranks as 1, so it never loosens the others; a finite p outside [0, 1] throws. */
148
+ export declare function holmAdjust(ps: readonly number[]): number[];
149
+ /** One of the four mutually exclusive outcomes the preregistration allows per hypothesis. */
150
+ export type Verdict = 'loss' | 'win' | 'tie' | 'inconclusive';
151
+ /** What a hypothesis needs to be read; see {@link verdict}. */
152
+ export interface VerdictSpec {
153
+ /** Direction that favours the treatment arm. */
154
+ readonly helpful: 'lower' | 'higher';
155
+ /** Inclusive band the interval must sit inside for a tie. */
156
+ readonly tieBand: readonly [number, number];
157
+ /** An estimate on this value or beyond it, on the helpful side, reaches the minimum effect. */
158
+ readonly minimumEffectAt: number;
159
+ /** Default 0.05. */
160
+ readonly alpha?: number;
161
+ }
162
+ /** A verdict, plus whether a win reaches the minimum effect (a win below it is a small win). */
163
+ export interface VerdictResult {
164
+ readonly verdict: Verdict;
165
+ readonly reachesMinimum: boolean;
166
+ }
167
+ /** Checks in the preregistered order; the null comes from `e.nullValue`, so p and sides cannot disagree.
168
+ * A NaN estimate, p or bound is inconclusive, since a win or loss cannot be ruled out. */
169
+ export declare function verdict(e: TestedEstimate, adjustedP: number, spec: VerdictSpec): VerdictResult;
170
+ /** A win must hold under both codings of not-applicable, while a loss under either is reported. */
171
+ export declare function combineCodings(a: VerdictResult, b: VerdictResult): VerdictResult;
172
+ /** Outcome of the harm gate; see {@link harmGate}. */
173
+ export interface HarmGate {
174
+ readonly pass: boolean;
175
+ readonly costOk: boolean;
176
+ readonly resolveOk: boolean;
177
+ }
178
+ /** Cost ratio's upper bound below 1.10, resolve difference's lower bound (a fraction) above -0.05.
179
+ * Both estimates must use alpha 0.05, the conservative reading of "upper 95% bound"; a NaN bound fails. */
180
+ export declare function harmGate(costRatio: Estimate, resolveDiff: Estimate): HarmGate;
123
181
  //# sourceMappingURL=eval-stats.d.ts.map
@@ -184,4 +184,115 @@ export function passHatK(runsByTask, k) {
184
184
  return Number.NaN;
185
185
  return eligible.filter((r) => r.slice(0, k).every(Boolean)).length / eligible.length;
186
186
  }
187
+ function notANumber(nullValue, dropped) {
188
+ const nan = Number.NaN;
189
+ return { estimate: nan, low: nan, high: nan, p: nan, iterations: 0, dropped, nullValue };
190
+ }
191
+ function resampleUnits(repos, rand) {
192
+ const units = [];
193
+ for (let i = 0; i < repos.length; i++) {
194
+ const repo = repos[Math.floor(rand() * repos.length)];
195
+ for (let j = 0; j < repo.length; j++) {
196
+ for (const unit of repo[Math.floor(rand() * repo.length)])
197
+ units.push(unit);
198
+ }
199
+ }
200
+ return units;
201
+ }
202
+ // Inclusive on both sides, as the p-value is worded; float noise around the null breaks a tie.
203
+ function twoSidedP(samples, nullValue) {
204
+ let below = 0;
205
+ let above = 0;
206
+ for (const s of samples) {
207
+ if (s <= nullValue)
208
+ below++;
209
+ if (s >= nullValue)
210
+ above++;
211
+ }
212
+ return Math.min(1, (2 * Math.min(below, above)) / samples.length);
213
+ }
214
+ /** Resamples repositories, then families inside each; only the repository draw carries a shared-store fault.
215
+ * The statistic never uses the PRNG, so a second call on one seed draws the same resamples. */
216
+ export function twoLevelBootstrap(repos, statistic, opts = {}) {
217
+ const requested = opts.iterations ?? 10_000;
218
+ if (!Number.isInteger(requested) || requested <= 0) {
219
+ throw new RangeError(`iterations must be a positive integer, got ${requested}`);
220
+ }
221
+ const nullValue = opts.nullValue ?? 0;
222
+ const kept = repos.map((r) => r.filter((f) => f.length > 0)).filter((r) => r.length > 0);
223
+ if (kept.length === 0)
224
+ return notANumber(nullValue, 0);
225
+ const rand = seededRandom(opts.seed ?? 1);
226
+ const samples = [];
227
+ for (let b = 0; b < requested; b++) {
228
+ const value = statistic(resampleUnits(kept, rand));
229
+ if (Number.isFinite(value))
230
+ samples.push(value);
231
+ }
232
+ const dropped = requested - samples.length;
233
+ if (samples.length === 0)
234
+ return notANumber(nullValue, dropped);
235
+ const estimate = statistic(kept.flatMap((r) => r.flat()));
236
+ const p = kept.length < 2 ? Number.NaN : twoSidedP(samples, nullValue);
237
+ const interval = percentileInterval(samples, opts.alpha ?? 0.05);
238
+ return { estimate, ...interval, p, iterations: samples.length, dropped, nullValue };
239
+ }
240
+ /** Holm step-down in input order; the family size is `ps.length`, as preregistered.
241
+ * A NaN stays NaN but ranks as 1, so it never loosens the others; a finite p outside [0, 1] throws. */
242
+ export function holmAdjust(ps) {
243
+ for (const p of ps) {
244
+ if (!Number.isNaN(p) && !(p >= 0 && p <= 1))
245
+ throw new RangeError(`p-value out of range: ${p}`);
246
+ }
247
+ const ranked = ps
248
+ .map((p, index) => ({ index, key: Number.isNaN(p) ? 1 : p }))
249
+ .sort((a, b) => a.key - b.key || a.index - b.index);
250
+ const adjusted = Array.from({ length: ps.length }, () => Number.NaN);
251
+ let running = 0;
252
+ ranked.forEach(({ index, key }, rank) => {
253
+ running = Math.max(running, (ps.length - rank) * key);
254
+ if (!Number.isNaN(ps[index]))
255
+ adjusted[index] = Math.min(1, running);
256
+ });
257
+ return adjusted;
258
+ }
259
+ /** Checks in the preregistered order; the null comes from `e.nullValue`, so p and sides cannot disagree.
260
+ * A NaN estimate, p or bound is inconclusive, since a win or loss cannot be ruled out. */
261
+ export function verdict(e, adjustedP, spec) {
262
+ const inconclusive = { verdict: 'inconclusive', reachesMinimum: false };
263
+ if ([adjustedP, e.estimate, e.low, e.high].some(Number.isNaN))
264
+ return inconclusive;
265
+ const nullValue = e.nullValue;
266
+ const lowerIsHelpful = spec.helpful === 'lower';
267
+ if (adjustedP < (spec.alpha ?? 0.05)) {
268
+ const helpfulSide = lowerIsHelpful ? e.estimate < nullValue : e.estimate > nullValue;
269
+ const harmfulSide = lowerIsHelpful ? e.estimate > nullValue : e.estimate < nullValue;
270
+ if (harmfulSide)
271
+ return { verdict: 'loss', reachesMinimum: false };
272
+ if (helpfulSide) {
273
+ const reaches = lowerIsHelpful ? e.estimate <= spec.minimumEffectAt : e.estimate >= spec.minimumEffectAt;
274
+ return { verdict: 'win', reachesMinimum: reaches };
275
+ }
276
+ }
277
+ const insideBand = e.low >= spec.tieBand[0] && e.high <= spec.tieBand[1];
278
+ return insideBand ? { verdict: 'tie', reachesMinimum: false } : inconclusive;
279
+ }
280
+ /** A win must hold under both codings of not-applicable, while a loss under either is reported. */
281
+ export function combineCodings(a, b) {
282
+ if (a.verdict === 'loss' || b.verdict === 'loss')
283
+ return { verdict: 'loss', reachesMinimum: false };
284
+ if (a.verdict === 'win' && b.verdict === 'win') {
285
+ return { verdict: 'win', reachesMinimum: a.reachesMinimum && b.reachesMinimum };
286
+ }
287
+ if (a.verdict === 'tie' && b.verdict === 'tie')
288
+ return { verdict: 'tie', reachesMinimum: false };
289
+ return { verdict: 'inconclusive', reachesMinimum: false };
290
+ }
291
+ /** Cost ratio's upper bound below 1.10, resolve difference's lower bound (a fraction) above -0.05.
292
+ * Both estimates must use alpha 0.05, the conservative reading of "upper 95% bound"; a NaN bound fails. */
293
+ export function harmGate(costRatio, resolveDiff) {
294
+ const costOk = costRatio.high < 1.1;
295
+ const resolveOk = resolveDiff.low > -0.05;
296
+ return { pass: costOk && resolveOk, costOk, resolveOk };
297
+ }
187
298
  //# sourceMappingURL=eval-stats.js.map
package/dist/extract.js CHANGED
@@ -3,6 +3,7 @@ import { writeEntry } from './store.js';
3
3
  import { loadConfig } from './config.js';
4
4
  import { RejectedValueError } from './rejection.js';
5
5
  import { redactSecrets } from './secret-detect.js';
6
+ import { fetchWithRetry, llmTimeoutMs } from './http-retry.js';
6
7
  import { neverAutoShareTags } from './shared.js';
7
8
  function isJsonString(value) {
8
9
  return typeof value === 'string';
@@ -24,7 +25,7 @@ export async function extractFacts(text, opts) {
24
25
  const fetchFn = opts.fetcher ?? fetch;
25
26
  let res;
26
27
  try {
27
- res = await fetchFn('https://api.anthropic.com/v1/messages', {
28
+ res = await fetchWithRetry('https://api.anthropic.com/v1/messages', {
28
29
  method: 'POST',
29
30
  headers: {
30
31
  'content-type': 'application/json',
@@ -36,7 +37,7 @@ export async function extractFacts(text, opts) {
36
37
  max_tokens: 1200,
37
38
  messages: [{ role: 'user', content: EXTRACTION_PROMPT + redactSecrets(text) }],
38
39
  }),
39
- });
40
+ }, { timeoutMs: llmTimeoutMs(), fetchFn });
40
41
  }
41
42
  catch (err) {
42
43
  opts.onError?.(`request failed: ${err instanceof Error ? err.message : String(err)}`);