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.
- package/README.md +28 -53
- package/dist/agent-memories/apply.d.ts +1 -1
- package/dist/agent-memories/claude-code.d.ts +3 -1
- package/dist/agent-memories/claude-code.js +53 -9
- package/dist/agent-memories/sync.d.ts +3 -3
- package/dist/agent-memories/sync.js +14 -6
- package/dist/agent-memories/types.d.ts +0 -2
- package/dist/api/assemble.js +55 -54
- package/dist/api/context-select.d.ts +49 -0
- package/dist/api/context-select.js +344 -0
- package/dist/api/context.d.ts +2 -2
- package/dist/api/context.js +195 -522
- package/dist/api/drill-down.js +36 -33
- package/dist/api/promote.js +55 -66
- package/dist/api/recall.js +303 -438
- package/dist/api/sleep.js +203 -218
- package/dist/capture/compact.d.ts +1 -1
- package/dist/capture/compact.js +2 -2
- package/dist/cli/briefs.js +324 -306
- package/dist/cli/context.js +44 -34
- package/dist/cli/continuity.js +283 -271
- package/dist/cli/curate.js +35 -34
- package/dist/cli/decisions.js +333 -333
- package/dist/cli/explain.js +66 -60
- package/dist/cli/maintenance.js +62 -51
- package/dist/cli/playbooks.js +387 -370
- package/dist/cli/projects.js +8 -5
- package/dist/cli/recall.js +28 -43
- package/dist/cli/remember.js +113 -70
- package/dist/cli/session-hooks.js +100 -90
- package/dist/cli/setup.js +267 -246
- package/dist/cli/status.js +73 -64
- package/dist/cli/transfer.js +85 -99
- package/dist/compaction-record.d.ts +0 -2
- package/dist/compaction-record.js +1 -1
- package/dist/customer-notes.js +77 -68
- package/dist/dag.js +222 -186
- package/dist/decisions.js +93 -76
- package/dist/doctor.js +11 -7
- package/dist/goals.js +99 -86
- package/dist/incidents.js +45 -38
- package/dist/policies.js +85 -68
- package/dist/processes.js +87 -71
- package/dist/project-briefs.js +135 -108
- package/dist/project-merge.d.ts +12 -4
- package/dist/project-merge.js +130 -45
- package/dist/shared.d.ts +9 -0
- package/dist/shared.js +10 -8
- package/dist/skills.js +81 -65
- package/dist/store/search-rows.d.ts +2 -2
- package/dist/store/search-rows.js +15 -9
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- 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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|