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
@@ -0,0 +1,203 @@
1
+ import { verifySlackSignature } from './signature.js';
2
+ import { isSlackEventEnvelope, isSlackMessageEvent } from './types.js';
3
+ import { ingestMessage } from './ingest.js';
4
+ import { handleMessageDeleted } from './deletion.js';
5
+ import { writeToDlq } from './dlq.js';
6
+ import { resolveTenantForTeam } from './tenant-routing.js';
7
+ import { resolveTenantId } from '../../tenant.js';
8
+ import { openHippoDb, closeHippoDb } from '../../db.js';
9
+ import { adminActor } from '../../api.js';
10
+ import { HttpError, JSON_HEADERS, isHeaderString, isJsonObjectRecord, readBody, sendJson, } from '../../http-util.js';
11
+ /**
12
+ * Slack Events API webhook. Auth is signature-based (HMAC over the raw
13
+ * body with SLACK_SIGNING_SECRET); Bearer is NOT required, which is why
14
+ * this route is in PUBLIC_ROUTES. The route is responsible for:
15
+ * 1. Echoing the one-time url_verification challenge.
16
+ * 2. Verifying the HMAC on every other inbound payload.
17
+ * 3. Resolving body.team_id → tenantId via slack_workspaces, falling
18
+ * back to HIPPO_TENANT then 'default'.
19
+ * 4. Dispatching event_callback envelopes to ingestMessage /
20
+ * handleMessageDeleted.
21
+ * 5. Parking malformed or unhandled payloads in slack_dlq and STILL
22
+ * ACKing 200 - Slack retries forever otherwise.
23
+ *
24
+ * When SLACK_SIGNING_SECRET is unset we return 404, not
25
+ * 503, so an external probe cannot distinguish "route gated off by config"
26
+ * from "route does not exist on this build".
27
+ *
28
+ * Bearer auth is skipped on purpose: server.ts checks isPublicRoute before calling this.
29
+ */
30
+ export async function handleSlackEventsWebhook({ req, res, opts }) {
31
+ const rawBody = await readBody(req);
32
+ const secret = process.env.SLACK_SIGNING_SECRET;
33
+ if (!secret) {
34
+ res.writeHead(404, JSON_HEADERS);
35
+ res.end(JSON.stringify({ error: 'not found' }));
36
+ return;
37
+ }
38
+ const previousSecret = process.env.SLACK_SIGNING_SECRET_PREVIOUS;
39
+ const sig = req.headers['x-slack-signature'];
40
+ const tsHdr = req.headers['x-slack-request-timestamp'];
41
+ const sigStr = isHeaderString(sig) ? sig : null;
42
+ const tsStr = isHeaderString(tsHdr) ? tsHdr : null;
43
+ if (sigStr === null ||
44
+ tsStr === null ||
45
+ !verifySlackSignature({
46
+ rawBody,
47
+ timestamp: tsStr,
48
+ signature: sigStr,
49
+ signingSecret: secret,
50
+ previousSecret,
51
+ })) {
52
+ throw new HttpError(401, 'invalid Slack signature');
53
+ }
54
+ // Cheap regex extracts team_id from a (possibly malformed) raw body so the
55
+ // DLQ row carries it for triage even when JSON.parse fails.
56
+ const teamIdFromRaw = (() => {
57
+ const m = rawBody.match(/"team_id"\s*:\s*"([^"]+)"/);
58
+ return m ? m[1] : null;
59
+ })();
60
+ let body;
61
+ try {
62
+ body = JSON.parse(rawBody);
63
+ }
64
+ catch {
65
+ // Attribute the parse failure to the originating workspace via the regex-extracted
66
+ // team_id; a null or unknown team writes tenantId=null, which lands as '__unroutable__'.
67
+ const db = openHippoDb(opts.hippoRoot);
68
+ try {
69
+ const parseFailTenant = teamIdFromRaw !== null ? resolveTenantForTeam(db, teamIdFromRaw) : null;
70
+ writeToDlq(db, {
71
+ tenantId: parseFailTenant, // null → '__unroutable__' sentinel
72
+ teamId: teamIdFromRaw,
73
+ rawPayload: rawBody,
74
+ error: 'invalid JSON',
75
+ bucket: 'parse_error',
76
+ signature: sigStr,
77
+ slackTimestamp: tsStr,
78
+ });
79
+ }
80
+ finally {
81
+ closeHippoDb(db);
82
+ }
83
+ sendJson(res, 200, { ok: true, status: 'dlq' });
84
+ return;
85
+ }
86
+ if (isJsonObjectRecord(body)) {
87
+ const bodyRecord = body;
88
+ if (bodyRecord.type === 'url_verification') {
89
+ sendJson(res, 200, {
90
+ challenge: String(bodyRecord.challenge ?? ''),
91
+ });
92
+ return;
93
+ }
94
+ }
95
+ // Resolve tenant, failing closed: when slack_workspaces is non-empty
96
+ // and the team_id is unknown, resolveTenantForTeam returns null and we
97
+ // park the envelope in slack_dlq with bucket='unroutable'. Mandatory ACK
98
+ // 200 so Slack stops retrying; do NOT call ingest.
99
+ let resolvedTenant = null;
100
+ if (body !== undefined && isSlackEventEnvelope(body)) {
101
+ const db = openHippoDb(opts.hippoRoot);
102
+ try {
103
+ resolvedTenant = resolveTenantForTeam(db, body.team_id);
104
+ }
105
+ finally {
106
+ closeHippoDb(db);
107
+ }
108
+ if (resolvedTenant === null) {
109
+ const db2 = openHippoDb(opts.hippoRoot);
110
+ try {
111
+ writeToDlq(db2, {
112
+ tenantId: null, // unroutable - stored as '__unroutable__'
113
+ teamId: body.team_id,
114
+ rawPayload: rawBody,
115
+ error: `unroutable team_id: ${body.team_id}`,
116
+ bucket: 'unroutable',
117
+ signature: sigStr,
118
+ slackTimestamp: tsStr,
119
+ });
120
+ }
121
+ finally {
122
+ closeHippoDb(db2);
123
+ }
124
+ sendJson(res, 200, { ok: true, status: 'dlq' });
125
+ return;
126
+ }
127
+ }
128
+ else {
129
+ // Non-envelope payload: use env tenant for the DLQ row's bookkeeping.
130
+ resolvedTenant = resolveTenantId({});
131
+ }
132
+ const ctx = {
133
+ hippoRoot: opts.hippoRoot,
134
+ tenantId: resolvedTenant,
135
+ actor: adminActor('connector:slack'),
136
+ };
137
+ if (body === undefined || !isSlackEventEnvelope(body)) {
138
+ const db = openHippoDb(ctx.hippoRoot);
139
+ try {
140
+ writeToDlq(db, {
141
+ tenantId: ctx.tenantId,
142
+ teamId: teamIdFromRaw,
143
+ rawPayload: rawBody,
144
+ error: 'not an event_callback envelope',
145
+ bucket: 'parse_error',
146
+ signature: sigStr,
147
+ slackTimestamp: tsStr,
148
+ });
149
+ }
150
+ finally {
151
+ closeHippoDb(db);
152
+ }
153
+ sendJson(res, 200, { ok: true, status: 'dlq' });
154
+ return;
155
+ }
156
+ const inner = body.event;
157
+ if (isSlackMessageEvent(inner)) {
158
+ if (inner.subtype === 'message_deleted' && inner.deleted_ts) {
159
+ const r = handleMessageDeleted(ctx, {
160
+ teamId: body.team_id,
161
+ channelId: inner.channel,
162
+ deletedTs: inner.deleted_ts,
163
+ eventId: body.event_id,
164
+ });
165
+ sendJson(res, 200, { ok: true, status: r.status });
166
+ return;
167
+ }
168
+ const r = ingestMessage(ctx, {
169
+ teamId: body.team_id,
170
+ // channel privacy isn't on the inner event; use channel_type as a
171
+ // proxy. 'group'|'im'|'mpim' → private. 'channel' → public. Unknown
172
+ // → private (fail closed).
173
+ channel: {
174
+ id: inner.channel,
175
+ is_private: inner.channel_type !== 'channel',
176
+ is_im: inner.channel_type === 'im',
177
+ is_mpim: inner.channel_type === 'mpim',
178
+ },
179
+ message: inner,
180
+ eventId: body.event_id,
181
+ });
182
+ sendJson(res, 200, { ok: true, status: r.status, memoryId: r.memoryId });
183
+ return;
184
+ }
185
+ const db = openHippoDb(ctx.hippoRoot);
186
+ try {
187
+ writeToDlq(db, {
188
+ tenantId: ctx.tenantId,
189
+ teamId: body.team_id,
190
+ rawPayload: rawBody,
191
+ error: `unhandled event type: ${inner.type ?? 'unknown'}`,
192
+ bucket: 'parse_error',
193
+ signature: sigStr,
194
+ slackTimestamp: tsStr,
195
+ });
196
+ }
197
+ finally {
198
+ closeHippoDb(db);
199
+ }
200
+ sendJson(res, 200, { ok: true, status: 'dlq' });
201
+ return;
202
+ }
203
+ //# sourceMappingURL=webhook.js.map
@@ -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
@@ -7,9 +7,10 @@
7
7
  * 3. Stats tracking
8
8
  */
9
9
  import { evalNow, isRecallBoostAblated } from './ablation.js';
10
- import { Layer, calculateStrength, canAutoDelete, createMemory } from './memory.js';
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, markRetrieved, tokenize } from './search.js';
12
+ import { tokenize } from './tokenize.js';
13
+ import { jaccardMinShared, overlapPartners } from './overlap-index.js';
13
14
  import { compareEntryIdentity } from './compare.js';
14
15
  import { duplicateKey, mergedText } from './same-text.js';
15
16
  import { successorAfterRetirement } from './merged-row.js';
@@ -25,7 +26,7 @@ import { renderTraceContent } from './trace.js';
25
26
  import { resolveTenantId } from './tenant.js';
26
27
  import { rescueSet, rankNonPinnedByTenant, validateWeights } from './memory-value.js';
27
28
  import { MEMORY_VALUE_WEIGHTS, SOURCE_ARTIFACT_SHA256 } from './memory-value-weights.js';
28
- import { appendAuditEvent } from './audit.js';
29
+ import { appendAuditEvent, reportAuditWriteFailure } from './audit.js';
29
30
  import { migrateDefaultHalfLife, LEGACY_TYPED_HALF_LIFE } from './half-life-migration.js';
30
31
  import { derivationScope, commonDerivationScope, derivationPartitionKey } from './recall-scope.js';
31
32
  import { isQuarantineScope } from './quarantine.js';
@@ -418,8 +419,8 @@ export async function consolidate(hippoRoot, options = {}) {
418
419
  },
419
420
  });
420
421
  }
421
- catch {
422
- // Best-effort — mirrors store.ts's audit() semantics.
422
+ catch (error) {
423
+ reportAuditWriteFailure('reject_refusal', String(error));
423
424
  }
424
425
  continue;
425
426
  }
@@ -707,17 +708,14 @@ export async function consolidate(hippoRoot, options = {}) {
707
708
  for (const [, tenantCandidates] of mergeCandidatesByTenant) {
708
709
  const mergeTenant = tenantCandidates[0].tenantId;
709
710
  const mergeScope = derivationScope(tenantCandidates[0].scope);
711
+ const partnersOf = mergePartners(tenantCandidates.map((e) => e.content));
710
712
  for (let i = 0; i < tenantCandidates.length; i++) {
711
713
  if (used.has(tenantCandidates[i].id) || tenantCandidates[i].content.length > MERGE_MAX_CHARS)
712
714
  continue;
713
715
  const related = [tenantCandidates[i]];
714
- for (let j = i + 1; j < tenantCandidates.length; j++) {
715
- if (used.has(tenantCandidates[j].id))
716
- continue;
717
- const overlap = textOverlap(tenantCandidates[i].content, tenantCandidates[j].content);
718
- if (overlap >= MERGE_OVERLAP_THRESHOLD) {
716
+ for (const j of partnersOf(i)) {
717
+ if (!used.has(tenantCandidates[j].id))
719
718
  related.push(tenantCandidates[j]);
720
- }
721
719
  }
722
720
  const cluster = [];
723
721
  let clusterChars = 0;
@@ -792,8 +790,8 @@ export async function consolidate(hippoRoot, options = {}) {
792
790
  },
793
791
  });
794
792
  }
795
- catch {
796
- // Best-effort — mirrors store.ts's audit() semantics.
793
+ catch (error) {
794
+ reportAuditWriteFailure('reject_refusal', String(error));
797
795
  }
798
796
  continue;
799
797
  }
@@ -910,8 +908,9 @@ export async function consolidate(hippoRoot, options = {}) {
910
908
  : {},
911
909
  });
912
910
  }
913
- catch {
911
+ catch (error) {
914
912
  auditFailures++;
913
+ reportAuditWriteFailure('mv_rescue', String(error), entry.id);
915
914
  }
916
915
  }
917
916
  if (auditFailures > 0) {
@@ -964,7 +963,14 @@ function pickStrongestValence(entries) {
964
963
  }
965
964
  return 'neutral';
966
965
  }
967
- 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 = {},
968
974
  // LC2-E3 (opt-in, default off): ids rescued by this cycle's decay pass.
969
975
  // detectConflicts recomputes its own strength>=DECAY_THRESHOLD survivor
970
976
  // filter independently of the decay pass above; without this bypass,
@@ -977,8 +983,10 @@ rescuedIds = new Set()) {
977
983
  && !isQuarantineScope(entry.scope ?? null)
978
984
  && (rescuedIds.has(entry.id) || calculateStrength(entry, now, decayOpts) >= DECAY_THRESHOLD));
979
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));
980
988
  for (let i = 0; i < survivors.length; i++) {
981
- for (let j = i + 1; j < survivors.length; j++) {
989
+ for (const j of partnersOf(i)) {
982
990
  // Traces are variants of each other, not contradictions. Two
983
991
  // strategies for the same task can both be valid; conflict detection
984
992
  // exists for stated-rule disagreement, not strategy diversity.
@@ -988,7 +996,7 @@ rescuedIds = new Set()) {
988
996
  continue;
989
997
  if ([survivors[i], survivors[j]].some((e) => e.tags.includes('extracted') || e.tags.includes('session-digest')))
990
998
  continue;
991
- const reasonAndScore = describeConflict(survivors[i], survivors[j]);
999
+ const reasonAndScore = describeConflict(profiles[i], profiles[j]);
992
1000
  if (!reasonAndScore)
993
1001
  continue;
994
1002
  detected.push({
@@ -1001,25 +1009,26 @@ rescuedIds = new Set()) {
1001
1009
  }
1002
1010
  return detected;
1003
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
+ }
1004
1019
  function describeConflict(a, b) {
1005
- const aDistinct = distinctiveTokens(a.content);
1006
- const bDistinct = distinctiveTokens(b.content);
1007
1020
  // Jaccard on stopword-stripped tokens. Defer the threshold check until we
1008
1021
  // know whether an explicit polarity pair is present (lower bar for those).
1009
- const overlapScore = jaccardSets(aDistinct, bDistinct);
1022
+ const overlapScore = jaccardSets(a.distinct, b.distinct);
1010
1023
  // Require at least N shared distinctive tokens so two short memories sharing
1011
1024
  // only "the project name" don't register.
1012
1025
  let shared = 0;
1013
- for (const t of aDistinct)
1014
- if (bDistinct.has(t))
1026
+ for (const t of a.distinct)
1027
+ if (b.distinct.has(t))
1015
1028
  shared++;
1016
1029
  if (shared < CONFLICT_MIN_RARE_SHARED)
1017
1030
  return null;
1018
- // Polarity is measured only in the first POLARITY_WINDOW_WORDS, so a stray
1019
- // negation deep in a prose memory doesn't flip the intent.
1020
- const polarityA = inferConflictPolarity(openingWindow(a.content));
1021
- const polarityB = inferConflictPolarity(openingWindow(b.content));
1022
- const conflictType = classifyConflictType(a.content, b.content, polarityA, polarityB);
1031
+ const conflictType = classifyConflictType(a.window, b.window, a.polarity, b.polarity);
1023
1032
  if (!conflictType)
1024
1033
  return null;
1025
1034
  if (overlapScore < CONFLICT_OVERLAP_THRESHOLD)
@@ -1051,14 +1060,8 @@ function jaccardSets(a, b) {
1051
1060
  function openingWindow(text) {
1052
1061
  return text.split(/\s+/).slice(0, POLARITY_WINDOW_WORDS).join(' ');
1053
1062
  }
1054
- function classifyConflictType(aText, bText, aPolarity, bPolarity) {
1055
- // Classifier scans only the opening window of each memory so " on " and
1056
- // " off " used as English prepositions deep in a long prose memory don't
1057
- // trigger an enabled/disabled flag. The opening window is where a rule or
1058
- // declaration is typically stated.
1059
- // Pad with spaces so space-delimited patterns match words at the start/end.
1060
- const a = ' ' + openingWindow(aText).toLowerCase() + ' ';
1061
- 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) {
1062
1065
  // Tightened tokens: require whole-word boundaries so " on " alone doesn't
1063
1066
  // match "on/off". Pair only `enabled` ↔ `disabled` and explicit on/off in
1064
1067
  // imperative context.
@@ -0,0 +1,3 @@
1
+ /** A context query from the cwd's git state ('' means `getContext` lists by strength); kept out of the api layer, which never shells out. */
2
+ export declare function autoDetectContext(): string;
3
+ //# sourceMappingURL=context-auto.d.ts.map
@@ -0,0 +1,34 @@
1
+ import { execSync } from 'child_process';
2
+ const SKIPPED_PATH_TERMS = ['src', 'dist', 'test', 'tests', 'node_modules', 'index'];
3
+ /** A context query from the cwd's git state ('' means `getContext` lists by strength); kept out of the api layer, which never shells out. */
4
+ export function autoDetectContext() {
5
+ try {
6
+ const diff = execSync('git diff --name-only HEAD 2>&1', {
7
+ encoding: 'utf8',
8
+ timeout: 3000,
9
+ windowsHide: true,
10
+ }).trim();
11
+ if (diff) {
12
+ const terms = diff
13
+ .split('\n')
14
+ .flatMap((f) => f.replace(/[/\\.]/g, ' ').split(/\s+/))
15
+ .filter((t) => t.length > 2 && !SKIPPED_PATH_TERMS.includes(t))
16
+ .slice(0, 10);
17
+ if (terms.length > 0)
18
+ return terms.join(' ');
19
+ }
20
+ const branch = execSync('git branch --show-current 2>&1', {
21
+ encoding: 'utf8',
22
+ timeout: 3000,
23
+ windowsHide: true,
24
+ }).trim();
25
+ if (branch && branch !== 'main' && branch !== 'master') {
26
+ return branch.replace(/[-_/]/g, ' ');
27
+ }
28
+ }
29
+ catch {
30
+ // Not a git repo or git missing: no query, so the caller lists by strength.
31
+ }
32
+ return '';
33
+ }
34
+ //# sourceMappingURL=context-auto.js.map
@@ -19,8 +19,10 @@
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
- import { writeEntry, assertTenantId } from './store.js';
24
+ import { writeEntry } from './store.js';
25
+ import { assertTenantId } from './tenant.js';
24
26
  import { markGraphDirty, removeGraphEntitiesForObject } from './graph.js';
25
27
  import { createMemory, Layer } from './memory.js';
26
28
  import { appendAuditEvent } from './audit.js';
@@ -46,21 +48,21 @@ export const MAX_CHANGE_SUMMARY_LEN = 4096;
46
48
  function validateNoteFields(customer, note, changeSummary) {
47
49
  const normalizedCustomer = (customer ?? '').trim();
48
50
  if (normalizedCustomer.length === 0)
49
- throw new Error('saveCustomerNote: customer is required');
51
+ throw new BadRequestError('saveCustomerNote: customer is required');
50
52
  if (/[\r\n]/.test(normalizedCustomer)) {
51
- 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)');
52
54
  }
53
55
  if (normalizedCustomer.length > MAX_CUSTOMER_LEN) {
54
- 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`);
55
57
  }
56
58
  if (!note || note.trim().length === 0) {
57
- throw new Error('saveCustomerNote: note is required');
59
+ throw new BadRequestError('saveCustomerNote: note is required');
58
60
  }
59
61
  if (note.length > MAX_NOTE_LEN) {
60
- 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`);
61
63
  }
62
64
  if (changeSummary !== undefined && changeSummary.length > MAX_CHANGE_SUMMARY_LEN) {
63
- 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`);
64
66
  }
65
67
  return { customer: normalizedCustomer };
66
68
  }
@@ -136,10 +138,10 @@ export function saveCustomerNote(hippoRoot, tenantId, opts, actor = 'cli') {
136
138
  // shape for the matching row, or undefined when no note/tenant pair matches.
137
139
  const pred = db.prepare(`SELECT status, version FROM customer_notes WHERE id = ? AND tenant_id = ?`).get(opts.supersedesNoteId, tenantId);
138
140
  if (!pred) {
139
- 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}`);
140
142
  }
141
143
  if (pred.status !== 'active') {
142
- 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.`);
143
145
  }
144
146
  version = pred.version + 1;
145
147
  }
@@ -157,7 +159,7 @@ export function saveCustomerNote(hippoRoot, tenantId, opts, actor = 'cli') {
157
159
  WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
158
160
  `).run(noteId, now, opts.supersedesNoteId, tenantId, noteId);
159
161
  if (sup.changes === 0) {
160
- 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).`);
161
163
  }
162
164
  appendAuditEvent(db, {
163
165
  tenantId,
@@ -220,9 +222,9 @@ export function closeCustomerNote(hippoRoot, tenantId, id, actor = 'cli') {
220
222
  // shape, or undefined when the id/tenant pair doesn't exist.
221
223
  const existing = db.prepare(`SELECT status FROM customer_notes WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
222
224
  if (!existing) {
223
- throw new Error(`closeCustomerNote: note ${id} not found for tenant ${tenantId}`);
225
+ throw new NotFoundError(`closeCustomerNote: note ${id} not found for tenant ${tenantId}`);
224
226
  }
225
- 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.`);
226
228
  }
227
229
  // SAFETY: SELECT ${NOTE_COLS} projects exactly the CustomerNoteRow columns;
228
230
  // .get() returns that row for the just-updated id, or undefined only in an
@@ -230,7 +232,7 @@ export function closeCustomerNote(hippoRoot, tenantId, id, actor = 'cli') {
230
232
  const row = db.prepare(`SELECT ${NOTE_COLS} FROM customer_notes WHERE id = ? AND tenant_id = ?`)
231
233
  .get(id, tenantId);
232
234
  if (!row)
233
- throw new Error(`closeCustomerNote: note ${id} not found after UPDATE`);
235
+ throw new NotFoundError(`closeCustomerNote: note ${id} not found after UPDATE`);
234
236
  appendAuditEvent(db, {
235
237
  tenantId,
236
238
  actor,
@@ -283,7 +285,7 @@ export function loadCustomerNotes(hippoRoot, tenantId, opts = {}) {
283
285
  assertTenantId('loadCustomerNotes', tenantId);
284
286
  const limit = opts.limit ?? 100;
285
287
  if (opts.status && !VALID_NOTE_STATES.has(opts.status)) {
286
- 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}`);
287
289
  }
288
290
  const db = openHippoDb(hippoRoot);
289
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
@@ -9,7 +9,8 @@ import * as os from 'os';
9
9
  import * as path from 'path';
10
10
  import * as fs from 'fs';
11
11
  import { extractPathTags } from './path-context.js';
12
- import { loadAllEntries, listCards, listMemoryConflicts, readEntry, writeEntry } from './store.js';
12
+ import { loadAllEntries, listMemoryConflicts, readEntry, writeEntry } from './store.js';
13
+ import { listCards } from './store-cards.js';
13
14
  import { calculateStrength, confidenceFacets } from './memory.js';
14
15
  import { loadConfig } from './config.js';
15
16
  import { listPeers } from './shared.js';
@@ -28,7 +29,7 @@ function buildDashboardData(hippoRoot) {
28
29
  // render resolved conflicts as faded historical context, not just open
29
30
  // conflicts. The open_conflicts stat below still counts only 'open' rows
30
31
  // to preserve the existing badge meaning.
31
- const conflicts = listMemoryConflicts(hippoRoot, '*');
32
+ const conflicts = listMemoryConflicts(hippoRoot, '*', tenantId);
32
33
  // D4 v1.12.10: tenant-scope peer discovery in the dashboard (matches the
33
34
  // tenantId already used for loadAllEntries on line 72).
34
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;