hippo-memory 1.61.0 → 1.62.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 (57) hide show
  1. package/README.md +28 -53
  2. package/dist/agent-memories/apply.d.ts +1 -1
  3. package/dist/agent-memories/claude-code.d.ts +3 -1
  4. package/dist/agent-memories/claude-code.js +53 -9
  5. package/dist/agent-memories/sync.d.ts +3 -3
  6. package/dist/agent-memories/sync.js +14 -6
  7. package/dist/agent-memories/types.d.ts +0 -2
  8. package/dist/api/assemble.js +55 -54
  9. package/dist/api/context-select.d.ts +49 -0
  10. package/dist/api/context-select.js +344 -0
  11. package/dist/api/context.d.ts +2 -2
  12. package/dist/api/context.js +195 -522
  13. package/dist/api/drill-down.js +36 -33
  14. package/dist/api/promote.js +55 -66
  15. package/dist/api/recall.js +303 -438
  16. package/dist/api/sleep.js +203 -218
  17. package/dist/capture/compact.d.ts +1 -1
  18. package/dist/capture/compact.js +2 -2
  19. package/dist/cli/briefs.js +324 -306
  20. package/dist/cli/context.js +44 -34
  21. package/dist/cli/continuity.js +283 -271
  22. package/dist/cli/curate.js +35 -34
  23. package/dist/cli/decisions.js +333 -333
  24. package/dist/cli/explain.js +66 -60
  25. package/dist/cli/maintenance.js +62 -51
  26. package/dist/cli/playbooks.js +387 -370
  27. package/dist/cli/projects.js +8 -5
  28. package/dist/cli/recall.js +28 -43
  29. package/dist/cli/remember.js +113 -70
  30. package/dist/cli/session-hooks.js +100 -90
  31. package/dist/cli/setup.js +267 -246
  32. package/dist/cli/status.js +73 -64
  33. package/dist/cli/transfer.js +85 -99
  34. package/dist/compaction-record.d.ts +0 -2
  35. package/dist/compaction-record.js +1 -1
  36. package/dist/customer-notes.js +77 -68
  37. package/dist/dag.js +222 -186
  38. package/dist/decisions.js +93 -76
  39. package/dist/doctor.js +11 -7
  40. package/dist/goals.js +99 -86
  41. package/dist/incidents.js +45 -38
  42. package/dist/policies.js +85 -68
  43. package/dist/processes.js +87 -71
  44. package/dist/project-briefs.js +135 -108
  45. package/dist/project-merge.d.ts +12 -4
  46. package/dist/project-merge.js +130 -45
  47. package/dist/shared.d.ts +9 -0
  48. package/dist/shared.js +10 -8
  49. package/dist/skills.js +81 -65
  50. package/dist/store/search-rows.d.ts +2 -2
  51. package/dist/store/search-rows.js +15 -9
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  55. package/extensions/openclaw-plugin/package.json +1 -1
  56. package/openclaw.plugin.json +1 -1
  57. package/package.json +1 -1
package/dist/dag.js CHANGED
@@ -87,17 +87,14 @@ export async function generateDagSummary(label, factContents, opts) {
87
87
  return null;
88
88
  }
89
89
  }
90
- export async function buildDag(hippoRoot, facts, opts) {
91
- const result = { candidateClusters: 0, summariesCreated: 0, factsLinked: 0, rejected: 0 };
92
- const baseHalfLifeDays = loadConfig(hippoRoot).defaultHalfLifeDays;
93
- const unparented = facts.filter((f) => f.dag_level === 1 && !f.dag_parent_id && f.tags.includes('extracted'));
94
- // Hardening follow-up (mirrors consolidate.ts's mergeCandidatesByTenant,
95
- // T1): partition unparented facts by tenantId BEFORE clustering so a
96
- // cluster can never mix facts from different tenants into one
97
- // LLM-synthesized summary. Map preserves insertion order, so
98
- // single-tenant stores (every row 'default') get exactly one partition
99
- // and iterate in the same order as before this fix — byte-identical
100
- // behavior there.
90
+ // Hardening follow-up (mirrors consolidate.ts's mergeCandidatesByTenant,
91
+ // T1): partition unparented facts by tenantId BEFORE clustering so a
92
+ // cluster can never mix facts from different tenants into one
93
+ // LLM-synthesized summary. Map preserves insertion order, so
94
+ // single-tenant stores (every row 'default') get exactly one partition
95
+ // and iterate in the same order as before this fix — byte-identical
96
+ // behavior there.
97
+ function partitionFactsByTenant(unparented) {
101
98
  const unparentedByTenant = new Map();
102
99
  for (const fact of unparented) {
103
100
  const key = derivationPartitionKey(fact.tenantId, fact.scope, fact.origin_project);
@@ -107,79 +104,149 @@ export async function buildDag(hippoRoot, facts, opts) {
107
104
  else
108
105
  unparentedByTenant.set(key, [fact]);
109
106
  }
110
- for (const [, tenantFacts] of unparentedByTenant) {
111
- const factTenant = tenantFacts[0].tenantId;
112
- const factScope = derivationScope(tenantFacts[0].scope);
107
+ return unparentedByTenant;
108
+ }
109
+ /** The L2 summary entry for one cluster, landing in the facts' own tenant and scope. */
110
+ function createClusterSummaryEntry(summary, cluster, home) {
111
+ const memberCreatedAts = cluster.members.map((m) => m.created).sort();
112
+ // Every member of `cluster` shares home.tenantId by construction (the
113
+ // tenant partition above), so the summary lands in the same tenant
114
+ // as the facts it summarizes instead of always 'default'
115
+ // (memory.ts:535 defaults tenantId when the option is omitted).
116
+ const summaryEntry = createMemory(summary, {
117
+ layer: Layer.Semantic,
118
+ tags: [...cluster.entityTags, ...neverAutoShareTags(cluster.members), 'dag-summary'],
119
+ confidence: 'inferred',
120
+ dag_level: 2,
121
+ tenantId: home.tenantId,
122
+ scope: home.scope,
123
+ baseHalfLifeDays: home.baseHalfLifeDays,
124
+ });
125
+ summaryEntry.origin_project = home.originProject;
126
+ // Schema v25: cache descendant_count + earliest/latest_at on the summary
127
+ // row so DAG-aware recall (docs/plans/2026-05-05-dag-recall.md Task 2)
128
+ // can reason about scope without walking the children.
129
+ summaryEntry.descendant_count = cluster.members.length;
130
+ summaryEntry.earliest_at = memberCreatedAts[0];
131
+ summaryEntry.latest_at = memberCreatedAts[memberCreatedAts.length - 1];
132
+ return summaryEntry;
133
+ }
134
+ /** Summarize one eligible cluster, write the summary, then re-parent its members under it. */
135
+ async function summarizeCluster(hippoRoot, cluster, home, opts, result) {
136
+ const summary = await generateDagSummary(cluster.label, cluster.members.map((m) => m.content), opts);
137
+ if (!summary)
138
+ return;
139
+ const summaryEntry = createClusterSummaryEntry(summary, cluster, home);
140
+ // AT1 (plan §3 containment): a refused LLM-synthesized summary skips
141
+ // ONLY this cluster — the sleep cycle continues to the next one. The
142
+ // member re-parenting writes below never run for a skipped cluster
143
+ // (there is no summary id to parent them under).
144
+ //
145
+ // The tombstone check itself is tenant-scoped for free: writeEntry ->
146
+ // writeEntryDbOnly -> upsertEntryRow calls
147
+ // checkRejectionGuard(db, entry.tenantId ?? 'default', ...)
148
+ // (store/entry-writes.ts), reading tenantId off the entry being written. Now
149
+ // that summaryEntry carries home.tenantId instead of the implicit
150
+ // 'default', the guard consults that tenant's tombstones — no
151
+ // separate check needed here (unlike consolidate.ts's merge pass,
152
+ // which pre-checks via findRejectedValue because it writes through
153
+ // batchWriteAndDelete's bypassRejectionGuard path instead of
154
+ // writeEntry).
155
+ try {
156
+ writeEntry(hippoRoot, summaryEntry);
157
+ }
158
+ catch (err) {
159
+ if (err instanceof RejectedValueError) {
160
+ result.rejected++;
161
+ log.warn(`buildDag: cluster "${cluster.label}" skipped: summary matches a rejected value`);
162
+ return;
163
+ }
164
+ throw err;
165
+ }
166
+ result.summariesCreated++;
167
+ for (const member of cluster.members) {
168
+ const updated = { ...member, dag_parent_id: summaryEntry.id };
169
+ writeEntry(hippoRoot, updated);
170
+ result.factsLinked++;
171
+ }
172
+ // v0.30 / E3 — cancel the cascade of dirty-marks fired by member
173
+ // writeEntry calls (E2 hook on writeEntryDbOnly in store/entry-writes.ts).
174
+ // The summary we just built IS fresh, no rebuild needed. Without this,
175
+ // E3 in the SAME sleep cycle would re-rebuild every new summary
176
+ // (2x LLM cost). plan-eng-r1 HIGH must-fix.
177
+ clearSummaryDirtyAfterBuild(hippoRoot, summaryEntry.id, summaryEntry.tenantId, 'buildDag');
178
+ }
179
+ export async function buildDag(hippoRoot, facts, opts) {
180
+ const result = { candidateClusters: 0, summariesCreated: 0, factsLinked: 0, rejected: 0 };
181
+ const baseHalfLifeDays = loadConfig(hippoRoot).defaultHalfLifeDays;
182
+ const unparented = facts.filter((f) => f.dag_level === 1 && !f.dag_parent_id && f.tags.includes('extracted'));
183
+ for (const [, tenantFacts] of partitionFactsByTenant(unparented)) {
184
+ const home = {
185
+ tenantId: tenantFacts[0].tenantId,
186
+ scope: derivationScope(tenantFacts[0].scope),
187
+ originProject: tenantFacts[0].origin_project,
188
+ baseHalfLifeDays,
189
+ };
113
190
  const clusters = clusterFacts(tenantFacts);
114
191
  const eligibleClusters = clusters.filter((c) => c.members.length >= 3);
115
192
  result.candidateClusters += eligibleClusters.length;
116
193
  for (const cluster of eligibleClusters) {
117
- const summary = await generateDagSummary(cluster.label, cluster.members.map((m) => m.content), opts);
118
- if (!summary)
119
- continue;
120
- const memberCreatedAts = cluster.members.map((m) => m.created).sort();
121
- // Every member of `cluster` shares factTenant by construction (the
122
- // tenant partition above), so the summary lands in the same tenant
123
- // as the facts it summarizes instead of always 'default'
124
- // (memory.ts:535 defaults tenantId when the option is omitted).
125
- const summaryEntry = createMemory(summary, {
126
- layer: Layer.Semantic,
127
- tags: [...cluster.entityTags, ...neverAutoShareTags(cluster.members), 'dag-summary'],
128
- confidence: 'inferred',
129
- dag_level: 2,
130
- tenantId: factTenant,
131
- scope: factScope,
132
- baseHalfLifeDays,
133
- });
134
- summaryEntry.origin_project = tenantFacts[0].origin_project;
135
- // Schema v25: cache descendant_count + earliest/latest_at on the summary
136
- // row so DAG-aware recall (docs/plans/2026-05-05-dag-recall.md Task 2)
137
- // can reason about scope without walking the children.
138
- summaryEntry.descendant_count = cluster.members.length;
139
- summaryEntry.earliest_at = memberCreatedAts[0];
140
- summaryEntry.latest_at = memberCreatedAts[memberCreatedAts.length - 1];
141
- // AT1 (plan §3 containment): a refused LLM-synthesized summary skips
142
- // ONLY this cluster — the sleep cycle continues to the next one. The
143
- // member re-parenting writes below never run for a skipped cluster
144
- // (there is no summary id to parent them under).
145
- //
146
- // The tombstone check itself is tenant-scoped for free: writeEntry ->
147
- // writeEntryDbOnly -> upsertEntryRow calls
148
- // checkRejectionGuard(db, entry.tenantId ?? 'default', ...)
149
- // (store/entry-writes.ts), reading tenantId off the entry being written. Now
150
- // that summaryEntry carries factTenant instead of the implicit
151
- // 'default', the guard consults that tenant's tombstones — no
152
- // separate check needed here (unlike consolidate.ts's merge pass,
153
- // which pre-checks via findRejectedValue because it writes through
154
- // batchWriteAndDelete's bypassRejectionGuard path instead of
155
- // writeEntry).
156
- try {
157
- writeEntry(hippoRoot, summaryEntry);
158
- }
159
- catch (err) {
160
- if (err instanceof RejectedValueError) {
161
- result.rejected++;
162
- log.warn(`buildDag: cluster "${cluster.label}" skipped: summary matches a rejected value`);
163
- continue;
164
- }
165
- throw err;
166
- }
167
- result.summariesCreated++;
168
- for (const member of cluster.members) {
169
- const updated = { ...member, dag_parent_id: summaryEntry.id };
170
- writeEntry(hippoRoot, updated);
171
- result.factsLinked++;
172
- }
173
- // v0.30 / E3 — cancel the cascade of dirty-marks fired by member
174
- // writeEntry calls (E2 hook on writeEntryDbOnly in store/entry-writes.ts).
175
- // The summary we just built IS fresh, no rebuild needed. Without this,
176
- // E3 in the SAME sleep cycle would re-rebuild every new summary
177
- // (2x LLM cost). plan-eng-r1 HIGH must-fix.
178
- clearSummaryDirtyAfterBuild(hippoRoot, summaryEntry.id, summaryEntry.tenantId, 'buildDag');
194
+ await summarizeCluster(hippoRoot, cluster, home, opts, result);
179
195
  }
180
196
  }
181
197
  return result;
182
198
  }
199
+ /** Regenerate one dirty summary from its children and tally the outcome; throws reach the caller's isolation. */
200
+ async function rebuildOneSummary(hippoRoot, summary, opts, result) {
201
+ const children = loadChildrenOfSummary(hippoRoot, summary.id, summary.tenantId);
202
+ if (children.length === 0) {
203
+ // Zero-child case: clear dirty + zero counts, no LLM call, no rebuild_count bump.
204
+ const { changed } = applyRebuildResult(hippoRoot, summary, {
205
+ content: summary.content,
206
+ descendant_count: 0,
207
+ earliest_at: null,
208
+ latest_at: null,
209
+ bumpRebuildCount: false,
210
+ zeroChildren: true,
211
+ actor: 'sleep',
212
+ });
213
+ if (changed)
214
+ result.zeroChildSkipped++;
215
+ // changed=false → race lost / row vanished; silently skip. `refused`
216
+ // is always false here — applyRebuildResult only checks the
217
+ // tombstone when bumpRebuildCount is true (store/summaries.ts).
218
+ return;
219
+ }
220
+ // Derive label from summary's existing entity tags (mirrors clusterFacts)
221
+ const entityTags = summary.tags.filter((t) => t.startsWith('speaker:') || t.startsWith('topic:'));
222
+ const label = entityTags.length > 0
223
+ ? entityTags.map((t) => t.split(':')[1]).join(': ')
224
+ : summary.content.slice(0, 40);
225
+ const newContent = await generateDagSummary(label, children.map((c) => c.content), opts);
226
+ if (!newContent) {
227
+ // LLM null / fetch error → leave dirty for next cycle
228
+ result.failed++;
229
+ return;
230
+ }
231
+ const childCreatedAts = children.map((c) => c.created).sort();
232
+ const { changed, refused } = applyRebuildResult(hippoRoot, summary, {
233
+ content: newContent,
234
+ descendant_count: children.length,
235
+ earliest_at: childCreatedAts[0],
236
+ latest_at: childCreatedAts[childCreatedAts.length - 1],
237
+ bumpRebuildCount: true,
238
+ zeroChildren: false,
239
+ actor: 'sleep',
240
+ });
241
+ if (refused) {
242
+ result.refused++;
243
+ }
244
+ else if (changed) {
245
+ result.rebuilt++;
246
+ }
247
+ // changed=false (refused also false) → race lost; not failure, not
248
+ // success, silently skip
249
+ }
183
250
  /**
184
251
  * v0.30 / E3 — sleep-cycle phase that drains the dirty L2 summary queue.
185
252
  * Thin orchestrator; the heavy lifting lives in store.ts (load + apply)
@@ -221,54 +288,7 @@ export async function rebuildDirtySummaries(hippoRoot, opts) {
221
288
  await new Promise((resolve) => setImmediate(resolve));
222
289
  }
223
290
  try {
224
- const children = loadChildrenOfSummary(hippoRoot, summary.id, summary.tenantId);
225
- if (children.length === 0) {
226
- // Zero-child case: clear dirty + zero counts, no LLM call, no rebuild_count bump.
227
- const { changed } = applyRebuildResult(hippoRoot, summary, {
228
- content: summary.content,
229
- descendant_count: 0,
230
- earliest_at: null,
231
- latest_at: null,
232
- bumpRebuildCount: false,
233
- zeroChildren: true,
234
- actor: 'sleep',
235
- });
236
- if (changed)
237
- result.zeroChildSkipped++;
238
- // changed=false → race lost / row vanished; silently skip. `refused`
239
- // is always false here — applyRebuildResult only checks the
240
- // tombstone when bumpRebuildCount is true (store/summaries.ts).
241
- continue;
242
- }
243
- // Derive label from summary's existing entity tags (mirrors clusterFacts)
244
- const entityTags = summary.tags.filter((t) => t.startsWith('speaker:') || t.startsWith('topic:'));
245
- const label = entityTags.length > 0
246
- ? entityTags.map((t) => t.split(':')[1]).join(': ')
247
- : summary.content.slice(0, 40);
248
- const newContent = await generateDagSummary(label, children.map((c) => c.content), opts);
249
- if (!newContent) {
250
- // LLM null / fetch error → leave dirty for next cycle
251
- result.failed++;
252
- continue;
253
- }
254
- const childCreatedAts = children.map((c) => c.created).sort();
255
- const { changed, refused } = applyRebuildResult(hippoRoot, summary, {
256
- content: newContent,
257
- descendant_count: children.length,
258
- earliest_at: childCreatedAts[0],
259
- latest_at: childCreatedAts[childCreatedAts.length - 1],
260
- bumpRebuildCount: true,
261
- zeroChildren: false,
262
- actor: 'sleep',
263
- });
264
- if (refused) {
265
- result.refused++;
266
- }
267
- else if (changed) {
268
- result.rebuilt++;
269
- }
270
- // changed=false (refused also false) → race lost; not failure, not
271
- // success, silently skip
291
+ await rebuildOneSummary(hippoRoot, summary, opts, result);
272
292
  }
273
293
  catch (err) {
274
294
  // Per-summary failure isolation — one throw doesn't abort the queue.
@@ -281,6 +301,76 @@ export async function rebuildDirtySummaries(hippoRoot, opts) {
281
301
  }
282
302
  return result;
283
303
  }
304
+ // independent-review HIGH #1 fold: cluster ONLY within-tenant.
305
+ // clusterFacts has no tenant awareness; without this partition step a
306
+ // multi-tenant host could form a cluster spanning tenants and produce
307
+ // a single L3 with tenantId='default' that doesn't belong to either
308
+ // child tenant. Fix: bucket by tenantId, run clusterFacts per-tenant,
309
+ // pass tenantId to createMemory.
310
+ function partitionL2sByTenant(unparented) {
311
+ const byTenant = new Map();
312
+ for (const l2 of unparented) {
313
+ const tid = l2.tenantId ?? 'default';
314
+ const key = derivationPartitionKey(tid, l2.scope, l2.origin_project);
315
+ const list = byTenant.get(key) ?? [];
316
+ list.push(l2);
317
+ byTenant.set(key, list);
318
+ }
319
+ return byTenant;
320
+ }
321
+ /** The L3 profile entry for one cluster of L2 summaries. */
322
+ function createProfileEntry(summary, cluster, home) {
323
+ const memberCreatedAts = cluster.members.map((m) => m.created).sort();
324
+ const nowIso = new Date().toISOString();
325
+ const profileEntry = createMemory(summary, {
326
+ layer: Layer.Semantic,
327
+ tags: [...cluster.entityTags, ...neverAutoShareTags(cluster.members), 'dag-entity-profile'],
328
+ confidence: 'inferred',
329
+ dag_level: 3,
330
+ tenantId: home.tenantId, // HIGH #1 fold: thread tenant explicitly
331
+ scope: home.scope,
332
+ baseHalfLifeDays: home.baseHalfLifeDays,
333
+ });
334
+ profileEntry.origin_project = home.originProject;
335
+ profileEntry.descendant_count = cluster.members.length;
336
+ profileEntry.earliest_at = memberCreatedAts[0];
337
+ profileEntry.latest_at = memberCreatedAts[memberCreatedAts.length - 1];
338
+ profileEntry.dag_level_3_built_at = nowIso;
339
+ return profileEntry;
340
+ }
341
+ /** Profile one eligible cluster of L2s, write it, then re-link the L2s under it. */
342
+ async function profileCluster(hippoRoot, cluster, home, opts, result) {
343
+ const summary = await generateDagSummary(cluster.label, cluster.members.map((m) => m.content), opts);
344
+ if (!summary) {
345
+ result.failed++;
346
+ return;
347
+ }
348
+ const profileEntry = createProfileEntry(summary, cluster, home);
349
+ // AT1 (plan §3 containment): per-cluster catch — skip this cluster,
350
+ // count, log once. The member re-linking writes below never run for a
351
+ // skipped cluster (mirrors buildDag above).
352
+ try {
353
+ writeEntry(hippoRoot, profileEntry);
354
+ }
355
+ catch (err) {
356
+ if (err instanceof RejectedValueError) {
357
+ result.rejected++;
358
+ log.warn(`buildEntityProfiles: cluster "${cluster.label}" skipped: profile matches a rejected value`);
359
+ return;
360
+ }
361
+ throw err;
362
+ }
363
+ result.profilesCreated++;
364
+ for (const member of cluster.members) {
365
+ const updated = { ...member, dag_parent_id: profileEntry.id };
366
+ writeEntry(hippoRoot, updated);
367
+ result.l2sLinked++;
368
+ }
369
+ // E3 born-dirty cancellation, same dance as buildDag L161-168 but for
370
+ // L3. Pass source='buildEntityProfiles-clean' to distinguish in audit.
371
+ // Args: (root, id, tenantId, actor, source).
372
+ clearSummaryDirtyAfterBuild(hippoRoot, profileEntry.id, home.tenantId, 'buildEntityProfiles', 'buildEntityProfiles-clean');
373
+ }
284
374
  /**
285
375
  * v0.30 / E5 — build L3 entity profiles by clustering L2 summaries with
286
376
  * shared entity tags. Threshold 2+ L2s per entity. Mirrors buildDag L1->L2
@@ -303,72 +393,18 @@ export async function buildEntityProfiles(hippoRoot, l2Summaries, opts) {
303
393
  const baseHalfLifeDays = loadConfig(hippoRoot).defaultHalfLifeDays;
304
394
  // Only L2 with no L3 parent yet (avoid re-clustering already-profiled L2s).
305
395
  const unparented = l2Summaries.filter((s) => s.dag_level === 2 && !s.dag_parent_id);
306
- // independent-review HIGH #1 fold: cluster ONLY within-tenant.
307
- // clusterFacts has no tenant awareness; without this partition step a
308
- // multi-tenant host could form a cluster spanning tenants and produce
309
- // a single L3 with tenantId='default' that doesn't belong to either
310
- // child tenant. Fix: bucket by tenantId, run clusterFacts per-tenant,
311
- // pass tenantId to createMemory.
312
- const byTenant = new Map();
313
- for (const l2 of unparented) {
314
- const tid = l2.tenantId ?? 'default';
315
- const key = derivationPartitionKey(tid, l2.scope, l2.origin_project);
316
- const list = byTenant.get(key) ?? [];
317
- list.push(l2);
318
- byTenant.set(key, list);
319
- }
320
- for (const [, tenantL2s] of byTenant) {
321
- const tenantId = tenantL2s[0].tenantId ?? 'default';
322
- const scope = derivationScope(tenantL2s[0].scope);
396
+ for (const [, tenantL2s] of partitionL2sByTenant(unparented)) {
397
+ const home = {
398
+ tenantId: tenantL2s[0].tenantId ?? 'default',
399
+ scope: derivationScope(tenantL2s[0].scope),
400
+ originProject: tenantL2s[0].origin_project,
401
+ baseHalfLifeDays,
402
+ };
323
403
  const clusters = clusterFacts(tenantL2s);
324
404
  const eligible = clusters.filter((c) => c.members.length >= 2);
325
405
  result.candidateClusters += eligible.length;
326
406
  for (const cluster of eligible) {
327
- const summary = await generateDagSummary(cluster.label, cluster.members.map((m) => m.content), opts);
328
- if (!summary) {
329
- result.failed++;
330
- continue;
331
- }
332
- const memberCreatedAts = cluster.members.map((m) => m.created).sort();
333
- const nowIso = new Date().toISOString();
334
- const profileEntry = createMemory(summary, {
335
- layer: Layer.Semantic,
336
- tags: [...cluster.entityTags, ...neverAutoShareTags(cluster.members), 'dag-entity-profile'],
337
- confidence: 'inferred',
338
- dag_level: 3,
339
- tenantId, // HIGH #1 fold: thread tenant explicitly
340
- scope,
341
- baseHalfLifeDays,
342
- });
343
- profileEntry.origin_project = tenantL2s[0].origin_project;
344
- profileEntry.descendant_count = cluster.members.length;
345
- profileEntry.earliest_at = memberCreatedAts[0];
346
- profileEntry.latest_at = memberCreatedAts[memberCreatedAts.length - 1];
347
- profileEntry.dag_level_3_built_at = nowIso;
348
- // AT1 (plan §3 containment): per-cluster catch — skip this cluster,
349
- // count, log once. The member re-linking writes below never run for a
350
- // skipped cluster (mirrors buildDag above).
351
- try {
352
- writeEntry(hippoRoot, profileEntry);
353
- }
354
- catch (err) {
355
- if (err instanceof RejectedValueError) {
356
- result.rejected++;
357
- log.warn(`buildEntityProfiles: cluster "${cluster.label}" skipped: profile matches a rejected value`);
358
- continue;
359
- }
360
- throw err;
361
- }
362
- result.profilesCreated++;
363
- for (const member of cluster.members) {
364
- const updated = { ...member, dag_parent_id: profileEntry.id };
365
- writeEntry(hippoRoot, updated);
366
- result.l2sLinked++;
367
- }
368
- // E3 born-dirty cancellation, same dance as buildDag L161-168 but for
369
- // L3. Pass source='buildEntityProfiles-clean' to distinguish in audit.
370
- // Args: (root, id, tenantId, actor, source).
371
- clearSummaryDirtyAfterBuild(hippoRoot, profileEntry.id, tenantId, 'buildEntityProfiles', 'buildEntityProfiles-clean');
407
+ await profileCluster(hippoRoot, cluster, home, opts, result);
372
408
  }
373
409
  }
374
410
  return result;
package/dist/decisions.js CHANGED
@@ -60,6 +60,97 @@ const DECISION_COLS = `
60
60
  // ---------------------------------------------------------------------------
61
61
  // Public API
62
62
  // ---------------------------------------------------------------------------
63
+ /** The legacy `hippo decide` memory mirror for a decision. */
64
+ function buildDecisionMemory(hippoRoot, tenantId, opts) {
65
+ const content = opts.context
66
+ ? `${opts.decisionText}\n\nContext: ${opts.context}`
67
+ : opts.decisionText;
68
+ const tags = ['decision', ...(opts.extraTags ?? [])];
69
+ return createMemory(content, {
70
+ tags,
71
+ layer: Layer.Semantic,
72
+ confidence: 'verified',
73
+ source: 'decision',
74
+ baseHalfLifeDays: objectHalfLifeDays(hippoRoot),
75
+ tenantId,
76
+ });
77
+ }
78
+ // Preflight the supersede target BEFORE inserting the new row. The new
79
+ // row's autoincrement id could otherwise collide with a non-existent
80
+ // supersedesDecisionId (e.g. superseding id 1 on an empty store, where the
81
+ // INSERT below would itself become id 1), making the row supersede itself.
82
+ // Validating first means the new row is never a candidate for its own
83
+ // supersede UPDATE. codex review 2026-05-28 (P1).
84
+ function preflightDecisionSupersede(db, tenantId, supersedesId) {
85
+ // SAFETY: row shape matches the single `status` column named in the SELECT below.
86
+ const pred = db.prepare(`SELECT status FROM decisions WHERE id = ? AND tenant_id = ?`).get(supersedesId, tenantId);
87
+ if (!pred) {
88
+ throw new NotFoundError(`saveDecision: decision ${supersedesId} to supersede not found for tenant ${tenantId}`);
89
+ }
90
+ if (pred.status !== 'active') {
91
+ throw new ConflictError(`saveDecision: decision ${supersedesId} is not active (status='${pred.status}'); only active decisions can be superseded.`);
92
+ }
93
+ }
94
+ function insertDecisionRow(db, memoryId, tenantId, opts, now) {
95
+ const result = db.prepare(`
96
+ INSERT INTO decisions(
97
+ memory_id, tenant_id, decision_text, context,
98
+ status, superseded_by, superseded_at, closed_at, created_at
99
+ ) VALUES (?, ?, ?, ?, 'active', NULL, NULL, NULL, ?)
100
+ `).run(memoryId, tenantId, opts.decisionText, opts.context ?? null, now);
101
+ return Number(result.lastInsertRowid ?? 0);
102
+ }
103
+ // Supersede the (preflight-validated) prior active decision in the SAME
104
+ // SAVEPOINT, atomic with the new row. The `id != decisionId` exclusion is
105
+ // defense-in-depth against the self-match described above; combined with
106
+ // the preflight, a 0-change here is unreachable in the single-writer txn.
107
+ function supersedeDecisionRow(db, tenantId, actor, supersedesId, decisionId, now) {
108
+ const sup = db.prepare(`
109
+ UPDATE decisions
110
+ SET status = 'superseded', superseded_by = ?, superseded_at = ?
111
+ WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
112
+ `).run(decisionId, now, supersedesId, tenantId, decisionId);
113
+ if (sup.changes === 0) {
114
+ throw new BadRequestError(`saveDecision: decision ${supersedesId} could not be superseded (no longer active or self-reference).`);
115
+ }
116
+ appendAuditEvent(db, {
117
+ tenantId,
118
+ actor,
119
+ op: 'decision_supersede',
120
+ targetId: String(supersedesId),
121
+ metadata: {
122
+ decision_id: supersedesId,
123
+ superseded_by: decisionId,
124
+ },
125
+ });
126
+ }
127
+ /** The afterWrite body: preflight, INSERT, supersede, reload, create audit, all in one SAVEPOINT. */
128
+ function writeDecisionRow(db, memoryId, tenantId, opts, actor, now) {
129
+ if (opts.supersedesDecisionId !== undefined) {
130
+ preflightDecisionSupersede(db, tenantId, opts.supersedesDecisionId);
131
+ }
132
+ const decisionId = insertDecisionRow(db, memoryId, tenantId, opts, now);
133
+ if (opts.supersedesDecisionId !== undefined) {
134
+ supersedeDecisionRow(db, tenantId, actor, opts.supersedesDecisionId, decisionId, now);
135
+ }
136
+ // SAFETY: row's shape matches the columns named in DECISION_COLS above.
137
+ const row = db.prepare(`SELECT ${DECISION_COLS} FROM decisions WHERE id = ?`)
138
+ .get(decisionId);
139
+ if (!row)
140
+ throw new Error('saveDecision: failed to reload saved decision row');
141
+ // GDPR-light metadata: id + flag only, no decision_text.
142
+ appendAuditEvent(db, {
143
+ tenantId,
144
+ actor,
145
+ op: 'decision_create',
146
+ targetId: String(decisionId),
147
+ metadata: {
148
+ decision_id: decisionId,
149
+ has_context: opts.context !== undefined && opts.context !== null && opts.context !== '',
150
+ },
151
+ });
152
+ return row;
153
+ }
63
154
  /**
64
155
  * Create a decision. Writes the memory mirror + the decisions row atomically
65
156
  * inside writeEntry's SAVEPOINT 'write_entry'. When supersedesDecisionId is
@@ -77,88 +168,14 @@ export function saveDecision(hippoRoot, tenantId, opts, actor = 'cli') {
77
168
  if (!opts.decisionText)
78
169
  throw new BadRequestError('saveDecision: decisionText is required');
79
170
  const now = new Date().toISOString();
80
- const content = opts.context
81
- ? `${opts.decisionText}\n\nContext: ${opts.context}`
82
- : opts.decisionText;
83
- const tags = ['decision', ...(opts.extraTags ?? [])];
84
- const mem = createMemory(content, {
85
- tags,
86
- layer: Layer.Semantic,
87
- confidence: 'verified',
88
- source: 'decision',
89
- baseHalfLifeDays: objectHalfLifeDays(hippoRoot),
90
- tenantId,
91
- });
171
+ const mem = buildDecisionMemory(hippoRoot, tenantId, opts);
92
172
  // Populated inside afterWrite so the INSERT, the supersede UPDATE, and the
93
173
  // memory write all share one SAVEPOINT.
94
174
  let savedRow;
95
175
  writeEntry(hippoRoot, mem, {
96
176
  actor,
97
177
  afterWrite: (db, memoryId) => {
98
- // Preflight the supersede target BEFORE inserting the new row. The new
99
- // row's autoincrement id could otherwise collide with a non-existent
100
- // supersedesDecisionId (e.g. superseding id 1 on an empty store, where the
101
- // INSERT below would itself become id 1), making the row supersede itself.
102
- // Validating first means the new row is never a candidate for its own
103
- // supersede UPDATE. codex review 2026-05-28 (P1).
104
- if (opts.supersedesDecisionId !== undefined) {
105
- // SAFETY: row shape matches the single `status` column named in the SELECT below.
106
- const pred = db.prepare(`SELECT status FROM decisions WHERE id = ? AND tenant_id = ?`).get(opts.supersedesDecisionId, tenantId);
107
- if (!pred) {
108
- throw new NotFoundError(`saveDecision: decision ${opts.supersedesDecisionId} to supersede not found for tenant ${tenantId}`);
109
- }
110
- if (pred.status !== 'active') {
111
- throw new ConflictError(`saveDecision: decision ${opts.supersedesDecisionId} is not active (status='${pred.status}'); only active decisions can be superseded.`);
112
- }
113
- }
114
- const result = db.prepare(`
115
- INSERT INTO decisions(
116
- memory_id, tenant_id, decision_text, context,
117
- status, superseded_by, superseded_at, closed_at, created_at
118
- ) VALUES (?, ?, ?, ?, 'active', NULL, NULL, NULL, ?)
119
- `).run(memoryId, tenantId, opts.decisionText, opts.context ?? null, now);
120
- const decisionId = Number(result.lastInsertRowid ?? 0);
121
- // Supersede the (preflight-validated) prior active decision in the SAME
122
- // SAVEPOINT, atomic with the new row. The `id != decisionId` exclusion is
123
- // defense-in-depth against the self-match described above; combined with
124
- // the preflight, a 0-change here is unreachable in the single-writer txn.
125
- if (opts.supersedesDecisionId !== undefined) {
126
- const sup = db.prepare(`
127
- UPDATE decisions
128
- SET status = 'superseded', superseded_by = ?, superseded_at = ?
129
- WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
130
- `).run(decisionId, now, opts.supersedesDecisionId, tenantId, decisionId);
131
- if (sup.changes === 0) {
132
- throw new BadRequestError(`saveDecision: decision ${opts.supersedesDecisionId} could not be superseded (no longer active or self-reference).`);
133
- }
134
- appendAuditEvent(db, {
135
- tenantId,
136
- actor,
137
- op: 'decision_supersede',
138
- targetId: String(opts.supersedesDecisionId),
139
- metadata: {
140
- decision_id: opts.supersedesDecisionId,
141
- superseded_by: decisionId,
142
- },
143
- });
144
- }
145
- // SAFETY: row's shape matches the columns named in DECISION_COLS above.
146
- const row = db.prepare(`SELECT ${DECISION_COLS} FROM decisions WHERE id = ?`)
147
- .get(decisionId);
148
- if (!row)
149
- throw new Error('saveDecision: failed to reload saved decision row');
150
- savedRow = row;
151
- // GDPR-light metadata: id + flag only, no decision_text.
152
- appendAuditEvent(db, {
153
- tenantId,
154
- actor,
155
- op: 'decision_create',
156
- targetId: String(decisionId),
157
- metadata: {
158
- decision_id: decisionId,
159
- has_context: opts.context !== undefined && opts.context !== null && opts.context !== '',
160
- },
161
- });
178
+ savedRow = writeDecisionRow(db, memoryId, tenantId, opts, actor, now);
162
179
  },
163
180
  // Post-commit hook: mark the tenant's graph dirty AFTER the DB row commits
164
181
  // but BEFORE the markdown mirrors are written, so a mirror-write failure can