hippo-memory 1.57.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 (91) 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 +5 -5
  7. package/dist/api.js +40 -47
  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/cli/shared.js +10 -6
  18. package/dist/cli.js +100 -39
  19. package/dist/client.js +9 -0
  20. package/dist/codex-patch.js +1 -1
  21. package/dist/compaction-record.d.ts +1 -1
  22. package/dist/compaction-record.js +3 -2
  23. package/dist/config.d.ts +5 -0
  24. package/dist/config.js +17 -0
  25. package/dist/connectors/github/dlq.js +5 -2
  26. package/dist/connectors/github/octokit-client.js +4 -2
  27. package/dist/connectors/slack/dlq.js +6 -2
  28. package/dist/connectors/slack/web-client.js +7 -5
  29. package/dist/consolidate.d.ts +10 -0
  30. package/dist/consolidate.js +36 -34
  31. package/dist/customer-notes.js +14 -13
  32. package/dist/dag.js +3 -2
  33. package/dist/dashboard.js +1 -1
  34. package/dist/db.d.ts +12 -0
  35. package/dist/db.js +62 -1
  36. package/dist/decisions.js +9 -8
  37. package/dist/doctor.js +5 -0
  38. package/dist/embedding-provider.js +3 -3
  39. package/dist/embeddings.d.ts +4 -4
  40. package/dist/embeddings.js +72 -16
  41. package/dist/extract.js +3 -2
  42. package/dist/http-retry.d.ts +21 -0
  43. package/dist/http-retry.js +50 -0
  44. package/dist/http-util.d.ts +8 -0
  45. package/dist/http-util.js +10 -0
  46. package/dist/importers.d.ts +2 -0
  47. package/dist/importers.js +16 -5
  48. package/dist/incidents.js +11 -10
  49. package/dist/judgment.js +10 -17
  50. package/dist/log.d.ts +25 -0
  51. package/dist/log.js +48 -0
  52. package/dist/mcp/server.js +52 -24
  53. package/dist/mcp/tool-args.d.ts +21 -0
  54. package/dist/mcp/tool-args.js +80 -0
  55. package/dist/memory.js +3 -2
  56. package/dist/overlap-index.d.ts +7 -0
  57. package/dist/overlap-index.js +38 -0
  58. package/dist/pilot-arm.d.ts +9 -0
  59. package/dist/pilot-arm.js +47 -0
  60. package/dist/policies.js +12 -11
  61. package/dist/predictions.js +9 -8
  62. package/dist/processes.js +14 -13
  63. package/dist/project-briefs.js +16 -15
  64. package/dist/project-identity.d.ts +1 -1
  65. package/dist/project-identity.js +25 -1
  66. package/dist/raw-archive.js +7 -6
  67. package/dist/recall-scope.d.ts +2 -1
  68. package/dist/recall-scope.js +2 -1
  69. package/dist/refine-llm.js +3 -2
  70. package/dist/reject-flow.js +6 -9
  71. package/dist/rejection.d.ts +2 -1
  72. package/dist/rejection.js +2 -1
  73. package/dist/search.js +14 -2
  74. package/dist/secret-detect.d.ts +13 -1
  75. package/dist/secret-detect.js +33 -1
  76. package/dist/server.d.ts +3 -1
  77. package/dist/server.js +180 -409
  78. package/dist/session-digest.js +2 -1
  79. package/dist/shared.js +7 -6
  80. package/dist/skills.js +15 -14
  81. package/dist/store.js +4 -4
  82. package/dist/token-ledger.d.ts +4 -2
  83. package/dist/token-ledger.js +2 -2
  84. package/dist/version.d.ts +1 -1
  85. package/dist/version.js +1 -1
  86. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  87. package/extensions/openclaw-plugin/package.json +1 -1
  88. package/openclaw.plugin.json +1 -1
  89. package/package.json +1 -1
  90. package/dist/connectors/slack/ratelimit.d.ts +0 -9
  91. package/dist/connectors/slack/ratelimit.js +0 -18
@@ -1,7 +1,8 @@
1
- import { fetchWithRetry } from './ratelimit.js';
1
+ import { fetchWithRetry, isRetryableStatus } from '../../http-retry.js';
2
2
  function isJsonString(value) {
3
3
  return typeof value === 'string';
4
4
  }
5
+ const SLACK_TIMEOUT_MS = 30_000;
5
6
  export function slackHistoryFetcher(token, fetchImpl) {
6
7
  return async ({ channelId, cursor, oldest }) => {
7
8
  const url = new URL('https://slack.com/api/conversations.history');
@@ -11,11 +12,12 @@ export function slackHistoryFetcher(token, fetchImpl) {
11
12
  url.searchParams.set('cursor', cursor);
12
13
  if (oldest)
13
14
  url.searchParams.set('oldest', oldest);
14
- const r = await fetchWithRetry({
15
- url: url.toString(),
16
- init: { method: 'GET', headers: { authorization: `Bearer ${token}` } },
17
- fetchImpl,
15
+ const r = await fetchWithRetry(url, { method: 'GET', headers: { authorization: `Bearer ${token}` } }, {
16
+ timeoutMs: SLACK_TIMEOUT_MS,
17
+ fetchFn: fetchImpl,
18
18
  });
19
+ if (isRetryableStatus(r.status))
20
+ throw new Error(`slack: still rate-limited or unavailable (HTTP ${r.status})`);
19
21
  // SAFETY: body is the Slack `conversations.history` response; per the
20
22
  // documented shape it's `{ ok, error?, messages?, response_metadata? }`.
21
23
  const body = (await r.json());
@@ -6,6 +6,7 @@
6
6
  * 2. Merge pass - find episodic entries with high text overlap, create semantic summaries
7
7
  * 3. Stats tracking
8
8
  */
9
+ import { MemoryEntry, type DecayOptions } from './memory.js';
9
10
  export interface ConsolidationResult {
10
11
  decayed: number;
11
12
  removed: number;
@@ -45,4 +46,13 @@ export declare function consolidate(hippoRoot: string, options?: {
45
46
  now?: Date;
46
47
  fetcher?: typeof fetch;
47
48
  }): Promise<ConsolidationResult>;
49
+ /** Maps i to each j > i, ascending, whose text overlap with i reaches the merge threshold; every text needs at least one token. */
50
+ export declare function mergePartners(contents: readonly string[]): (i: number) => number[];
51
+ /** Pairs of live non-semantic memories that contradict each other, in survivor order. */
52
+ export declare function detectConflicts(entries: MemoryEntry[], now: Date, decayOpts?: DecayOptions, rescuedIds?: Set<string>): Array<{
53
+ memory_a_id: string;
54
+ memory_b_id: string;
55
+ reason: string;
56
+ score: number;
57
+ }>;
48
58
  //# sourceMappingURL=consolidate.d.ts.map
@@ -9,8 +9,8 @@
9
9
  import { evalNow, isRecallBoostAblated } from './ablation.js';
10
10
  import { Layer, calculateStrength, canAutoDelete, createMemory, markRetrieved } from './memory.js';
11
11
  import { loadAllEntries, batchWriteAndDelete, appendConsolidationRun, replaceDetectedConflicts, loadSessionDecayContext, incrementSleepCount, findPromotableSessions, traceExistsForSession, listSessionEvents, memoriesBackingObjects, } from './store.js';
12
- import { textOverlap } from './search.js';
13
12
  import { tokenize } from './tokenize.js';
13
+ import { jaccardMinShared, overlapPartners } from './overlap-index.js';
14
14
  import { compareEntryIdentity } from './compare.js';
15
15
  import { duplicateKey, mergedText } from './same-text.js';
16
16
  import { successorAfterRetirement } from './merged-row.js';
@@ -26,7 +26,7 @@ import { renderTraceContent } from './trace.js';
26
26
  import { resolveTenantId } from './tenant.js';
27
27
  import { rescueSet, rankNonPinnedByTenant, validateWeights } from './memory-value.js';
28
28
  import { MEMORY_VALUE_WEIGHTS, SOURCE_ARTIFACT_SHA256 } from './memory-value-weights.js';
29
- import { appendAuditEvent } from './audit.js';
29
+ import { appendAuditEvent, reportAuditWriteFailure } from './audit.js';
30
30
  import { migrateDefaultHalfLife, LEGACY_TYPED_HALF_LIFE } from './half-life-migration.js';
31
31
  import { derivationScope, commonDerivationScope, derivationPartitionKey } from './recall-scope.js';
32
32
  import { isQuarantineScope } from './quarantine.js';
@@ -419,8 +419,8 @@ export async function consolidate(hippoRoot, options = {}) {
419
419
  },
420
420
  });
421
421
  }
422
- catch {
423
- // Best-effort — mirrors store.ts's audit() semantics.
422
+ catch (error) {
423
+ reportAuditWriteFailure('reject_refusal', String(error));
424
424
  }
425
425
  continue;
426
426
  }
@@ -708,17 +708,14 @@ export async function consolidate(hippoRoot, options = {}) {
708
708
  for (const [, tenantCandidates] of mergeCandidatesByTenant) {
709
709
  const mergeTenant = tenantCandidates[0].tenantId;
710
710
  const mergeScope = derivationScope(tenantCandidates[0].scope);
711
+ const partnersOf = mergePartners(tenantCandidates.map((e) => e.content));
711
712
  for (let i = 0; i < tenantCandidates.length; i++) {
712
713
  if (used.has(tenantCandidates[i].id) || tenantCandidates[i].content.length > MERGE_MAX_CHARS)
713
714
  continue;
714
715
  const related = [tenantCandidates[i]];
715
- for (let j = i + 1; j < tenantCandidates.length; j++) {
716
- if (used.has(tenantCandidates[j].id))
717
- continue;
718
- const overlap = textOverlap(tenantCandidates[i].content, tenantCandidates[j].content);
719
- if (overlap >= MERGE_OVERLAP_THRESHOLD) {
716
+ for (const j of partnersOf(i)) {
717
+ if (!used.has(tenantCandidates[j].id))
720
718
  related.push(tenantCandidates[j]);
721
- }
722
719
  }
723
720
  const cluster = [];
724
721
  let clusterChars = 0;
@@ -793,8 +790,8 @@ export async function consolidate(hippoRoot, options = {}) {
793
790
  },
794
791
  });
795
792
  }
796
- catch {
797
- // Best-effort — mirrors store.ts's audit() semantics.
793
+ catch (error) {
794
+ reportAuditWriteFailure('reject_refusal', String(error));
798
795
  }
799
796
  continue;
800
797
  }
@@ -911,8 +908,9 @@ export async function consolidate(hippoRoot, options = {}) {
911
908
  : {},
912
909
  });
913
910
  }
914
- catch {
911
+ catch (error) {
915
912
  auditFailures++;
913
+ reportAuditWriteFailure('mv_rescue', String(error), entry.id);
916
914
  }
917
915
  }
918
916
  if (auditFailures > 0) {
@@ -965,7 +963,14 @@ function pickStrongestValence(entries) {
965
963
  }
966
964
  return 'neutral';
967
965
  }
968
- function detectConflicts(entries, now, decayOpts = {},
966
+ /** Maps i to each j > i, ascending, whose text overlap with i reaches the merge threshold; every text needs at least one token. */
967
+ export function mergePartners(contents) {
968
+ const sets = contents.map((text) => new Set(tokenize(text)));
969
+ const candidatesOf = overlapPartners(sets, jaccardMinShared(MERGE_OVERLAP_THRESHOLD));
970
+ return (i) => candidatesOf(i).filter((j) => jaccardSets(sets[i], sets[j]) >= MERGE_OVERLAP_THRESHOLD);
971
+ }
972
+ /** Pairs of live non-semantic memories that contradict each other, in survivor order. */
973
+ export function detectConflicts(entries, now, decayOpts = {},
969
974
  // LC2-E3 (opt-in, default off): ids rescued by this cycle's decay pass.
970
975
  // detectConflicts recomputes its own strength>=DECAY_THRESHOLD survivor
971
976
  // filter independently of the decay pass above; without this bypass,
@@ -978,8 +983,10 @@ rescuedIds = new Set()) {
978
983
  && !isQuarantineScope(entry.scope ?? null)
979
984
  && (rescuedIds.has(entry.id) || calculateStrength(entry, now, decayOpts) >= DECAY_THRESHOLD));
980
985
  const detected = [];
986
+ const profiles = survivors.map((entry) => conflictProfile(entry.content));
987
+ const partnersOf = overlapPartners(profiles.map((p) => p.distinct), jaccardMinShared(CONFLICT_OVERLAP_THRESHOLD, CONFLICT_MIN_RARE_SHARED));
981
988
  for (let i = 0; i < survivors.length; i++) {
982
- for (let j = i + 1; j < survivors.length; j++) {
989
+ for (const j of partnersOf(i)) {
983
990
  // Traces are variants of each other, not contradictions. Two
984
991
  // strategies for the same task can both be valid; conflict detection
985
992
  // exists for stated-rule disagreement, not strategy diversity.
@@ -989,7 +996,7 @@ rescuedIds = new Set()) {
989
996
  continue;
990
997
  if ([survivors[i], survivors[j]].some((e) => e.tags.includes('extracted') || e.tags.includes('session-digest')))
991
998
  continue;
992
- const reasonAndScore = describeConflict(survivors[i], survivors[j]);
999
+ const reasonAndScore = describeConflict(profiles[i], profiles[j]);
993
1000
  if (!reasonAndScore)
994
1001
  continue;
995
1002
  detected.push({
@@ -1002,25 +1009,26 @@ rescuedIds = new Set()) {
1002
1009
  }
1003
1010
  return detected;
1004
1011
  }
1012
+ function conflictProfile(text) {
1013
+ const opening = openingWindow(text);
1014
+ // Polarity is measured only in the first POLARITY_WINDOW_WORDS, so a stray
1015
+ // negation deep in a prose memory doesn't flip the intent.
1016
+ // Pad with spaces so space-delimited patterns match words at the start/end.
1017
+ return { distinct: distinctiveTokens(text), polarity: inferConflictPolarity(opening), window: ' ' + opening.toLowerCase() + ' ' };
1018
+ }
1005
1019
  function describeConflict(a, b) {
1006
- const aDistinct = distinctiveTokens(a.content);
1007
- const bDistinct = distinctiveTokens(b.content);
1008
1020
  // Jaccard on stopword-stripped tokens. Defer the threshold check until we
1009
1021
  // know whether an explicit polarity pair is present (lower bar for those).
1010
- const overlapScore = jaccardSets(aDistinct, bDistinct);
1022
+ const overlapScore = jaccardSets(a.distinct, b.distinct);
1011
1023
  // Require at least N shared distinctive tokens so two short memories sharing
1012
1024
  // only "the project name" don't register.
1013
1025
  let shared = 0;
1014
- for (const t of aDistinct)
1015
- if (bDistinct.has(t))
1026
+ for (const t of a.distinct)
1027
+ if (b.distinct.has(t))
1016
1028
  shared++;
1017
1029
  if (shared < CONFLICT_MIN_RARE_SHARED)
1018
1030
  return null;
1019
- // Polarity is measured only in the first POLARITY_WINDOW_WORDS, so a stray
1020
- // negation deep in a prose memory doesn't flip the intent.
1021
- const polarityA = inferConflictPolarity(openingWindow(a.content));
1022
- const polarityB = inferConflictPolarity(openingWindow(b.content));
1023
- const conflictType = classifyConflictType(a.content, b.content, polarityA, polarityB);
1031
+ const conflictType = classifyConflictType(a.window, b.window, a.polarity, b.polarity);
1024
1032
  if (!conflictType)
1025
1033
  return null;
1026
1034
  if (overlapScore < CONFLICT_OVERLAP_THRESHOLD)
@@ -1052,14 +1060,8 @@ function jaccardSets(a, b) {
1052
1060
  function openingWindow(text) {
1053
1061
  return text.split(/\s+/).slice(0, POLARITY_WINDOW_WORDS).join(' ');
1054
1062
  }
1055
- function classifyConflictType(aText, bText, aPolarity, bPolarity) {
1056
- // Classifier scans only the opening window of each memory so " on " and
1057
- // " off " used as English prepositions deep in a long prose memory don't
1058
- // trigger an enabled/disabled flag. The opening window is where a rule or
1059
- // declaration is typically stated.
1060
- // Pad with spaces so space-delimited patterns match words at the start/end.
1061
- const a = ' ' + openingWindow(aText).toLowerCase() + ' ';
1062
- const b = ' ' + openingWindow(bText).toLowerCase() + ' ';
1063
+ // Takes opening windows only, so " on " and " off " as prepositions deep in long prose don't read as enabled/disabled.
1064
+ function classifyConflictType(a, b, aPolarity, bPolarity) {
1063
1065
  // Tightened tokens: require whole-word boundaries so " on " alone doesn't
1064
1066
  // match "on/off". Pair only `enabled` ↔ `disabled` and explicit on/off in
1065
1067
  // imperative context.
@@ -19,6 +19,7 @@
19
19
  *
20
20
  * Lifecycle: active -> superseded (a corrected version) or active -> closed (retired).
21
21
  */
22
+ import { BadRequestError, ConflictError, NotFoundError } from './api-errors.js';
22
23
  import { openHippoDb, closeHippoDb } from './db.js';
23
24
  import { writeEntry } from './store.js';
24
25
  import { assertTenantId } from './tenant.js';
@@ -47,21 +48,21 @@ export const MAX_CHANGE_SUMMARY_LEN = 4096;
47
48
  function validateNoteFields(customer, note, changeSummary) {
48
49
  const normalizedCustomer = (customer ?? '').trim();
49
50
  if (normalizedCustomer.length === 0)
50
- throw new Error('saveCustomerNote: customer is required');
51
+ throw new BadRequestError('saveCustomerNote: customer is required');
51
52
  if (/[\r\n]/.test(normalizedCustomer)) {
52
- throw new Error('saveCustomerNote: customer must be a single line (no newlines)');
53
+ throw new BadRequestError('saveCustomerNote: customer must be a single line (no newlines)');
53
54
  }
54
55
  if (normalizedCustomer.length > MAX_CUSTOMER_LEN) {
55
- throw new Error(`saveCustomerNote: customer exceeds the ${MAX_CUSTOMER_LEN}-char cap`);
56
+ throw new BadRequestError(`saveCustomerNote: customer exceeds the ${MAX_CUSTOMER_LEN}-char cap`);
56
57
  }
57
58
  if (!note || note.trim().length === 0) {
58
- throw new Error('saveCustomerNote: note is required');
59
+ throw new BadRequestError('saveCustomerNote: note is required');
59
60
  }
60
61
  if (note.length > MAX_NOTE_LEN) {
61
- throw new Error(`saveCustomerNote: note exceeds the ${MAX_NOTE_LEN}-char cap`);
62
+ throw new BadRequestError(`saveCustomerNote: note exceeds the ${MAX_NOTE_LEN}-char cap`);
62
63
  }
63
64
  if (changeSummary !== undefined && changeSummary.length > MAX_CHANGE_SUMMARY_LEN) {
64
- throw new Error(`saveCustomerNote: changeSummary exceeds the ${MAX_CHANGE_SUMMARY_LEN}-char cap`);
65
+ throw new BadRequestError(`saveCustomerNote: changeSummary exceeds the ${MAX_CHANGE_SUMMARY_LEN}-char cap`);
65
66
  }
66
67
  return { customer: normalizedCustomer };
67
68
  }
@@ -137,10 +138,10 @@ export function saveCustomerNote(hippoRoot, tenantId, opts, actor = 'cli') {
137
138
  // shape for the matching row, or undefined when no note/tenant pair matches.
138
139
  const pred = db.prepare(`SELECT status, version FROM customer_notes WHERE id = ? AND tenant_id = ?`).get(opts.supersedesNoteId, tenantId);
139
140
  if (!pred) {
140
- throw new Error(`saveCustomerNote: note ${opts.supersedesNoteId} to supersede not found for tenant ${tenantId}`);
141
+ throw new NotFoundError(`saveCustomerNote: note ${opts.supersedesNoteId} to supersede not found for tenant ${tenantId}`);
141
142
  }
142
143
  if (pred.status !== 'active') {
143
- throw new Error(`saveCustomerNote: note ${opts.supersedesNoteId} is not active (status='${pred.status}'); only active notes can be superseded.`);
144
+ throw new ConflictError(`saveCustomerNote: note ${opts.supersedesNoteId} is not active (status='${pred.status}'); only active notes can be superseded.`);
144
145
  }
145
146
  version = pred.version + 1;
146
147
  }
@@ -158,7 +159,7 @@ export function saveCustomerNote(hippoRoot, tenantId, opts, actor = 'cli') {
158
159
  WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
159
160
  `).run(noteId, now, opts.supersedesNoteId, tenantId, noteId);
160
161
  if (sup.changes === 0) {
161
- throw new Error(`saveCustomerNote: note ${opts.supersedesNoteId} could not be superseded (no longer active or self-reference).`);
162
+ throw new ConflictError(`saveCustomerNote: note ${opts.supersedesNoteId} could not be superseded (no longer active or self-reference).`);
162
163
  }
163
164
  appendAuditEvent(db, {
164
165
  tenantId,
@@ -221,9 +222,9 @@ export function closeCustomerNote(hippoRoot, tenantId, id, actor = 'cli') {
221
222
  // shape, or undefined when the id/tenant pair doesn't exist.
222
223
  const existing = db.prepare(`SELECT status FROM customer_notes WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
223
224
  if (!existing) {
224
- throw new Error(`closeCustomerNote: note ${id} not found for tenant ${tenantId}`);
225
+ throw new NotFoundError(`closeCustomerNote: note ${id} not found for tenant ${tenantId}`);
225
226
  }
226
- throw new Error(`closeCustomerNote: note ${id} is not active (status='${existing.status}'); only active notes can be closed.`);
227
+ throw new ConflictError(`closeCustomerNote: note ${id} is not active (status='${existing.status}'); only active notes can be closed.`);
227
228
  }
228
229
  // SAFETY: SELECT ${NOTE_COLS} projects exactly the CustomerNoteRow columns;
229
230
  // .get() returns that row for the just-updated id, or undefined only in an
@@ -231,7 +232,7 @@ export function closeCustomerNote(hippoRoot, tenantId, id, actor = 'cli') {
231
232
  const row = db.prepare(`SELECT ${NOTE_COLS} FROM customer_notes WHERE id = ? AND tenant_id = ?`)
232
233
  .get(id, tenantId);
233
234
  if (!row)
234
- throw new Error(`closeCustomerNote: note ${id} not found after UPDATE`);
235
+ throw new NotFoundError(`closeCustomerNote: note ${id} not found after UPDATE`);
235
236
  appendAuditEvent(db, {
236
237
  tenantId,
237
238
  actor,
@@ -284,7 +285,7 @@ export function loadCustomerNotes(hippoRoot, tenantId, opts = {}) {
284
285
  assertTenantId('loadCustomerNotes', tenantId);
285
286
  const limit = opts.limit ?? 100;
286
287
  if (opts.status && !VALID_NOTE_STATES.has(opts.status)) {
287
- throw new Error(`loadCustomerNotes: status must be one of ${Array.from(VALID_NOTE_STATES).join('|')}; got ${opts.status}`);
288
+ throw new BadRequestError(`loadCustomerNotes: status must be one of ${Array.from(VALID_NOTE_STATES).join('|')}; got ${opts.status}`);
288
289
  }
289
290
  const db = openHippoDb(hippoRoot);
290
291
  try {
package/dist/dag.js CHANGED
@@ -2,6 +2,7 @@ import { createMemory, Layer } from './memory.js';
2
2
  import { writeEntry, loadAllDirtySummaries, loadChildrenOfSummary, applyRebuildResult, clearSummaryDirtyAfterBuild, } from './store.js';
3
3
  import { RejectedValueError } from './rejection.js';
4
4
  import { redactSecrets } from './secret-detect.js';
5
+ import { fetchWithRetry, llmTimeoutMs } from './http-retry.js';
5
6
  import { derivationScope, derivationPartitionKey } from './recall-scope.js';
6
7
  import { loadConfig } from './config.js';
7
8
  import { neverAutoShareTags } from './shared.js';
@@ -52,7 +53,7 @@ export async function generateDagSummary(label, factContents, opts) {
52
53
  .replace('{facts}', factsBlock);
53
54
  let res;
54
55
  try {
55
- res = await fetchFn('https://api.anthropic.com/v1/messages', {
56
+ res = await fetchWithRetry('https://api.anthropic.com/v1/messages', {
56
57
  method: 'POST',
57
58
  headers: {
58
59
  'content-type': 'application/json',
@@ -64,7 +65,7 @@ export async function generateDagSummary(label, factContents, opts) {
64
65
  max_tokens: 400,
65
66
  messages: [{ role: 'user', content: prompt }],
66
67
  }),
67
- });
68
+ }, { timeoutMs: llmTimeoutMs(), fetchFn });
68
69
  }
69
70
  catch (err) {
70
71
  opts.onError?.(`request failed: ${err instanceof Error ? err.message : String(err)}`);
package/dist/dashboard.js CHANGED
@@ -29,7 +29,7 @@ function buildDashboardData(hippoRoot) {
29
29
  // render resolved conflicts as faded historical context, not just open
30
30
  // conflicts. The open_conflicts stat below still counts only 'open' rows
31
31
  // to preserve the existing badge meaning.
32
- const conflicts = listMemoryConflicts(hippoRoot, '*');
32
+ const conflicts = listMemoryConflicts(hippoRoot, '*', tenantId);
33
33
  // D4 v1.12.10: tenant-scope peer discovery in the dashboard (matches the
34
34
  // tenantId already used for loadAllEntries on line 72).
35
35
  const peers = listPeers(undefined, tenantId);
package/dist/db.d.ts CHANGED
@@ -10,6 +10,8 @@ export interface DatabaseSyncLike {
10
10
  exec(sql: string): void;
11
11
  prepare(sql: string): StatementSyncLike;
12
12
  close(): void;
13
+ readonly isOpen?: boolean;
14
+ readonly isTransaction?: boolean;
13
15
  }
14
16
  export declare function getHippoDbPath(hippoRoot: string): string;
15
17
  export declare function getCurrentSchemaVersion(): number;
@@ -17,6 +19,16 @@ export declare function getCurrentSchemaVersion(): number;
17
19
  export declare class IncompatibleBinaryError extends Error {
18
20
  }
19
21
  export declare function isSqliteBusy(error: unknown): boolean;
22
+ export declare function execWithBusyRetry(db: DatabaseSyncLike, sql: string, timeoutMs?: number): void;
23
+ /** 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. */
24
+ export declare const HOOK_DB_WAIT_MS = 1000;
25
+ /** A busy store made a command skip work: warn once per process (the holder is usually `hippo sleep`). */
26
+ export declare function noteStoreBusy(skipped: string): void;
27
+ /** Runs `fn` with one handle per store: openHippoDb reuses it and closeHippoDb leaves it open until `fn` settles or the process exits.
28
+ * `busyWaitMs` is the lock wait of every open inside `fn` that does not pass its own. */
29
+ export declare function withSharedStoreHandles<T>(fn: () => T | Promise<T>, opts?: {
30
+ busyWaitMs?: number;
31
+ }): Promise<T>;
20
32
  /** `busyWaitMs` shortens every lock wait of this open, for a hook that must finish inside its own timeout. */
21
33
  export declare function openHippoDb(hippoRoot: string, opts?: {
22
34
  busyWaitMs?: number;
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,6 +23,7 @@
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
28
  import { writeEntry } from './store.js';
28
29
  import { assertTenantId } from './tenant.js';
@@ -73,7 +74,7 @@ const DECISION_COLS = `
73
74
  export function saveDecision(hippoRoot, tenantId, opts, actor = 'cli') {
74
75
  assertTenantId('saveDecision', tenantId);
75
76
  if (!opts.decisionText)
76
- throw new Error('saveDecision: decisionText is required');
77
+ throw new BadRequestError('saveDecision: decisionText is required');
77
78
  const now = new Date().toISOString();
78
79
  const content = opts.context
79
80
  ? `${opts.decisionText}\n\nContext: ${opts.context}`
@@ -103,10 +104,10 @@ export function saveDecision(hippoRoot, tenantId, opts, actor = 'cli') {
103
104
  // SAFETY: row shape matches the single `status` column named in the SELECT below.
104
105
  const pred = db.prepare(`SELECT status FROM decisions WHERE id = ? AND tenant_id = ?`).get(opts.supersedesDecisionId, tenantId);
105
106
  if (!pred) {
106
- 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}`);
107
108
  }
108
109
  if (pred.status !== 'active') {
109
- 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.`);
110
111
  }
111
112
  }
112
113
  const result = db.prepare(`
@@ -127,7 +128,7 @@ export function saveDecision(hippoRoot, tenantId, opts, actor = 'cli') {
127
128
  WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
128
129
  `).run(decisionId, now, opts.supersedesDecisionId, tenantId, decisionId);
129
130
  if (sup.changes === 0) {
130
- 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).`);
131
132
  }
132
133
  appendAuditEvent(db, {
133
134
  tenantId,
@@ -191,15 +192,15 @@ export function closeDecision(hippoRoot, tenantId, id, actor = 'cli') {
191
192
  // SAFETY: row shape matches the single `status` column named in the SELECT above.
192
193
  const existing = db.prepare(`SELECT status FROM decisions WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
193
194
  if (!existing) {
194
- throw new Error(`closeDecision: decision ${id} not found for tenant ${tenantId}`);
195
+ throw new NotFoundError(`closeDecision: decision ${id} not found for tenant ${tenantId}`);
195
196
  }
196
- 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.`);
197
198
  }
198
199
  // SAFETY: row's shape matches the columns named in DECISION_COLS above.
199
200
  const row = db.prepare(`SELECT ${DECISION_COLS} FROM decisions WHERE id = ? AND tenant_id = ?`)
200
201
  .get(id, tenantId);
201
202
  if (!row)
202
- throw new Error(`closeDecision: decision ${id} not found after UPDATE`);
203
+ throw new NotFoundError(`closeDecision: decision ${id} not found after UPDATE`);
203
204
  appendAuditEvent(db, {
204
205
  tenantId,
205
206
  actor,
@@ -256,7 +257,7 @@ export function loadDecisions(hippoRoot, tenantId, opts = {}) {
256
257
  let rows;
257
258
  if (opts.status) {
258
259
  if (!VALID_DECISION_STATES.has(opts.status)) {
259
- 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}`);
260
261
  }
261
262
  // SAFETY: rows' shape matches the columns named in DECISION_COLS above.
262
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